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

580 statements  

« prev     ^ index     » next       coverage.py v7.0.1, created at 2022-12-25 06:11 +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 ( 

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

58 if (isinstance(self.style, Style) and self.style._meta) 

59 else f"Span({self.start}, {self.end}, {repr(self.style)})" 

60 ) 

61 

62 def __bool__(self) -> bool: 

63 return self.end > self.start 

64 

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

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

67 

68 if offset < self.start: 

69 return self, None 

70 if offset >= self.end: 

71 return self, None 

72 

73 start, end, style = self 

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

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

76 return span1, span2 

77 

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

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

80 

81 Args: 

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

83 

84 Returns: 

85 TextSpan: A new TextSpan with adjusted position. 

86 """ 

87 start, end, style = self 

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

89 

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

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

92 

93 Args: 

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

95 

96 Returns: 

97 Span: A new (possibly smaller) span. 

98 """ 

99 start, end, style = self 

100 if offset >= end: 

101 return self 

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

103 

104 

105class Text(JupyterMixin): 

106 """Text with color / style. 

107 

108 Args: 

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

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

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

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

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

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

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

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

117 """ 

118 

119 __slots__ = [ 

120 "_text", 

121 "style", 

122 "justify", 

123 "overflow", 

124 "no_wrap", 

125 "end", 

126 "tab_size", 

127 "_spans", 

128 "_length", 

129 ] 

130 

131 def __init__( 

132 self, 

133 text: str = "", 

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

135 *, 

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

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

138 no_wrap: Optional[bool] = None, 

139 end: str = "\n", 

140 tab_size: Optional[int] = 8, 

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

142 ) -> None: 

143 sanitized_text = strip_control_codes(text) 

144 self._text = [sanitized_text] 

145 self.style = style 

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

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

148 self.no_wrap = no_wrap 

149 self.end = end 

150 self.tab_size = tab_size 

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

152 self._length: int = len(sanitized_text) 

153 

154 def __len__(self) -> int: 

155 return self._length 

156 

157 def __bool__(self) -> bool: 

158 return bool(self._length) 

159 

160 def __str__(self) -> str: 

161 return self.plain 

162 

163 def __repr__(self) -> str: 

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

165 

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

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

168 result = self.copy() 

169 result.append(other) 

170 return result 

171 return NotImplemented 

172 

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

174 if not isinstance(other, Text): 

175 return NotImplemented 

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

177 

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

179 if isinstance(other, str): 

180 return other in self.plain 

181 elif isinstance(other, Text): 

182 return other.plain in self.plain 

183 return False 

184 

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

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

187 _Span = Span 

188 text = Text( 

189 self.plain[offset], 

190 spans=[ 

191 _Span(0, 1, style) 

192 for start, end, style in self._spans 

193 if end > offset >= start 

194 ], 

195 end="", 

196 ) 

197 return text 

198 

199 if isinstance(slice, int): 

200 return get_text_at(slice) 

201 else: 

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

203 if step == 1: 

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

205 return lines[1] 

206 else: 

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

208 # For now, its not required 

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

210 

211 @property 

212 def cell_len(self) -> int: 

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

214 return cell_len(self.plain) 

215 

216 @property 

217 def markup(self) -> str: 

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

219 

220 Returns: 

221 str: A string potentially creating markup tags. 

222 """ 

223 from .markup import escape 

224 

225 output: List[str] = [] 

226 

227 plain = self.plain 

228 markup_spans = [ 

229 (0, False, self.style), 

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

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

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

233 ] 

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

235 position = 0 

236 append = output.append 

237 for offset, closing, style in markup_spans: 

238 if offset > position: 

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

240 position = offset 

241 if style: 

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

243 markup = "".join(output) 

244 return markup 

245 

246 @classmethod 

247 def from_markup( 

248 cls, 

249 text: str, 

250 *, 

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

252 emoji: bool = True, 

253 emoji_variant: Optional[EmojiVariant] = None, 

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

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

256 end: str = "\n", 

257 ) -> "Text": 

258 """Create Text instance from markup. 

259 

260 Args: 

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

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

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

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

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

266 

267 Returns: 

268 Text: A Text instance with markup rendered. 

269 """ 

270 from .markup import render 

271 

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

273 rendered_text.justify = justify 

274 rendered_text.overflow = overflow 

275 rendered_text.end = end 

276 return rendered_text 

277 

278 @classmethod 

279 def from_ansi( 

280 cls, 

281 text: str, 

282 *, 

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

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

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

286 no_wrap: Optional[bool] = None, 

287 end: str = "\n", 

288 tab_size: Optional[int] = 8, 

289 ) -> "Text": 

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

291 

292 Args: 

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

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

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

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

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

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

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

300 """ 

301 from .ansi import AnsiDecoder 

302 

303 joiner = Text( 

304 "\n", 

305 justify=justify, 

306 overflow=overflow, 

307 no_wrap=no_wrap, 

308 end=end, 

309 tab_size=tab_size, 

310 style=style, 

311 ) 

312 decoder = AnsiDecoder() 

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

314 return result 

315 

316 @classmethod 

317 def styled( 

318 cls, 

319 text: str, 

320 style: StyleType = "", 

321 *, 

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

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

324 ) -> "Text": 

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

326 to pad the text when it is justified. 

327 

328 Args: 

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

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

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

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

333 

334 Returns: 

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

336 """ 

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

338 styled_text.stylize(style) 

339 return styled_text 

340 

341 @classmethod 

342 def assemble( 

343 cls, 

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

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

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

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

348 no_wrap: Optional[bool] = None, 

349 end: str = "\n", 

350 tab_size: int = 8, 

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

352 ) -> "Text": 

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

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

355 

356 Args: 

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

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

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

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

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

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

363 

364 Returns: 

365 Text: A new text instance. 

366 """ 

367 text = cls( 

368 style=style, 

369 justify=justify, 

370 overflow=overflow, 

371 no_wrap=no_wrap, 

372 end=end, 

373 tab_size=tab_size, 

374 ) 

375 append = text.append 

376 _Text = Text 

377 for part in parts: 

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

379 append(part) 

380 else: 

381 append(*part) 

382 if meta: 

383 text.apply_meta(meta) 

384 return text 

385 

386 @property 

387 def plain(self) -> str: 

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

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

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

391 return self._text[0] 

392 

393 @plain.setter 

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

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

396 if new_text != self.plain: 

397 sanitized_text = strip_control_codes(new_text) 

398 self._text[:] = [sanitized_text] 

399 old_length = self._length 

400 self._length = len(sanitized_text) 

401 if old_length > self._length: 

402 self._trim_spans() 

403 

404 @property 

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

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

407 return self._spans 

408 

409 @spans.setter 

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

411 """Set spans.""" 

412 self._spans = spans[:] 

413 

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

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

416 copy_self = Text( 

417 plain, 

418 style=self.style, 

419 justify=self.justify, 

420 overflow=self.overflow, 

421 no_wrap=self.no_wrap, 

422 end=self.end, 

423 tab_size=self.tab_size, 

424 ) 

425 return copy_self 

426 

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

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

429 copy_self = Text( 

430 self.plain, 

431 style=self.style, 

432 justify=self.justify, 

433 overflow=self.overflow, 

434 no_wrap=self.no_wrap, 

435 end=self.end, 

436 tab_size=self.tab_size, 

437 ) 

438 copy_self._spans[:] = self._spans 

439 return copy_self 

440 

441 def stylize( 

442 self, 

443 style: Union[str, Style], 

444 start: int = 0, 

445 end: Optional[int] = None, 

446 ) -> None: 

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

448 

449 Args: 

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

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

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

453 """ 

454 if style: 

455 length = len(self) 

456 if start < 0: 

457 start = length + start 

458 if end is None: 

459 end = length 

460 if end < 0: 

461 end = length + end 

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

463 # Span not in text or not valid 

464 return 

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

466 

467 def stylize_before( 

468 self, 

469 style: Union[str, Style], 

470 start: int = 0, 

471 end: Optional[int] = None, 

472 ) -> None: 

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

474 

475 Args: 

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

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

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

479 """ 

480 if style: 

481 length = len(self) 

482 if start < 0: 

483 start = length + start 

484 if end is None: 

485 end = length 

486 if end < 0: 

487 end = length + end 

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

489 # Span not in text or not valid 

490 return 

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

492 

493 def apply_meta( 

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

495 ) -> None: 

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

497 

498 Args: 

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

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

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

502 

503 """ 

504 style = Style.from_meta(meta) 

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

506 

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

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

509 

510 Example: 

511 >>> from rich.text import Text 

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

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

514 

515 Args: 

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

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

518 

519 Returns: 

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

521 """ 

522 meta = {} if meta is None else meta 

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

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

525 return self 

526 

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

528 """Remove a suffix if it exists. 

529 

530 Args: 

531 suffix (str): Suffix to remove. 

532 """ 

533 if self.plain.endswith(suffix): 

534 self.right_crop(len(suffix)) 

535 

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

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

538 

539 Args: 

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

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

542 

543 Returns: 

544 Style: A Style instance. 

545 """ 

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

547 if offset < 0: 

548 offset = len(self) + offset 

549 get_style = console.get_style 

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

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

552 if end > offset >= start: 

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

554 return style 

555 

556 def highlight_regex( 

557 self, 

558 re_highlight: str, 

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

560 *, 

561 style_prefix: str = "", 

562 ) -> int: 

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

564 translated to styles. 

565 

566 Args: 

567 re_highlight (str): A regular expression. 

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

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

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

571 

572 Returns: 

573 int: Number of regex matches 

574 """ 

575 count = 0 

576 append_span = self._spans.append 

577 _Span = Span 

578 plain = self.plain 

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

580 get_span = match.span 

581 if style: 

582 start, end = get_span() 

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

584 if match_style is not None and end > start: 

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

586 

587 count += 1 

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

589 start, end = get_span(name) 

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

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

592 return count 

593 

594 def highlight_words( 

595 self, 

596 words: Iterable[str], 

597 style: Union[str, Style], 

598 *, 

599 case_sensitive: bool = True, 

600 ) -> int: 

601 """Highlight words with a style. 

602 

603 Args: 

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

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

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

607 

608 Returns: 

609 int: Number of words highlighted. 

610 """ 

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

612 add_span = self._spans.append 

613 count = 0 

614 _Span = Span 

615 for match in re.finditer( 

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

617 ): 

618 start, end = match.span(0) 

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

620 count += 1 

621 return count 

622 

623 def rstrip(self) -> None: 

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

625 self.plain = self.plain.rstrip() 

626 

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

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

629 

630 Args: 

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

632 """ 

633 text_length = len(self) 

634 if text_length > size: 

635 excess = text_length - size 

636 whitespace_match = _re_whitespace.search(self.plain) 

637 if whitespace_match is not None: 

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

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

640 

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

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

643 length = len(self) 

644 if length != new_length: 

645 if length < new_length: 

646 self.pad_right(new_length - length) 

647 else: 

648 self.right_crop(length - new_length) 

649 

650 def __rich_console__( 

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

652 ) -> Iterable[Segment]: 

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

654 justify = self.justify or options.justify or DEFAULT_JUSTIFY 

655 

656 overflow = self.overflow or options.overflow or DEFAULT_OVERFLOW 

657 

658 lines = self.wrap( 

659 console, 

660 options.max_width, 

661 justify=justify, 

662 overflow=overflow, 

663 tab_size=tab_size or 8, 

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

665 ) 

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

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

668 

669 def __rich_measure__( 

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

671 ) -> Measurement: 

672 text = self.plain 

673 lines = text.splitlines() 

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

675 words = text.split() 

676 min_text_width = ( 

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

678 ) 

679 return Measurement(min_text_width, max_text_width) 

680 

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

682 """Render the text as Segments. 

683 

684 Args: 

685 console (Console): Console instance. 

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

687 

688 Returns: 

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

690 """ 

691 _Segment = Segment 

692 text = self.plain 

693 if not self._spans: 

694 yield Segment(text) 

695 if end: 

696 yield _Segment(end) 

697 return 

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

699 

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

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

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

703 

704 spans = [ 

705 (0, False, 0), 

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

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

708 (len(text), True, 0), 

709 ] 

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

711 

712 stack: List[int] = [] 

713 stack_append = stack.append 

714 stack_pop = stack.remove 

715 

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

717 style_cache_get = style_cache.get 

718 combine = Style.combine 

719 

720 def get_current_style() -> Style: 

721 """Construct current style from stack.""" 

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

723 cached_style = style_cache_get(styles) 

724 if cached_style is not None: 

725 return cached_style 

726 current_style = combine(styles) 

727 style_cache[styles] = current_style 

728 return current_style 

729 

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

731 if leaving: 

732 stack_pop(style_id) 

733 else: 

734 stack_append(style_id) 

735 if next_offset > offset: 

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

737 if end: 

738 yield _Segment(end) 

739 

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

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

742 

743 Args: 

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

745 

746 Returns: 

747 Text: A new text instance containing join text. 

748 """ 

749 

750 new_text = self.blank_copy() 

751 

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

753 if self.plain: 

754 for last, line in loop_last(lines): 

755 yield line 

756 if not last: 

757 yield self 

758 else: 

759 yield from lines 

760 

761 extend_text = new_text._text.extend 

762 append_span = new_text._spans.append 

763 extend_spans = new_text._spans.extend 

764 offset = 0 

765 _Span = Span 

766 

767 for text in iter_text(): 

768 extend_text(text._text) 

769 if text.style: 

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

771 extend_spans( 

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

773 for start, end, style in text._spans 

774 ) 

775 offset += len(text) 

776 new_text._length = offset 

777 return new_text 

778 

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

780 """Converts tabs to spaces. 

781 

782 Args: 

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

784 

785 """ 

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

787 return 

788 pos = 0 

789 if tab_size is None: 

790 tab_size = self.tab_size 

791 assert tab_size is not None 

792 result = self.blank_copy() 

793 append = result.append 

794 

795 _style = self.style 

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

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

798 for part in parts: 

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

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

801 append(part) 

802 pos += len(part) 

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

804 if spaces: 

805 append(" " * spaces, _style) 

806 pos += spaces 

807 else: 

808 append(part) 

809 self._text = [result.plain] 

810 self._length = len(self.plain) 

811 self._spans[:] = result._spans 

812 

813 def truncate( 

814 self, 

815 max_width: int, 

816 *, 

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

818 pad: bool = False, 

819 ) -> None: 

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

821 

822 Args: 

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

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

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

826 """ 

827 _overflow = overflow or self.overflow or DEFAULT_OVERFLOW 

828 if _overflow != "ignore": 

829 length = cell_len(self.plain) 

830 if length > max_width: 

831 if _overflow == "ellipsis": 

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

833 else: 

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

835 if pad and length < max_width: 

836 spaces = max_width - length 

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

838 self._length = len(self.plain) 

839 

840 def _trim_spans(self) -> None: 

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

842 max_offset = len(self.plain) 

843 _Span = Span 

844 self._spans[:] = [ 

845 ( 

846 span 

847 if span.end < max_offset 

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

849 ) 

850 for span in self._spans 

851 if span.start < max_offset 

852 ] 

853 

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

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

856 

857 Args: 

858 count (int): Width of padding. 

859 """ 

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

861 if count: 

862 pad_characters = character * count 

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

864 _Span = Span 

865 self._spans[:] = [ 

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

867 for start, end, style in self._spans 

868 ] 

869 

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

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

872 

873 Args: 

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

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

876 """ 

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

878 if count: 

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

880 _Span = Span 

881 self._spans[:] = [ 

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

883 for start, end, style in self._spans 

884 ] 

885 

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

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

888 

889 Args: 

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

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

892 """ 

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

894 if count: 

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

896 

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

898 """Align text to a given width. 

899 

900 Args: 

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

902 width (int): Desired width. 

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

904 """ 

905 self.truncate(width) 

906 excess_space = width - cell_len(self.plain) 

907 if excess_space: 

908 if align == "left": 

909 self.pad_right(excess_space, character) 

910 elif align == "center": 

911 left = excess_space // 2 

912 self.pad_left(left, character) 

913 self.pad_right(excess_space - left, character) 

914 else: 

915 self.pad_left(excess_space, character) 

916 

917 def append( 

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

919 ) -> "Text": 

920 """Add text with an optional style. 

921 

922 Args: 

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

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

925 

926 Returns: 

927 Text: Returns self for chaining. 

928 """ 

929 

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

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

932 

933 if len(text): 

934 if isinstance(text, str): 

935 sanitized_text = strip_control_codes(text) 

936 self._text.append(sanitized_text) 

937 offset = len(self) 

938 text_length = len(sanitized_text) 

939 if style is not None: 

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

941 self._length += text_length 

942 elif isinstance(text, Text): 

943 _Span = Span 

944 if style is not None: 

945 raise ValueError( 

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

947 ) 

948 text_length = self._length 

949 if text.style is not None: 

950 self._spans.append( 

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

952 ) 

953 self._text.append(text.plain) 

954 self._spans.extend( 

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

956 for start, end, style in text._spans 

957 ) 

958 self._length += len(text) 

959 return self 

960 

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

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

963 only works for Text. 

964 

965 Returns: 

966 Text: Returns self for chaining. 

967 """ 

968 _Span = Span 

969 text_length = self._length 

970 if text.style is not None: 

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

972 self._text.append(text.plain) 

973 self._spans.extend( 

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

975 for start, end, style in text._spans 

976 ) 

977 self._length += len(text) 

978 return self 

979 

980 def append_tokens( 

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

982 ) -> "Text": 

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

984 

985 Args: 

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

987 

988 Returns: 

989 Text: Returns self for chaining. 

990 """ 

991 append_text = self._text.append 

992 append_span = self._spans.append 

993 _Span = Span 

994 offset = len(self) 

995 for content, style in tokens: 

996 append_text(content) 

997 if style is not None: 

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

999 offset += len(content) 

1000 self._length = offset 

1001 return self 

1002 

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

1004 """Copy styles from another Text instance. 

1005 

1006 Args: 

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

1008 """ 

1009 self._spans.extend(text._spans) 

1010 

1011 def split( 

1012 self, 

1013 separator: str = "\n", 

1014 *, 

1015 include_separator: bool = False, 

1016 allow_blank: bool = False, 

1017 ) -> Lines: 

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

1019 

1020 Args: 

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

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

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

1024 

1025 Returns: 

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

1027 """ 

1028 assert separator, "separator must not be empty" 

1029 

1030 text = self.plain 

1031 if separator not in text: 

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

1033 

1034 if include_separator: 

1035 lines = self.divide( 

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

1037 ) 

1038 else: 

1039 

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

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

1042 start, end = match.span() 

1043 yield start 

1044 yield end 

1045 

1046 lines = Lines( 

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

1048 ) 

1049 

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

1051 lines.pop() 

1052 

1053 return lines 

1054 

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

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

1057 

1058 Args: 

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

1060 

1061 Returns: 

1062 Lines: New RichText instances between offsets. 

1063 """ 

1064 _offsets = list(offsets) 

1065 

1066 if not _offsets: 

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

1068 

1069 text = self.plain 

1070 text_length = len(text) 

1071 divide_offsets = [0, *_offsets, text_length] 

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

1073 

1074 style = self.style 

1075 justify = self.justify 

1076 overflow = self.overflow 

1077 _Text = Text 

1078 new_lines = Lines( 

1079 _Text( 

1080 text[start:end], 

1081 style=style, 

1082 justify=justify, 

1083 overflow=overflow, 

1084 ) 

1085 for start, end in line_ranges 

1086 ) 

1087 if not self._spans: 

1088 return new_lines 

1089 

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

1091 line_count = len(line_ranges) 

1092 _Span = Span 

1093 

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

1095 

1096 lower_bound = 0 

1097 upper_bound = line_count 

1098 start_line_no = (lower_bound + upper_bound) // 2 

1099 

1100 while True: 

1101 line_start, line_end = line_ranges[start_line_no] 

1102 if span_start < line_start: 

1103 upper_bound = start_line_no - 1 

1104 elif span_start > line_end: 

1105 lower_bound = start_line_no + 1 

1106 else: 

1107 break 

1108 start_line_no = (lower_bound + upper_bound) // 2 

1109 

1110 if span_end < line_end: 

1111 end_line_no = start_line_no 

1112 else: 

1113 end_line_no = lower_bound = start_line_no 

1114 upper_bound = line_count 

1115 

1116 while True: 

1117 line_start, line_end = line_ranges[end_line_no] 

1118 if span_end < line_start: 

1119 upper_bound = end_line_no - 1 

1120 elif span_end > line_end: 

1121 lower_bound = end_line_no + 1 

1122 else: 

1123 break 

1124 end_line_no = (lower_bound + upper_bound) // 2 

1125 

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

1127 line_start, line_end = line_ranges[line_no] 

1128 new_start = max(0, span_start - line_start) 

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

1130 if new_end > new_start: 

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

1132 

1133 return new_lines 

1134 

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

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

1137 max_offset = len(self.plain) - amount 

1138 _Span = Span 

1139 self._spans[:] = [ 

1140 ( 

1141 span 

1142 if span.end < max_offset 

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

1144 ) 

1145 for span in self._spans 

1146 if span.start < max_offset 

1147 ] 

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

1149 self._length -= amount 

1150 

1151 def wrap( 

1152 self, 

1153 console: "Console", 

1154 width: int, 

1155 *, 

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

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

1158 tab_size: int = 8, 

1159 no_wrap: Optional[bool] = None, 

1160 ) -> Lines: 

1161 """Word wrap the text. 

1162 

1163 Args: 

1164 console (Console): Console instance. 

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

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

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

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

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

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

1171 

1172 Returns: 

1173 Lines: Number of lines. 

1174 """ 

1175 wrap_justify = justify or self.justify or DEFAULT_JUSTIFY 

1176 wrap_overflow = overflow or self.overflow or DEFAULT_OVERFLOW 

1177 

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

1179 

1180 lines = Lines() 

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

1182 if "\t" in line: 

1183 line.expand_tabs(tab_size) 

1184 if no_wrap: 

1185 new_lines = Lines([line]) 

1186 else: 

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

1188 new_lines = line.divide(offsets) 

1189 for line in new_lines: 

1190 line.rstrip_end(width) 

1191 if wrap_justify: 

1192 new_lines.justify( 

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

1194 ) 

1195 for line in new_lines: 

1196 line.truncate(width, overflow=wrap_overflow) 

1197 lines.extend(new_lines) 

1198 return lines 

1199 

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

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

1202 

1203 Args: 

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

1205 

1206 Returns: 

1207 Lines: List of lines. 

1208 """ 

1209 lines: Lines = Lines() 

1210 append = lines.append 

1211 for line in self.split(): 

1212 line.set_length(width) 

1213 append(line) 

1214 return lines 

1215 

1216 def detect_indentation(self) -> int: 

1217 """Auto-detect indentation of code. 

1218 

1219 Returns: 

1220 int: Number of spaces used to indent code. 

1221 """ 

1222 

1223 _indentations = { 

1224 len(match.group(1)) 

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

1226 } 

1227 

1228 try: 

1229 indentation = ( 

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

1231 ) 

1232 except TypeError: 

1233 indentation = 1 

1234 

1235 return indentation 

1236 

1237 def with_indent_guides( 

1238 self, 

1239 indent_size: Optional[int] = None, 

1240 *, 

1241 character: str = "│", 

1242 style: StyleType = "dim green", 

1243 ) -> "Text": 

1244 """Adds indent guide lines to text. 

1245 

1246 Args: 

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

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

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

1250 

1251 Returns: 

1252 Text: New text with indentation guides. 

1253 """ 

1254 

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

1256 

1257 text = self.copy() 

1258 text.expand_tabs() 

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

1260 

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

1262 new_lines: List[Text] = [] 

1263 add_line = new_lines.append 

1264 blank_lines = 0 

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

1266 match = re_indent.match(line.plain) 

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

1268 blank_lines += 1 

1269 continue 

1270 indent = match.group(1) 

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

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

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

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

1275 if blank_lines: 

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

1277 blank_lines = 0 

1278 add_line(line) 

1279 if blank_lines: 

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

1281 

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

1283 return new_text 

1284 

1285 

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

1287 from rich.console import Console 

1288 

1289 text = Text( 

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

1291 ) 

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

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

1294 

1295 console = Console() 

1296 

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

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

1299 console.print() 

1300 

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

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

1303 console.print() 

1304 

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

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

1307 console.print() 

1308 

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

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

1311 console.print()