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

580 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-06-07 06:35 +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 

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

43 

44 

45class Span(NamedTuple): 

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

47 

48 start: int 

49 """Span start index.""" 

50 end: int 

51 """Span end index.""" 

52 style: Union[str, Style] 

53 """Style associated with the span.""" 

54 

55 def __repr__(self) -> str: 

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

57 

58 def __bool__(self) -> bool: 

59 return self.end > self.start 

60 

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

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

63 

64 if offset < self.start: 

65 return self, None 

66 if offset >= self.end: 

67 return self, None 

68 

69 start, end, style = self 

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

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

72 return span1, span2 

73 

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

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

76 

77 Args: 

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

79 

80 Returns: 

81 TextSpan: A new TextSpan with adjusted position. 

82 """ 

83 start, end, style = self 

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

85 

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

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

88 

89 Args: 

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

91 

92 Returns: 

93 Span: A new (possibly smaller) span. 

94 """ 

95 start, end, style = self 

96 if offset >= end: 

97 return self 

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

99 

100 

101class Text(JupyterMixin): 

102 """Text with color / style. 

103 

104 Args: 

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

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

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

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

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

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

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

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

113 """ 

114 

115 __slots__ = [ 

116 "_text", 

117 "style", 

118 "justify", 

119 "overflow", 

120 "no_wrap", 

121 "end", 

122 "tab_size", 

123 "_spans", 

124 "_length", 

125 ] 

126 

127 def __init__( 

128 self, 

129 text: str = "", 

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

131 *, 

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

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

134 no_wrap: Optional[bool] = None, 

135 end: str = "\n", 

136 tab_size: Optional[int] = 8, 

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

138 ) -> None: 

139 sanitized_text = strip_control_codes(text) 

140 self._text = [sanitized_text] 

141 self.style = style 

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

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

144 self.no_wrap = no_wrap 

145 self.end = end 

146 self.tab_size = tab_size 

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

148 self._length: int = len(sanitized_text) 

149 

150 def __len__(self) -> int: 

151 return self._length 

152 

153 def __bool__(self) -> bool: 

154 return bool(self._length) 

155 

156 def __str__(self) -> str: 

157 return self.plain 

158 

159 def __repr__(self) -> str: 

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

161 

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

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

164 result = self.copy() 

165 result.append(other) 

166 return result 

167 return NotImplemented 

168 

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

170 if not isinstance(other, Text): 

171 return NotImplemented 

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

173 

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

175 if isinstance(other, str): 

176 return other in self.plain 

177 elif isinstance(other, Text): 

178 return other.plain in self.plain 

179 return False 

180 

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

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

183 _Span = Span 

184 text = Text( 

185 self.plain[offset], 

186 spans=[ 

187 _Span(0, 1, style) 

188 for start, end, style in self._spans 

189 if end > offset >= start 

190 ], 

191 end="", 

192 ) 

193 return text 

194 

195 if isinstance(slice, int): 

196 return get_text_at(slice) 

197 else: 

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

199 if step == 1: 

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

201 return lines[1] 

202 else: 

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

204 # For now, its not required 

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

206 

207 @property 

208 def cell_len(self) -> int: 

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

210 return cell_len(self.plain) 

211 

212 @property 

213 def markup(self) -> str: 

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

215 

216 Returns: 

217 str: A string potentially creating markup tags. 

218 """ 

219 from .markup import escape 

220 

221 output: List[str] = [] 

222 

223 plain = self.plain 

224 markup_spans = [ 

225 (0, False, self.style), 

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

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

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

229 ] 

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

231 position = 0 

232 append = output.append 

233 for offset, closing, style in markup_spans: 

234 if offset > position: 

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

236 position = offset 

237 if style: 

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

239 markup = "".join(output) 

240 return markup 

241 

242 @classmethod 

243 def from_markup( 

244 cls, 

245 text: str, 

246 *, 

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

248 emoji: bool = True, 

249 emoji_variant: Optional[EmojiVariant] = None, 

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

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

252 end: str = "\n", 

253 ) -> "Text": 

254 """Create Text instance from markup. 

255 

256 Args: 

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

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

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

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

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

262 

263 Returns: 

264 Text: A Text instance with markup rendered. 

265 """ 

266 from .markup import render 

267 

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

269 rendered_text.justify = justify 

270 rendered_text.overflow = overflow 

271 rendered_text.end = end 

272 return rendered_text 

273 

274 @classmethod 

275 def from_ansi( 

276 cls, 

277 text: str, 

278 *, 

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

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

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

282 no_wrap: Optional[bool] = None, 

283 end: str = "\n", 

284 tab_size: Optional[int] = 8, 

285 ) -> "Text": 

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

287 

288 Args: 

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

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

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

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

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

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

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

296 """ 

297 from .ansi import AnsiDecoder 

298 

299 joiner = Text( 

300 "\n", 

301 justify=justify, 

302 overflow=overflow, 

303 no_wrap=no_wrap, 

304 end=end, 

305 tab_size=tab_size, 

306 style=style, 

307 ) 

308 decoder = AnsiDecoder() 

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

310 return result 

311 

312 @classmethod 

313 def styled( 

314 cls, 

315 text: str, 

316 style: StyleType = "", 

317 *, 

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

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

320 ) -> "Text": 

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

322 to pad the text when it is justified. 

323 

324 Args: 

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

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

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

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

329 

330 Returns: 

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

332 """ 

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

334 styled_text.stylize(style) 

335 return styled_text 

336 

337 @classmethod 

338 def assemble( 

339 cls, 

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

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

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

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

344 no_wrap: Optional[bool] = None, 

345 end: str = "\n", 

346 tab_size: int = 8, 

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

348 ) -> "Text": 

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

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

351 

352 Args: 

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

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

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

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

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

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

359 

360 Returns: 

361 Text: A new text instance. 

362 """ 

363 text = cls( 

364 style=style, 

365 justify=justify, 

366 overflow=overflow, 

367 no_wrap=no_wrap, 

368 end=end, 

369 tab_size=tab_size, 

370 ) 

371 append = text.append 

372 _Text = Text 

373 for part in parts: 

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

375 append(part) 

376 else: 

377 append(*part) 

378 if meta: 

379 text.apply_meta(meta) 

380 return text 

381 

382 @property 

383 def plain(self) -> str: 

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

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

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

387 return self._text[0] 

388 

389 @plain.setter 

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

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

392 if new_text != self.plain: 

393 sanitized_text = strip_control_codes(new_text) 

394 self._text[:] = [sanitized_text] 

395 old_length = self._length 

396 self._length = len(sanitized_text) 

397 if old_length > self._length: 

398 self._trim_spans() 

399 

400 @property 

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

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

403 return self._spans 

404 

405 @spans.setter 

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

407 """Set spans.""" 

408 self._spans = spans[:] 

409 

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

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

412 copy_self = Text( 

413 plain, 

414 style=self.style, 

415 justify=self.justify, 

416 overflow=self.overflow, 

417 no_wrap=self.no_wrap, 

418 end=self.end, 

419 tab_size=self.tab_size, 

420 ) 

421 return copy_self 

422 

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

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

425 copy_self = Text( 

426 self.plain, 

427 style=self.style, 

428 justify=self.justify, 

429 overflow=self.overflow, 

430 no_wrap=self.no_wrap, 

431 end=self.end, 

432 tab_size=self.tab_size, 

433 ) 

434 copy_self._spans[:] = self._spans 

435 return copy_self 

436 

437 def stylize( 

438 self, 

439 style: Union[str, Style], 

440 start: int = 0, 

441 end: Optional[int] = None, 

442 ) -> None: 

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

444 

445 Args: 

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

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

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

449 """ 

450 if style: 

451 length = len(self) 

452 if start < 0: 

453 start = length + start 

454 if end is None: 

455 end = length 

456 if end < 0: 

457 end = length + end 

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

459 # Span not in text or not valid 

460 return 

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

462 

463 def stylize_before( 

464 self, 

465 style: Union[str, Style], 

466 start: int = 0, 

467 end: Optional[int] = None, 

468 ) -> None: 

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

470 

471 Args: 

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

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

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

475 """ 

476 if style: 

477 length = len(self) 

478 if start < 0: 

479 start = length + start 

480 if end is None: 

481 end = length 

482 if end < 0: 

483 end = length + end 

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

485 # Span not in text or not valid 

486 return 

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

488 

489 def apply_meta( 

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

491 ) -> None: 

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

493 

494 Args: 

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

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

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

498 

499 """ 

500 style = Style.from_meta(meta) 

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

502 

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

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

505 

506 Example: 

507 >>> from rich.text import Text 

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

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

510 

511 Args: 

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

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

514 

515 Returns: 

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

517 """ 

518 meta = {} if meta is None else meta 

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

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

521 return self 

522 

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

524 """Remove a suffix if it exists. 

525 

526 Args: 

527 suffix (str): Suffix to remove. 

528 """ 

529 if self.plain.endswith(suffix): 

530 self.right_crop(len(suffix)) 

531 

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

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

534 

535 Args: 

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

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

538 

539 Returns: 

540 Style: A Style instance. 

541 """ 

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

543 if offset < 0: 

544 offset = len(self) + offset 

545 get_style = console.get_style 

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

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

548 if end > offset >= start: 

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

550 return style 

551 

552 def highlight_regex( 

553 self, 

554 re_highlight: str, 

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

556 *, 

557 style_prefix: str = "", 

558 ) -> int: 

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

560 translated to styles. 

561 

562 Args: 

563 re_highlight (str): A regular expression. 

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

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

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

567 

568 Returns: 

569 int: Number of regex matches 

570 """ 

571 count = 0 

572 append_span = self._spans.append 

573 _Span = Span 

574 plain = self.plain 

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

576 get_span = match.span 

577 if style: 

578 start, end = get_span() 

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

580 if match_style is not None and end > start: 

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

582 

583 count += 1 

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

585 start, end = get_span(name) 

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

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

588 return count 

589 

590 def highlight_words( 

591 self, 

592 words: Iterable[str], 

593 style: Union[str, Style], 

594 *, 

595 case_sensitive: bool = True, 

596 ) -> int: 

597 """Highlight words with a style. 

598 

599 Args: 

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

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

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

603 

604 Returns: 

605 int: Number of words highlighted. 

606 """ 

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

608 add_span = self._spans.append 

609 count = 0 

610 _Span = Span 

611 for match in re.finditer( 

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

613 ): 

614 start, end = match.span(0) 

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

616 count += 1 

617 return count 

618 

619 def rstrip(self) -> None: 

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

621 self.plain = self.plain.rstrip() 

622 

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

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

625 

626 Args: 

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

628 """ 

629 text_length = len(self) 

630 if text_length > size: 

631 excess = text_length - size 

632 whitespace_match = _re_whitespace.search(self.plain) 

633 if whitespace_match is not None: 

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

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

636 

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

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

639 length = len(self) 

640 if length != new_length: 

641 if length < new_length: 

642 self.pad_right(new_length - length) 

643 else: 

644 self.right_crop(length - new_length) 

645 

646 def __rich_console__( 

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

648 ) -> Iterable[Segment]: 

649 tab_size: int = console.tab_size or self.tab_size or 8 

650 justify = self.justify or options.justify or DEFAULT_JUSTIFY 

651 

652 overflow = self.overflow or options.overflow or DEFAULT_OVERFLOW 

653 

654 lines = self.wrap( 

655 console, 

656 options.max_width, 

657 justify=justify, 

658 overflow=overflow, 

659 tab_size=tab_size or 8, 

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

661 ) 

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

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

664 

665 def __rich_measure__( 

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

667 ) -> Measurement: 

668 text = self.plain 

669 lines = text.splitlines() 

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

671 words = text.split() 

672 min_text_width = ( 

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

674 ) 

675 return Measurement(min_text_width, max_text_width) 

676 

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

678 """Render the text as Segments. 

679 

680 Args: 

681 console (Console): Console instance. 

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

683 

684 Returns: 

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

686 """ 

687 _Segment = Segment 

688 text = self.plain 

689 if not self._spans: 

690 yield Segment(text) 

691 if end: 

692 yield _Segment(end) 

693 return 

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

695 

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

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

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

699 

700 spans = [ 

701 (0, False, 0), 

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

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

704 (len(text), True, 0), 

705 ] 

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

707 

708 stack: List[int] = [] 

709 stack_append = stack.append 

710 stack_pop = stack.remove 

711 

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

713 style_cache_get = style_cache.get 

714 combine = Style.combine 

715 

716 def get_current_style() -> Style: 

717 """Construct current style from stack.""" 

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

719 cached_style = style_cache_get(styles) 

720 if cached_style is not None: 

721 return cached_style 

722 current_style = combine(styles) 

723 style_cache[styles] = current_style 

724 return current_style 

725 

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

727 if leaving: 

728 stack_pop(style_id) 

729 else: 

730 stack_append(style_id) 

731 if next_offset > offset: 

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

733 if end: 

734 yield _Segment(end) 

735 

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

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

738 

739 Args: 

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

741 

742 Returns: 

743 Text: A new text instance containing join text. 

744 """ 

745 

746 new_text = self.blank_copy() 

747 

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

749 if self.plain: 

750 for last, line in loop_last(lines): 

751 yield line 

752 if not last: 

753 yield self 

754 else: 

755 yield from lines 

756 

757 extend_text = new_text._text.extend 

758 append_span = new_text._spans.append 

759 extend_spans = new_text._spans.extend 

760 offset = 0 

761 _Span = Span 

762 

763 for text in iter_text(): 

764 extend_text(text._text) 

765 if text.style: 

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

767 extend_spans( 

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

769 for start, end, style in text._spans 

770 ) 

771 offset += len(text) 

772 new_text._length = offset 

773 return new_text 

774 

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

776 """Converts tabs to spaces. 

777 

778 Args: 

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

780 

781 """ 

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

783 return 

784 pos = 0 

785 if tab_size is None: 

786 tab_size = self.tab_size 

787 assert tab_size is not None 

788 result = self.blank_copy() 

789 append = result.append 

790 

791 _style = self.style 

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

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

794 for part in parts: 

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

796 part._text = [part.plain[:-1] + " "] 

797 append(part) 

798 pos += len(part) 

799 spaces = tab_size - ((pos - 1) % tab_size) - 1 

800 if spaces: 

801 append(" " * spaces, _style) 

802 pos += spaces 

803 else: 

804 append(part) 

805 self._text = [result.plain] 

806 self._length = len(self.plain) 

807 self._spans[:] = result._spans 

808 

809 def truncate( 

810 self, 

811 max_width: int, 

812 *, 

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

814 pad: bool = False, 

815 ) -> None: 

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

817 

818 Args: 

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

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

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

822 """ 

823 _overflow = overflow or self.overflow or DEFAULT_OVERFLOW 

824 if _overflow != "ignore": 

825 length = cell_len(self.plain) 

826 if length > max_width: 

827 if _overflow == "ellipsis": 

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

829 else: 

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

831 if pad and length < max_width: 

832 spaces = max_width - length 

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

834 self._length = len(self.plain) 

835 

836 def _trim_spans(self) -> None: 

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

838 max_offset = len(self.plain) 

839 _Span = Span 

840 self._spans[:] = [ 

841 ( 

842 span 

843 if span.end < max_offset 

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

845 ) 

846 for span in self._spans 

847 if span.start < max_offset 

848 ] 

849 

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

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

852 

853 Args: 

854 count (int): Width of padding. 

855 """ 

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

857 if count: 

858 pad_characters = character * count 

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

860 _Span = Span 

861 self._spans[:] = [ 

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

863 for start, end, style in self._spans 

864 ] 

865 

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

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

868 

869 Args: 

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

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

872 """ 

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

874 if count: 

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

876 _Span = Span 

877 self._spans[:] = [ 

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

879 for start, end, style in self._spans 

880 ] 

881 

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

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

884 

885 Args: 

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

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

888 """ 

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

890 if count: 

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

892 

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

894 """Align text to a given width. 

895 

896 Args: 

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

898 width (int): Desired width. 

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

900 """ 

901 self.truncate(width) 

902 excess_space = width - cell_len(self.plain) 

903 if excess_space: 

904 if align == "left": 

905 self.pad_right(excess_space, character) 

906 elif align == "center": 

907 left = excess_space // 2 

908 self.pad_left(left, character) 

909 self.pad_right(excess_space - left, character) 

910 else: 

911 self.pad_left(excess_space, character) 

912 

913 def append( 

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

915 ) -> "Text": 

916 """Add text with an optional style. 

917 

918 Args: 

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

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

921 

922 Returns: 

923 Text: Returns self for chaining. 

924 """ 

925 

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

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

928 

929 if len(text): 

930 if isinstance(text, str): 

931 sanitized_text = strip_control_codes(text) 

932 self._text.append(sanitized_text) 

933 offset = len(self) 

934 text_length = len(sanitized_text) 

935 if style is not None: 

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

937 self._length += text_length 

938 elif isinstance(text, Text): 

939 _Span = Span 

940 if style is not None: 

941 raise ValueError( 

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

943 ) 

944 text_length = self._length 

945 if text.style is not None: 

946 self._spans.append( 

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

948 ) 

949 self._text.append(text.plain) 

950 self._spans.extend( 

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

952 for start, end, style in text._spans 

953 ) 

954 self._length += len(text) 

955 return self 

956 

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

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

959 only works for Text. 

960 

961 Returns: 

962 Text: Returns self for chaining. 

963 """ 

964 _Span = Span 

965 text_length = self._length 

966 if text.style is not None: 

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

968 self._text.append(text.plain) 

969 self._spans.extend( 

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

971 for start, end, style in text._spans 

972 ) 

973 self._length += len(text) 

974 return self 

975 

976 def append_tokens( 

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

978 ) -> "Text": 

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

980 

981 Args: 

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

983 

984 Returns: 

985 Text: Returns self for chaining. 

986 """ 

987 append_text = self._text.append 

988 append_span = self._spans.append 

989 _Span = Span 

990 offset = len(self) 

991 for content, style in tokens: 

992 append_text(content) 

993 if style is not None: 

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

995 offset += len(content) 

996 self._length = offset 

997 return self 

998 

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

1000 """Copy styles from another Text instance. 

1001 

1002 Args: 

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

1004 """ 

1005 self._spans.extend(text._spans) 

1006 

1007 def split( 

1008 self, 

1009 separator: str = "\n", 

1010 *, 

1011 include_separator: bool = False, 

1012 allow_blank: bool = False, 

1013 ) -> Lines: 

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

1015 

1016 Args: 

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

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

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

1020 

1021 Returns: 

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

1023 """ 

1024 assert separator, "separator must not be empty" 

1025 

1026 text = self.plain 

1027 if separator not in text: 

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

1029 

1030 if include_separator: 

1031 lines = self.divide( 

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

1033 ) 

1034 else: 

1035 

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

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

1038 start, end = match.span() 

1039 yield start 

1040 yield end 

1041 

1042 lines = Lines( 

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

1044 ) 

1045 

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

1047 lines.pop() 

1048 

1049 return lines 

1050 

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

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

1053 

1054 Args: 

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

1056 

1057 Returns: 

1058 Lines: New RichText instances between offsets. 

1059 """ 

1060 _offsets = list(offsets) 

1061 

1062 if not _offsets: 

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

1064 

1065 text = self.plain 

1066 text_length = len(text) 

1067 divide_offsets = [0, *_offsets, text_length] 

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

1069 

1070 style = self.style 

1071 justify = self.justify 

1072 overflow = self.overflow 

1073 _Text = Text 

1074 new_lines = Lines( 

1075 _Text( 

1076 text[start:end], 

1077 style=style, 

1078 justify=justify, 

1079 overflow=overflow, 

1080 ) 

1081 for start, end in line_ranges 

1082 ) 

1083 if not self._spans: 

1084 return new_lines 

1085 

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

1087 line_count = len(line_ranges) 

1088 _Span = Span 

1089 

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

1091 

1092 lower_bound = 0 

1093 upper_bound = line_count 

1094 start_line_no = (lower_bound + upper_bound) // 2 

1095 

1096 while True: 

1097 line_start, line_end = line_ranges[start_line_no] 

1098 if span_start < line_start: 

1099 upper_bound = start_line_no - 1 

1100 elif span_start > line_end: 

1101 lower_bound = start_line_no + 1 

1102 else: 

1103 break 

1104 start_line_no = (lower_bound + upper_bound) // 2 

1105 

1106 if span_end < line_end: 

1107 end_line_no = start_line_no 

1108 else: 

1109 end_line_no = lower_bound = start_line_no 

1110 upper_bound = line_count 

1111 

1112 while True: 

1113 line_start, line_end = line_ranges[end_line_no] 

1114 if span_end < line_start: 

1115 upper_bound = end_line_no - 1 

1116 elif span_end > line_end: 

1117 lower_bound = end_line_no + 1 

1118 else: 

1119 break 

1120 end_line_no = (lower_bound + upper_bound) // 2 

1121 

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

1123 line_start, line_end = line_ranges[line_no] 

1124 new_start = max(0, span_start - line_start) 

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

1126 if new_end > new_start: 

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

1128 

1129 return new_lines 

1130 

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

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

1133 max_offset = len(self.plain) - amount 

1134 _Span = Span 

1135 self._spans[:] = [ 

1136 ( 

1137 span 

1138 if span.end < max_offset 

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

1140 ) 

1141 for span in self._spans 

1142 if span.start < max_offset 

1143 ] 

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

1145 self._length -= amount 

1146 

1147 def wrap( 

1148 self, 

1149 console: "Console", 

1150 width: int, 

1151 *, 

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

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

1154 tab_size: int = 8, 

1155 no_wrap: Optional[bool] = None, 

1156 ) -> Lines: 

1157 """Word wrap the text. 

1158 

1159 Args: 

1160 console (Console): Console instance. 

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

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

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

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

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

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

1167 

1168 Returns: 

1169 Lines: Number of lines. 

1170 """ 

1171 wrap_justify = justify or self.justify or DEFAULT_JUSTIFY 

1172 wrap_overflow = overflow or self.overflow or DEFAULT_OVERFLOW 

1173 

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

1175 

1176 lines = Lines() 

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

1178 if "\t" in line: 

1179 line.expand_tabs(tab_size) 

1180 if no_wrap: 

1181 new_lines = Lines([line]) 

1182 else: 

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

1184 new_lines = line.divide(offsets) 

1185 for line in new_lines: 

1186 line.rstrip_end(width) 

1187 if wrap_justify: 

1188 new_lines.justify( 

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

1190 ) 

1191 for line in new_lines: 

1192 line.truncate(width, overflow=wrap_overflow) 

1193 lines.extend(new_lines) 

1194 return lines 

1195 

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

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

1198 

1199 Args: 

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

1201 

1202 Returns: 

1203 Lines: Lines container. 

1204 """ 

1205 lines: Lines = Lines() 

1206 append = lines.append 

1207 for line in self.split(): 

1208 line.set_length(width) 

1209 append(line) 

1210 return lines 

1211 

1212 def detect_indentation(self) -> int: 

1213 """Auto-detect indentation of code. 

1214 

1215 Returns: 

1216 int: Number of spaces used to indent code. 

1217 """ 

1218 

1219 _indentations = { 

1220 len(match.group(1)) 

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

1222 } 

1223 

1224 try: 

1225 indentation = ( 

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

1227 ) 

1228 except TypeError: 

1229 indentation = 1 

1230 

1231 return indentation 

1232 

1233 def with_indent_guides( 

1234 self, 

1235 indent_size: Optional[int] = None, 

1236 *, 

1237 character: str = "│", 

1238 style: StyleType = "dim green", 

1239 ) -> "Text": 

1240 """Adds indent guide lines to text. 

1241 

1242 Args: 

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

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

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

1246 

1247 Returns: 

1248 Text: New text with indentation guides. 

1249 """ 

1250 

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

1252 

1253 text = self.copy() 

1254 text.expand_tabs() 

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

1256 

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

1258 new_lines: List[Text] = [] 

1259 add_line = new_lines.append 

1260 blank_lines = 0 

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

1262 match = re_indent.match(line.plain) 

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

1264 blank_lines += 1 

1265 continue 

1266 indent = match.group(1) 

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

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

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

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

1271 if blank_lines: 

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

1273 blank_lines = 0 

1274 add_line(line) 

1275 if blank_lines: 

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

1277 

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

1279 return new_text 

1280 

1281 

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

1283 from rich.console import Console 

1284 

1285 text = Text( 

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

1287 ) 

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

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

1290 

1291 console = Console() 

1292 

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

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

1295 console.print() 

1296 

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

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

1299 console.print() 

1300 

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

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

1303 console.print() 

1304 

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

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

1307 console.print()