Coverage for /run/media/veganeco/water/status600.com/pypi/reptilian_climates/modules_series_2/ships/fields/gardens_pip/rich/text.py: 44%

1 import re

2 from functools import partial, reduce

3 from math import gcd

4 from operator import itemgetter

5 from 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

18 from ._loop import loop_last

19 from ._pick import pick_bool

20 from ._wrap import divide_line

21 from .align import AlignMethod

22 from .cells import cell_len, set_cell_size

23 from .containers import Lines

24 from .control import strip_control_codes

25 from .emoji import EmojiVariant

26 from .jupyter import JupyterMixin

27 from .measure import Measurement

28 from .segment import Segment

29 from .style import Style, StyleType

30

31 if TYPE_CHECKING: # pragma: no cover

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

33

34 DEFAULT_JUSTIFY: "JustifyMethod" = "default"

35 DEFAULT_OVERFLOW: "OverflowMethod" = "fold"

36

37

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

39

40 TextType = Union[str, "Text"]

41"""A plain string or a [Text][rich.text.Text] instance."""

42

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

44

45

46 class 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

117 class Text(JupyterMixin):

118 """Text with color / style.

119

120 Args:

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

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

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

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

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

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

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

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

129 """

130

131 __slots__ = [

132 "_text",

133 "style",

134 "justify",

135 "overflow",

136 "no_wrap",

137 "end",

138 "tab_size",

139 "_spans",

140 "_length",

141 ]

142

143 def __init__(

144 self,

145 text: str = "",

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

147 *,

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

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

150 no_wrap: Optional[bool] = None,

151 end: str = "\n",

152 tab_size: Optional[int] = None,

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

154 ) -> None:

155 sanitized_text = strip_control_codes(text)

156 self._text = [sanitized_text]

157 self.style = style

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

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

160 self.no_wrap = no_wrap

161 self.end = end

162 self.tab_size = tab_size

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

164 self._length: int = len(sanitized_text)

165

166 def __len__(self) -> int:

167 return self._length

168

169 def __bool__(self) -> bool:

170 return bool(self._length)

171

172 def __str__(self) -> str:

173 return self.plain

174

175 def __repr__(self) -> str:

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

177

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

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

180 result = self.copy()

181 result.append(other)

182 return result

183 return NotImplemented

184

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

186 if not isinstance(other, Text):

187 return NotImplemented

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

189

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

191 if isinstance(other, str):

192 return other in self.plain

193 elif isinstance(other, Text):

194 return other.plain in self.plain

195 return False

196

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

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

199 _Span = Span

200 text = Text(

201 self.plain[offset],

202 spans=[

203 _Span(0, 1, style)

204 for start, end, style in self._spans

205 if end > offset >= start

206 ],

207 end="",

208 )

209 return text

210

211 if isinstance(slice, int):

212 return get_text_at(slice)

213 else:

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

215 if step == 1:

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

217 return lines[1]

218 else:

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

220 # For now, its not required

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

222

223 @property

224 def cell_len(self) -> int:

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

226 return cell_len(self.plain)

227

228 @property

229 def markup(self) -> str:

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

231

232 Returns:

233 str: A string potentially creating markup tags.

234 """

235 from .markup import escape

236

237 output: List[str] = []

238

239 plain = self.plain

240 markup_spans = [

241 (0, False, self.style),

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

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

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

245 ]

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

247 position = 0

248 append = output.append

249 for offset, closing, style in markup_spans:

250 if offset > position:

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

252 position = offset

253 if style:

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

255 markup = "".join(output)

256 return markup

257

258 @classmethod

259 def from_markup(

260 cls,

261 text: str,

262 *,

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

264 emoji: bool = True,

265 emoji_variant: Optional[EmojiVariant] = None,

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

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

268 end: str = "\n",

269 ) -> "Text":

270 """Create Text instance from markup.

271

272 Args:

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

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

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

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

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

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

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

280

281 Returns:

282 Text: A Text instance with markup rendered.

283 """

284 from .markup import render

285

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

287 rendered_text.justify = justify

288 rendered_text.overflow = overflow

289 rendered_text.end = end

290 return rendered_text

291

292 @classmethod

293 def from_ansi(

294 cls,

295 text: str,

296 *,

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

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

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

300 no_wrap: Optional[bool] = None,

301 end: str = "\n",

302 tab_size: Optional[int] = 8,

303 ) -> "Text":

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

305

306 Args:

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

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

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

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

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

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

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

314 """

315 from .ansi import AnsiDecoder

316

317 joiner = Text(

318 "\n",

319 justify=justify,

320 overflow=overflow,

321 no_wrap=no_wrap,

322 end=end,

323 tab_size=tab_size,

324 style=style,

325 )

326 decoder = AnsiDecoder()

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

328 return result

329

330 @classmethod

331 def styled(

332 cls,

333 text: str,

334 style: StyleType = "",

335 *,

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

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

338 ) -> "Text":

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

340 to pad the text when it is justified.

341

342 Args:

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

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

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

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

347

348 Returns:

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

350 """

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

352 styled_text.stylize(style)

353 return styled_text

354

355 @classmethod

356 def assemble(

357 cls,

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

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

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

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

362 no_wrap: Optional[bool] = None,

363 end: str = "\n",

364 tab_size: int = 8,

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

366 ) -> "Text":

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

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

369

370 Args:

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

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

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

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

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

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

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

378

379 Returns:

380 Text: A new text instance.

381 """

382 text = cls(

383 style=style,

384 justify=justify,

385 overflow=overflow,

386 no_wrap=no_wrap,

387 end=end,

388 tab_size=tab_size,

389 )

390 append = text.append

391 _Text = Text

392 for part in parts:

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

394 append(part)

395 else:

396 append(*part)

397 if meta:

398 text.apply_meta(meta)

399 return text

400

401 @property

402 def plain(self) -> str:

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

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

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

406 return self._text[0]

407

408 @plain.setter

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

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

411 if new_text != self.plain:

412 sanitized_text = strip_control_codes(new_text)

413 self._text[:] = [sanitized_text]

414 old_length = self._length

415 self._length = len(sanitized_text)

416 if old_length > self._length:

417 self._trim_spans()

418

419 @property

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

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

422 return self._spans

423

424 @spans.setter

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

426 """Set spans."""

427 self._spans = spans[:]

428

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

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

431 copy_self = Text(

432 plain,

433 style=self.style,

434 justify=self.justify,

435 overflow=self.overflow,

436 no_wrap=self.no_wrap,

437 end=self.end,

438 tab_size=self.tab_size,

439 )

440 return copy_self

441

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

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

444 copy_self = Text(

445 self.plain,

446 style=self.style,

447 justify=self.justify,

448 overflow=self.overflow,

449 no_wrap=self.no_wrap,

450 end=self.end,

451 tab_size=self.tab_size,

452 )

453 copy_self._spans[:] = self._spans

454 return copy_self

455

456 def stylize(

457 self,

458 style: Union[str, Style],

459 start: int = 0,

460 end: Optional[int] = None,

461 ) -> None:

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

463

464 Args:

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

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

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

468 """

469 if style:

470 length = len(self)

471 if start < 0:

472 start = length + start

473 if end is None:

474 end = length

475 if end < 0:

476 end = length + end

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

478 # Span not in text or not valid

479 return

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

481

482 def stylize_before(

483 self,

484 style: Union[str, Style],

485 start: int = 0,

486 end: Optional[int] = None,

487 ) -> None:

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

489

490 Args:

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

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

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

494 """

495 if style:

496 length = len(self)

497 if start < 0:

498 start = length + start

499 if end is None:

500 end = length

501 if end < 0:

502 end = length + end

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

504 # Span not in text or not valid

505 return

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

507

508 def apply_meta(

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

510 ) -> None:

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

512

513 Args:

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

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

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

517

518 """

519 style = Style.from_meta(meta)

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

521

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

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

524

525 Example:

526 >>> from rich.text import Text

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

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

529

530 Args:

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

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

533

534 Returns:

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

536 """

537 meta = {} if meta is None else meta

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

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

540 return self

541

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

543 """Remove a suffix if it exists.

544

545 Args:

546 suffix (str): Suffix to remove.

547 """

548 if self.plain.endswith(suffix):

549 self.right_crop(len(suffix))

550

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

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

553

554 Args:

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

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

557

558 Returns:

559 Style: A Style instance.

560 """

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

562 if offset < 0:

563 offset = len(self) + offset

564 get_style = console.get_style

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

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

567 if end > offset >= start:

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

569 return style

570

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

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

573

574 Args:

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

576 """

577 if spaces <= 0:

578 return

579 spans = self.spans

580 new_spaces = " " * spaces

581 if spans:

582 end_offset = len(self)

583 self._spans[:] = [

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

585 for span in spans

586 ]

587 self._text.append(new_spaces)

588 self._length += spaces

589 else:

590 self.plain += new_spaces

591

592 def highlight_regex(

593 self,

594 re_highlight: str,

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

596 *,

597 style_prefix: str = "",

598 ) -> int:

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

600 translated to styles.

601

602 Args:

603 re_highlight (str): A regular expression.

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

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

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

607

608 Returns:

609 int: Number of regex matches

610 """

611 count = 0

612 append_span = self._spans.append

613 _Span = Span

614 plain = self.plain

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

616 get_span = match.span

617 if style:

618 start, end = get_span()

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

620 if match_style is not None and end > start:

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

622

623 count += 1

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

625 start, end = get_span(name)

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

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

628 return count

629

630 def highlight_words(

631 self,

632 words: Iterable[str],

633 style: Union[str, Style],

634 *,

635 case_sensitive: bool = True,

636 ) -> int:

637 """Highlight words with a style.

638

639 Args:

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

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

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

643

644 Returns:

645 int: Number of words highlighted.

646 """

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

648 add_span = self._spans.append

649 count = 0

650 _Span = Span

651 for match in re.finditer(

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

653 ):

654 start, end = match.span(0)

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

656 count += 1

657 return count

658

659 def rstrip(self) -> None:

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

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

662

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

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

665

666 Args:

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

668 """

669 text_length = len(self)

670 if text_length > size:

671 excess = text_length - size

672 whitespace_match = _re_whitespace.search(self.plain)

673 if whitespace_match is not None:

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

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

676

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

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

679 length = len(self)

680 if length != new_length:

681 if length < new_length:

682 self.pad_right(new_length - length)

683 else:

684 self.right_crop(length - new_length)

685

686 def __rich_console__(

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

688 ) -> Iterable[Segment]:

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

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

691

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

693

694 lines = self.wrap(

695 console,

696 options.max_width,

697 justify=justify,

698 overflow=overflow,

699 tab_size=tab_size or 8,

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

701 )

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

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

704

705 def __rich_measure__(

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

707 ) -> Measurement:

708 text = self.plain

709 lines = text.splitlines()

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

711 words = text.split()

712 min_text_width = (

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

714 )

715 return Measurement(min_text_width, max_text_width)

716

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

718 """Render the text as Segments.

719

720 Args:

721 console (Console): Console instance.

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

723

724 Returns:

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

726 """

727 _Segment = Segment

728 text = self.plain

729 if not self._spans:

730 yield Segment(text)

731 if end:

732 yield _Segment(end)

733 return

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

735

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

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

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

739

740 spans = [

741 (0, False, 0),

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

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

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

745 ]

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

747

748 stack: List[int] = []

749 stack_append = stack.append

750 stack_pop = stack.remove

751

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

753 style_cache_get = style_cache.get

754 combine = Style.combine

755

756 def get_current_style() -> Style:

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

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

759 cached_style = style_cache_get(styles)

760 if cached_style is not None:

761 return cached_style

762 current_style = combine(styles)

763 style_cache[styles] = current_style

764 return current_style

765

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

767 if leaving:

768 stack_pop(style_id)

769 else:

770 stack_append(style_id)

771 if next_offset > offset:

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

773 if end:

774 yield _Segment(end)

775

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

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

778

779 Args:

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

781

782 Returns:

783 Text: A new text instance containing join text.

784 """

785

786 new_text = self.blank_copy()

787

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

789 if self.plain:

790 for last, line in loop_last(lines):

791 yield line

792 if not last:

793 yield self

794 else:

795 yield from lines

796

797 extend_text = new_text._text.extend

798 append_span = new_text._spans.append

799 extend_spans = new_text._spans.extend

800 offset = 0

801 _Span = Span

802

803 for text in iter_text():

804 extend_text(text._text)

805 if text.style:

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

807 extend_spans(

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

809 for start, end, style in text._spans

810 )

811 offset += len(text)

812 new_text._length = offset

813 return new_text

814

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

816 """Converts tabs to spaces.

817

818 Args:

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

820

821 """

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

823 return

824 if tab_size is None:

825 tab_size = self.tab_size

826 if tab_size is None:

827 tab_size = 8

828

829 new_text: List[Text] = []

830 append = new_text.append

831

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

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

834 append(line)

835 else:

836 cell_position = 0

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

838 for part in parts:

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

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

841 cell_position += part.cell_len

842 tab_remainder = cell_position % tab_size

843 if tab_remainder:

844 spaces = tab_size - tab_remainder

845 part.extend_style(spaces)

846 cell_position += spaces

847 else:

848 cell_position += part.cell_len

849 append(part)

850

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

852

853 self._text = [result.plain]

854 self._length = len(self.plain)

855 self._spans[:] = result._spans

856

857 def truncate(

858 self,

859 max_width: int,

860 *,

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

862 pad: bool = False,

863 ) -> None:

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

865

866 Args:

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

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

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

870 """

871 _overflow = overflow or self.overflow or DEFAULT_OVERFLOW

872 if _overflow != "ignore":

873 length = cell_len(self.plain)

874 if length > max_width:

875 if _overflow == "ellipsis":

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

877 else:

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

879 if pad and length < max_width:

880 spaces = max_width - length

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

882 self._length = len(self.plain)

883

884 def _trim_spans(self) -> None:

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

886 max_offset = len(self.plain)

887 _Span = Span

888 self._spans[:] = [

889 (

890 span

891 if span.end < max_offset

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

893 )

894 for span in self._spans

895 if span.start < max_offset

896 ]

897

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

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

900

901 Args:

902 count (int): Width of padding.

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

904 """

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

906 if count:

907 pad_characters = character * count

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

909 _Span = Span

910 self._spans[:] = [

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

912 for start, end, style in self._spans

913 ]

914

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

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

917

918 Args:

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

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

921 """

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

923 if count:

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

925 _Span = Span

926 self._spans[:] = [

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

928 for start, end, style in self._spans

929 ]

930

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

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

933

934 Args:

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

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

937 """

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

939 if count:

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

941

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

943 """Align text to a given width.

944

945 Args:

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

947 width (int): Desired width.

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

949 """

950 self.truncate(width)

951 excess_space = width - cell_len(self.plain)

952 if excess_space:

953 if align == "left":

954 self.pad_right(excess_space, character)

955 elif align == "center":

956 left = excess_space // 2

957 self.pad_left(left, character)

958 self.pad_right(excess_space - left, character)

959 else:

960 self.pad_left(excess_space, character)

961

962 def append(

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

964 ) -> "Text":

965 """Add text with an optional style.

966

967 Args:

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

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

970

971 Returns:

972 Text: Returns self for chaining.

973 """

974

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

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

977

978 if len(text):

979 if isinstance(text, str):

980 sanitized_text = strip_control_codes(text)

981 self._text.append(sanitized_text)

982 offset = len(self)

983 text_length = len(sanitized_text)

984 if style:

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

986 self._length += text_length

987 elif isinstance(text, Text):

988 _Span = Span

989 if style is not None:

990 raise ValueError(

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

992 )

993 text_length = self._length

994 if text.style:

995 self._spans.append(

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

997 )

998 self._text.append(text.plain)

999 self._spans.extend(

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

1001 for start, end, style in text._spans

1002 )

1003 self._length += len(text)

1004 return self

1005

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

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

1008 only works for Text.

1009

1010 Args:

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

1012

1013 Returns:

1014 Text: Returns self for chaining.

1015 """

1016 _Span = Span

1017 text_length = self._length

1018 if text.style:

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

1020 self._text.append(text.plain)

1021 self._spans.extend(

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

1023 for start, end, style in text._spans

1024 )

1025 self._length += len(text)

1026 return self

1027

1028 def append_tokens(

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

1030 ) -> "Text":

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

1032

1033 Args:

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

1035

1036 Returns:

1037 Text: Returns self for chaining.

1038 """

1039 append_text = self._text.append

1040 append_span = self._spans.append

1041 _Span = Span

1042 offset = len(self)

1043 for content, style in tokens:

1044 append_text(content)

1045 if style:

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

1047 offset += len(content)

1048 self._length = offset

1049 return self

1050

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

1052 """Copy styles from another Text instance.

1053

1054 Args:

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

1056 """

1057 self._spans.extend(text._spans)

1058

1059 def split(

1060 self,

1061 separator: str = "\n",

1062 *,

1063 include_separator: bool = False,

1064 allow_blank: bool = False,

1065 ) -> Lines:

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

1067

1068 Args:

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

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

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

1072

1073 Returns:

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

1075 """

1076 assert separator, "separator must not be empty"

1077

1078 text = self.plain

1079 if separator not in text:

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

1081

1082 if include_separator:

1083 lines = self.divide(

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

1085 )

1086 else:

1087

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

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

1090 start, end = match.span()

1091 yield start

1092 yield end

1093

1094 lines = Lines(

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

1096 )

1097

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

1099 lines.pop()

1100

1101 return lines

1102

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

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

1105

1106 Args:

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

1108

1109 Returns:

1110 Lines: New RichText instances between offsets.

1111 """

1112 _offsets = list(offsets)

1113

1114 if not _offsets:

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

1116

1117 text = self.plain

1118 text_length = len(text)

1119 divide_offsets = [0, *_offsets, text_length]

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

1121

1122 style = self.style

1123 justify = self.justify

1124 overflow = self.overflow

1125 _Text = Text

1126 new_lines = Lines(

1127 _Text(

1128 text[start: end],

1129 style=style,

1130 justify=justify,

1131 overflow=overflow,

1132 )

1133 for start, end in line_ranges

1134 )

1135 if not self._spans:

1136 return new_lines

1137

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

1139 line_count = len(line_ranges)

1140 _Span = Span

1141

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

1143 lower_bound = 0

1144 upper_bound = line_count

1145 start_line_no = (lower_bound + upper_bound) // 2

1146

1147 while True:

1148 line_start, line_end = line_ranges[start_line_no]

1149 if span_start < line_start:

1150 upper_bound = start_line_no - 1

1151 elif span_start > line_end:

1152 lower_bound = start_line_no + 1

1153 else:

1154 break

1155 start_line_no = (lower_bound + upper_bound) // 2

1156

1157 if span_end < line_end:

1158 end_line_no = start_line_no

1159 else:

1160 end_line_no = lower_bound = start_line_no

1161 upper_bound = line_count

1162

1163 while True:

1164 line_start, line_end = line_ranges[end_line_no]

1165 if span_end < line_start:

1166 upper_bound = end_line_no - 1

1167 elif span_end > line_end:

1168 lower_bound = end_line_no + 1

1169 else:

1170 break

1171 end_line_no = (lower_bound + upper_bound) // 2

1172

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

1174 line_start, line_end = line_ranges[line_no]

1175 new_start = max(0, span_start - line_start)

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

1177 if new_end > new_start:

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

1179

1180 return new_lines

1181

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

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

1184 max_offset = len(self.plain) - amount

1185 _Span = Span

1186 self._spans[:] = [

1187 (

1188 span

1189 if span.end < max_offset

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

1191 )

1192 for span in self._spans

1193 if span.start < max_offset

1194 ]

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

1196 self._length -= amount

1197

1198 def wrap(

1199 self,

1200 console: "Console",

1201 width: int,

1202 *,

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

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

1205 tab_size: int = 8,

1206 no_wrap: Optional[bool] = None,

1207 ) -> Lines:

1208 """Word wrap the text.

1209

1210 Args:

1211 console (Console): Console instance.

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

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

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

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

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

1217

1218 Returns:

1219 Lines: Number of lines.

1220 """

1221 wrap_justify = justify or self.justify or DEFAULT_JUSTIFY

1222 wrap_overflow = overflow or self.overflow or DEFAULT_OVERFLOW

1223

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

1225

1226 lines = Lines()

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

1228 if "\t" in line:

1229 line.expand_tabs(tab_size)

1230 if no_wrap:

1231 new_lines = Lines([line])

1232 else:

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

1234 new_lines = line.divide(offsets)

1235 for line in new_lines:

1236 line.rstrip_end(width)

1237 if wrap_justify:

1238 new_lines.justify(

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

1240 )

1241 for line in new_lines:

1242 line.truncate(width, overflow=wrap_overflow)

1243 lines.extend(new_lines)

1244 return lines

1245

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

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

1248

1249 Args:

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

1251

1252 Returns:

1253 Lines: Lines container.

1254 """

1255 lines: Lines = Lines()

1256 append = lines.append

1257 for line in self.split():

1258 line.set_length(width)

1259 append(line)

1260 return lines

1261

1262 def detect_indentation(self) -> int:

1263 """Auto-detect indentation of code.

1264

1265 Returns:

1266 int: Number of spaces used to indent code.

1267 """

1268

1269 _indentations = {

1270 len(match.group(1))

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

1272 }

1273

1274 try:

1275 indentation = (

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

1277 )

1278 except TypeError:

1279 indentation = 1

1280

1281 return indentation

1282

1283 def with_indent_guides(

1284 self,

1285 indent_size: Optional[int] = None,

1286 *,

1287 character: str = "│",

1288 style: StyleType = "dim green",

1289 ) -> "Text":

1290 """Adds indent guide lines to text.

1291

1292 Args:

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

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

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

1296

1297 Returns:

1298 Text: New text with indentation guides.

1299 """

1300

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

1302

1303 text = self.copy()

1304 text.expand_tabs()

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

1306

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

1308 new_lines: List[Text] = []

1309 add_line = new_lines.append

1310 blank_lines = 0

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

1312 match = re_indent.match(line.plain)

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

1314 blank_lines += 1

1315 continue

1316 indent = match.group(1)

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

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

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

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

1321 if blank_lines:

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

1323 blank_lines = 0

1324 add_line(line)

1325 if blank_lines:

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

1327

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

1329 return new_text

1330

1331

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

1333 from rich.console import Console

1334

1335 text = Text(

1336 """\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"""

1337 )

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

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

1340

1341 console = Console()

1342

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

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

1345 console.print()

1346

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

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

1349 console.print()

1350

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

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

1353 console.print()

1354

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

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

1357 console.print()