Coverage for yuio / string.py: 99%

1494 statements  

« prev     ^ index     » next       coverage.py v7.13.3, created at 2026-02-03 15:42 +0000

1# Yuio project, MIT license. 

2# 

3# https://github.com/taminomara/yuio/ 

4# 

5# You're free to copy this file to your project and edit it for your needs, 

6# just keep this copyright line please :3 

7 

8""" 

9The higher-level :mod:`yuio.io` module uses strings with xml-like color 

10tags to store information about line formatting. Here, on the lower level, 

11these strings are parsed and transformed to :class:`ColorizedString`\\ s. 

12 

13.. autoclass:: ColorizedString 

14 :members: 

15 

16 

17.. _pretty-protocol: 

18 

19Pretty printing protocol 

20------------------------ 

21 

22Complex message formatting requires knowing capabilities of the target terminal. 

23This affects which message decorations are used (Unicode or ASCII), how lines are 

24wrapped, and so on. This data is encapsulated in an instance of :class:`ReprContext`: 

25 

26.. autoclass:: ReprContext 

27 :members: 

28 

29Repr context may not always be available when a message is created, though. 

30For example, we may know that we will be printing some data, but we don't know 

31whether we'll print it to a file or to a terminal. 

32 

33The solution is to defer formatting by creating a :type:`Colorable`, i.e. an object 

34that defines one of the following special methods: 

35 

36``__colorized_str__``, ``__colorized_repr__`` 

37 This should be a method that accepts a single positional argument, 

38 :class:`ReprContext`, and returns a :class:`ColorizedString`. 

39 

40 .. tip:: 

41 

42 Prefer ``__rich_repr__`` for simpler use cases, and only use 

43 ``__colorized_repr__`` when you need something advanced. 

44 

45 **Example:** 

46 

47 .. code-block:: python 

48 

49 class MyObject: 

50 def __init__(self, value): 

51 self.value = value 

52 

53 def __colorized_str__(self, ctx: yuio.string.ReprContext): 

54 result = yuio.string.ColorizedString() 

55 result += ctx.get_color("magenta") 

56 result += "MyObject" 

57 result += ctx.get_color("normal") 

58 result += "(" 

59 result += ctx.repr(self.value) 

60 result += ")" 

61 return result 

62 

63``__rich_repr__`` 

64 This method doesn't have any arguments. It should return an iterable of tuples 

65 describing object's arguments: 

66 

67 - ``yield name, value`` will generate a keyword argument, 

68 - ``yield name, value, default`` will generate a keyword argument if value 

69 is not equal to default, 

70 - if `name` is :data:`None`, it will generate positional argument instead. 

71 

72 See the `Rich library documentation`__ for more info. 

73 

74 __ https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol 

75 

76 **Example:** 

77 

78 .. code-block:: python 

79 

80 class MyObject: 

81 def __init__(self, value1, value2): 

82 self.value1 = value1 

83 self.value2 = value2 

84 

85 def __rich_repr__(self) -> yuio.string.RichReprResult: 

86 yield "value1", self.value1 

87 yield "value2", self.value2 

88 

89.. type:: RichReprResult 

90 :canonical: typing.Iterable[tuple[typing.Any] | tuple[str | None, typing.Any] | tuple[str | None, typing.Any, typing.Any]] 

91 

92 This is an alias similar to ``rich.repr.Result``, but stricter: it only 

93 allows tuples, not arbitrary values. 

94 

95 This is done to avoid bugs where you yield a single value which happens to contain 

96 a tuple, and Yuio (or Rich) renders it as a named argument. 

97 

98 

99.. type:: ColorizedStrProtocol 

100 

101 Protocol for objects that define ``__colorized_str__`` method. 

102 

103.. type:: ColorizedReprProtocol 

104 

105 Protocol for objects that define ``__colorized_repr__`` method. 

106 

107.. type:: RichReprProtocol 

108 

109 Protocol for objects that define ``__rich_repr__`` method. 

110 

111.. type:: Printable 

112 

113 Any object that supports printing. 

114 

115 Technically, any object supports colorized printing because we'll fall back 

116 to ``__repr__`` or ``__str__`` if there are no special methods on it. 

117 

118 However, we don't use :class:`typing.Any` to avoid potential errors. 

119 

120.. type:: Colorable 

121 :canonical: Printable | ColorizedStrProtocol | ColorizedReprProtocol | RichReprProtocol | ~typing.LiteralString | BaseException 

122 

123 An object that supports colorized printing. 

124 

125 This can be a string, and exception, or any object that follows 

126 :class:`ColorizedStrProtocol`. Additionally, you can pass any object that has 

127 ``__repr__``, but you'll have to wrap it into :type:`Printable` to confirm 

128 your intent to print it. 

129 

130.. type:: ToColorable 

131 :canonical: Colorable | ~string.templatelib.Template 

132 

133 Any object that can be converted to a :type:`Colorable` by formatting it via 

134 :class:`Format`. 

135 

136.. autofunction:: repr_from_rich 

137 

138 

139.. _formatting-utilities: 

140 

141Formatting utilities 

142-------------------- 

143 

144.. autoclass:: Format 

145 :members: 

146 

147.. autoclass:: Repr 

148 :members: 

149 

150.. autoclass:: TypeRepr 

151 :members: 

152 

153.. autoclass:: JoinStr 

154 :members: 

155 :inherited-members: 

156 

157.. autoclass:: JoinRepr 

158 :members: 

159 :inherited-members: 

160 

161.. autofunction:: And 

162 

163.. autofunction:: Or 

164 

165.. autoclass:: Stack 

166 :members: 

167 

168.. autoclass:: Link 

169 :members: 

170 

171.. autoclass:: Indent 

172 :members: 

173 

174.. autoclass:: Md 

175 :members: 

176 

177.. autoclass:: Rst 

178 :members: 

179 

180.. autoclass:: Hl 

181 :members: 

182 

183.. autoclass:: Wrap 

184 :members: 

185 

186.. autoclass:: WithBaseColor 

187 :members: 

188 

189.. autoclass:: Hr 

190 :members: 

191 

192 

193Parsing color tags 

194------------------ 

195 

196.. autofunction:: colorize 

197 

198.. autofunction:: strip_color_tags 

199 

200 

201Helpers 

202------- 

203 

204.. autofunction:: line_width 

205 

206.. type:: AnyString 

207 :canonical: str | ~yuio.color.Color | ColorizedString | NoWrapMarker | typing.Iterable[AnyString] 

208 

209 Any string (i.e. a :class:`str`, a raw colorized string, 

210 or a normal colorized string). 

211 

212.. autoclass:: LinkMarker 

213 

214.. autodata:: NO_WRAP_START 

215 

216.. autodata:: NO_WRAP_END 

217 

218.. type:: NoWrapMarker 

219 NoWrapStart 

220 NoWrapEnd 

221 

222 Type of a no-wrap marker. 

223 

224""" 

225 

226from __future__ import annotations 

227 

228import abc 

229import collections 

230import contextlib 

231import dataclasses 

232import functools 

233import os 

234import pathlib 

235import re 

236import reprlib 

237import string 

238import sys 

239import types 

240import unicodedata 

241from dataclasses import dataclass 

242from enum import Enum 

243 

244import yuio 

245import yuio.color 

246import yuio.term 

247import yuio.theme 

248from yuio.color import Color as _Color 

249from yuio.util import UserString as _UserString 

250from yuio.util import dedent as _dedent 

251 

252import yuio._typing_ext as _tx 

253from typing import TYPE_CHECKING 

254 

255if TYPE_CHECKING: 

256 import typing_extensions as _t 

257else: 

258 from yuio import _typing as _t 

259 

260if sys.version_info >= (3, 14): 

261 from string.templatelib import Interpolation as _Interpolation 

262 from string.templatelib import Template as _Template 

263else: 

264 

265 class _Interpolation: ... 

266 

267 class _Template: ... 

268 

269 _Interpolation.__module__ = "string.templatelib" 

270 _Interpolation.__name__ = "Interpolation" 

271 _Interpolation.__qualname__ = "Interpolation" 

272 _Template.__module__ = "string.templatelib" 

273 _Template.__name__ = "Template" 

274 _Template.__qualname__ = "Template" 

275 

276 

277__all__ = [ 

278 "NO_WRAP_END", 

279 "NO_WRAP_START", 

280 "And", 

281 "AnyString", 

282 "Colorable", 

283 "ColorizedReprProtocol", 

284 "ColorizedStrProtocol", 

285 "ColorizedString", 

286 "Esc", 

287 "Format", 

288 "Hl", 

289 "Hr", 

290 "Indent", 

291 "JoinRepr", 

292 "JoinStr", 

293 "Link", 

294 "LinkMarker", 

295 "Md", 

296 "NoWrapEnd", 

297 "NoWrapMarker", 

298 "NoWrapStart", 

299 "Or", 

300 "Printable", 

301 "Repr", 

302 "ReprContext", 

303 "RichReprProtocol", 

304 "RichReprResult", 

305 "Rst", 

306 "Stack", 

307 "ToColorable", 

308 "TypeRepr", 

309 "WithBaseColor", 

310 "Wrap", 

311 "colorize", 

312 "line_width", 

313 "repr_from_rich", 

314 "strip_color_tags", 

315] 

316 

317 

318def line_width(s: str, /) -> int: 

319 """ 

320 Calculates string width when the string is displayed 

321 in a terminal. 

322 

323 This function makes effort to detect wide characters 

324 such as emojis. If does not, however, work correctly 

325 with extended grapheme clusters, and so it may fail 

326 for emojis with modifiers, or other complex characters. 

327 

328 Example where it fails is ``👩🏽‍💻``. It consists 

329 of four code points: 

330 

331 - Unicode Character `WOMAN` (``U+1F469``, ``👩``), 

332 - Unicode Character `EMOJI MODIFIER FITZPATRICK TYPE-4` (``U+1F3FD``), 

333 - Unicode Character `ZERO WIDTH JOINER` (``U+200D``), 

334 - Unicode Character `PERSONAL COMPUTER` (``U+1F4BB``, ``💻``). 

335 

336 Since :func:`line_width` can't understand that these code points 

337 are combined into a single emoji, it treats them separately, 

338 resulting in answer `6` (`2` for every code point except `ZERO WIDTH JOINER`):: 

339 

340 >>> line_width("\U0001f469\U0001f3fd\U0000200d\U0001f4bb") 

341 6 

342 

343 In all fairness, detecting how much space such an emoji will take 

344 is not so straight forward, as that will depend on unicode capabilities 

345 of a specific terminal. Since a lot of terminals will not handle such emojis 

346 correctly, I've decided to go with this simplistic implementation. 

347 

348 """ 

349 

350 # Note: it may be better to bundle `wcwidth` and use it instead of the code below. 

351 # However, there is an issue that `wcwidth`'s results are not additive. 

352 # In the above example, `wcswidth('👩🏽‍💻')` will see that it is two-spaces wide, 

353 # while `sum(wcwidth(c) for c in '👩🏽‍💻')` will report that it is four-spaces wide. 

354 # To render it properly, the widget will have to be aware of extended grapheme 

355 # clusters, and generally this will be a lot of headache. Since most terminals 

356 # won't handle these edge cases correctly, I don't want to bother. 

357 

358 if s.isascii(): 

359 # Fast path. Note that our renderer replaces unprintable characters 

360 # with spaces, so ascii strings always have width equal to their length. 

361 return len(s) 

362 else: 

363 # Long path. It kinda works, but not always, but most of the times... 

364 return sum( 

365 (unicodedata.east_asian_width(c) in "WF") + 1 

366 for c in s 

367 if unicodedata.category(c)[0] not in "MC" 

368 ) 

369 

370 

371RichReprResult: _t.TypeAlias = _t.Iterable[ 

372 tuple[_t.Any] | tuple[str | None, _t.Any] | tuple[str | None, _t.Any, _t.Any] 

373] 

374""" 

375Similar to ``rich.repr.Result``, but only allows tuples, not arbitrary values. 

376 

377""" 

378 

379 

380@_t.runtime_checkable 

381class ColorizedStrProtocol(_t.Protocol): 

382 """ 

383 Protocol for objects that define ``__colorized_str__`` method. 

384 

385 """ 

386 

387 @abc.abstractmethod 

388 def __colorized_str__(self, ctx: ReprContext, /) -> ColorizedString: ... 

389 

390 

391@_t.runtime_checkable 

392class ColorizedReprProtocol(_t.Protocol): 

393 """ 

394 Protocol for objects that define ``__colorized_repr__`` method. 

395 

396 """ 

397 

398 @abc.abstractmethod 

399 def __colorized_repr__(self, ctx: ReprContext, /) -> ColorizedString: ... 

400 

401 

402@_t.runtime_checkable 

403class RichReprProtocol(_t.Protocol): 

404 """ 

405 Protocol for objects that define ``__rich_repr__`` method. 

406 

407 """ 

408 

409 @abc.abstractmethod 

410 def __rich_repr__(self) -> _t.Iterable[_t.Any]: ... 

411 

412 

413Printable = _t.NewType("Printable", object) 

414""" 

415Any object that supports printing. 

416 

417Technically, any object supports colorized printing because we'll fall back 

418to ``__repr__`` or ``__str__`` if there are no special methods on it. 

419 

420However, we don't use :class:`typing.Any` to avoid potential errors. 

421 

422""" 

423 

424 

425Colorable: _t.TypeAlias = ( 

426 Printable 

427 | ColorizedStrProtocol 

428 | ColorizedReprProtocol 

429 | RichReprProtocol 

430 | str 

431 | BaseException 

432) 

433""" 

434Any object that supports colorized printing. 

435 

436This can be a string, and exception, or any object that follows 

437:class:`ColorizedStrProtocol`. Additionally, you can pass any object that has 

438``__repr__``, but you'll have to wrap it into :type:`Printable` to confirm 

439your intent to print it. 

440 

441""" 

442 

443ToColorable: _t.TypeAlias = Colorable | _Template 

444""" 

445Any object that can be converted to a :type:`Colorable` by formatting it via 

446:class:`Format`. 

447 

448""" 

449 

450 

451RichReprProtocolT = _t.TypeVar("RichReprProtocolT", bound=RichReprProtocol) 

452 

453 

454def repr_from_rich(cls: type[RichReprProtocolT], /) -> type[RichReprProtocolT]: 

455 """repr_from_rich(cls: RichReprProtocol) -> RichReprProtocol 

456 

457 A decorator that generates ``__repr__`` from ``__rich_repr__``. 

458 

459 :param cls: 

460 class that needs ``__repr__``. 

461 :returns: 

462 always returns `cls`. 

463 :example: 

464 .. code-block:: python 

465 

466 @yuio.string.repr_from_rich 

467 class MyClass: 

468 def __init__(self, value): 

469 self.value = value 

470 

471 def __rich_repr__(self) -> yuio.string.RichReprResult: 

472 yield "value", self.value 

473 

474 :: 

475 

476 >>> print(repr(MyClass("plush!"))) 

477 MyClass(value='plush!') 

478 

479 

480 """ 

481 

482 setattr(cls, "__repr__", _repr_from_rich_impl) 

483 return cls 

484 

485 

486def _repr_from_rich_impl(self: RichReprProtocol): 

487 if rich_repr := getattr(self, "__rich_repr__", None): 

488 args = rich_repr() 

489 angular = getattr(rich_repr, "angular", False) 

490 else: 

491 args = [] 

492 angular = False 

493 

494 if args is None: 

495 args = [] # `rich_repr` didn't yield? 

496 

497 res = [] 

498 

499 if angular: 

500 res.append("<") 

501 res.append(self.__class__.__name__) 

502 if angular: 

503 res.append(" ") 

504 else: 

505 res.append("(") 

506 

507 sep = False 

508 for arg in args: 

509 if isinstance(arg, tuple): 

510 if len(arg) == 3: 

511 key, child, default = arg 

512 if default == child: 

513 continue 

514 elif len(arg) == 2: 

515 key, child = arg 

516 elif len(arg) == 1: 

517 key, child = None, arg[0] 

518 else: 

519 key, child = None, arg 

520 else: 

521 key, child = None, arg 

522 

523 if sep: 

524 res.append(" " if angular else ", ") 

525 if key: 

526 res.append(str(key)) 

527 res.append("=") 

528 res.append(repr(child)) 

529 sep = True 

530 

531 res.append(">" if angular else ")") 

532 

533 return "".join(res) 

534 

535 

536class NoWrapMarker(Enum): 

537 """ 

538 Type for a no-wrap marker. 

539 

540 """ 

541 

542 NO_WRAP_START = "<no_wrap_start>" 

543 NO_WRAP_END = "<no_wrap_end>" 

544 

545 def __repr__(self): 

546 return f"yuio.string.{self.name}" # pragma: no cover 

547 

548 def __str__(self) -> str: 

549 return self.value # pragma: no cover 

550 

551 

552NoWrapStart: _t.TypeAlias = _t.Literal[NoWrapMarker.NO_WRAP_START] 

553""" 

554Type of the :data:`NO_WRAP_START` placeholder. 

555 

556""" 

557 

558NO_WRAP_START: NoWrapStart = NoWrapMarker.NO_WRAP_START 

559""" 

560Indicates start of a no-wrap region in a :class:`ColorizedString`. 

561 

562""" 

563 

564 

565NoWrapEnd: _t.TypeAlias = _t.Literal[NoWrapMarker.NO_WRAP_END] 

566""" 

567Type of the :data:`NO_WRAP_END` placeholder. 

568 

569""" 

570 

571NO_WRAP_END: NoWrapEnd = NoWrapMarker.NO_WRAP_END 

572""" 

573Indicates end of a no-wrap region in a :class:`ColorizedString`. 

574 

575""" 

576 

577 

578@dataclass(slots=True, frozen=True, unsafe_hash=True) 

579class LinkMarker: 

580 """ 

581 Indicates start or end of a hyperlink in a colorized string. 

582 

583 """ 

584 

585 url: str | None 

586 """ 

587 Hyperlink's url. 

588 

589 """ 

590 

591 

592@_t.final 

593@repr_from_rich 

594class ColorizedString: 

595 """ColorizedString() 

596 ColorizedString(rhs: ColorizedString, /) 

597 ColorizedString(*args: AnyString, /) 

598 

599 A string with colors. 

600 

601 This class is a wrapper over a list of strings, colors, and no-wrap markers. 

602 Each color applies to strings after it, right until the next color. 

603 

604 :class:`ColorizedString` supports some basic string operations. 

605 Most notably, it supports wide-character-aware wrapping 

606 (see :meth:`~ColorizedString.wrap`), 

607 and ``%``-like formatting (see :meth:`~ColorizedString.percent_format`). 

608 

609 Unlike :class:`str`, :class:`ColorizedString` is mutable through 

610 the ``+=`` operator and ``append``/``extend`` methods. 

611 

612 :param rhs: 

613 when constructor gets a single :class:`ColorizedString`, it makes a copy. 

614 :param args: 

615 when constructor gets multiple arguments, it creates an empty string 

616 and appends arguments to it. 

617 

618 

619 **String combination semantics** 

620 

621 When you append a :class:`str`, it will take on color and no-wrap semantics 

622 according to the last appended color and no-wrap marker. 

623 

624 When you append another :class:`ColorizedString`, it will not change its colors 

625 based on the last appended color, nor will it affect colors of the consequent 

626 strings. If appended :class:`ColorizedString` had an unterminated no-wrap region 

627 or link region, this region will be terminated after appending. 

628 

629 Thus, appending a colorized string does not change current color, no-wrap 

630 or link setting:: 

631 

632 >>> s1 = yuio.string.ColorizedString() 

633 >>> s1 += yuio.color.Color.FORE_RED 

634 >>> s1 += yuio.string.NO_WRAP_START 

635 >>> s1 += "red nowrap text" 

636 >>> s1 # doctest: +NORMALIZE_WHITESPACE 

637 ColorizedString([yuio.string.NO_WRAP_START, 

638 <Color fore=<RED>>, 

639 'red nowrap text']) 

640 

641 >>> s2 = yuio.string.ColorizedString() 

642 >>> s2 += yuio.color.Color.FORE_GREEN 

643 >>> s2 += "green text " 

644 >>> s2 += s1 

645 >>> s2 += " green text continues" 

646 >>> s2 # doctest: +NORMALIZE_WHITESPACE 

647 ColorizedString([<Color fore=<GREEN>>, 

648 'green text ', 

649 yuio.string.NO_WRAP_START, 

650 <Color fore=<RED>>, 

651 'red nowrap text', 

652 yuio.string.NO_WRAP_END, 

653 <Color fore=<GREEN>>, 

654 ' green text continues']) 

655 

656 """ 

657 

658 # Invariants: 

659 # 

660 # - there is always a color before the first string in `_parts`. 

661 # - there are no empty strings in `_parts`. 

662 # - for every pair of colors in `_parts`, there is a string between them 

663 # (i.e. there are no colors that don't highlight anything). 

664 # - every color in `_parts` is different from the previous one 

665 # (i.e. there are no redundant color markers). 

666 # - `start-no-wrap` and `end-no-wrap` markers form a balanced bracket sequence, 

667 # except for the last `start-no-wrap`, which may have no corresponding 

668 # `end-no-wrap` yet. 

669 # - no-wrap regions can't be nested. 

670 # - for every pair of (start-no-wrap, end-no-wrap) markers, there is a string 

671 # between them (i.e. no empty no-wrap regions). 

672 

673 def __init__( 

674 self, 

675 /, 

676 *args: AnyString, 

677 _isolate_colors: bool = True, 

678 ): 

679 if len(args) == 1 and isinstance(args[0], ColorizedString): 

680 content = args[0] 

681 self._parts = content._parts.copy() 

682 self._last_color = content._last_color 

683 self._active_color = content._active_color 

684 self._last_url = content._last_url 

685 self._active_url = content._active_url 

686 self._explicit_newline = content._explicit_newline 

687 self._len = content._len 

688 self._has_no_wrap = content._has_no_wrap 

689 if (width := content.__dict__.get("width", None)) is not None: 

690 self.__dict__["width"] = width 

691 else: 

692 self._parts: list[_Color | NoWrapMarker | LinkMarker | str] = [] 

693 self._active_color = _Color.NONE 

694 self._last_color: _Color | None = None 

695 self._last_url: str | None = None 

696 self._active_url: str | None = None 

697 self._explicit_newline: str = "" 

698 self._len = 0 

699 self._has_no_wrap = False 

700 

701 if not _isolate_colors: 

702 # Prevent adding `_Color.NONE` to the front of the string. 

703 self._last_color = self._active_color 

704 

705 for arg in args: 

706 self += arg 

707 

708 @property 

709 def explicit_newline(self) -> str: 

710 """ 

711 Explicit newline indicates that a line of a wrapped text 

712 was broken because the original text contained a new line character. 

713 

714 See :meth:`~ColorizedString.wrap` for details. 

715 

716 """ 

717 

718 return self._explicit_newline 

719 

720 @property 

721 def active_color(self) -> _Color: 

722 """ 

723 Last color appended to this string. 

724 

725 """ 

726 

727 return self._active_color 

728 

729 @property 

730 def active_url(self) -> str | None: 

731 """ 

732 Last url appended to this string. 

733 

734 """ 

735 

736 return self._active_url 

737 

738 @functools.cached_property 

739 def width(self) -> int: 

740 """ 

741 String width when the string is displayed in a terminal. 

742 

743 See :func:`line_width` for more information. 

744 

745 """ 

746 

747 return sum(line_width(s) for s in self._parts if isinstance(s, str)) 

748 

749 @property 

750 def len(self) -> int: 

751 """ 

752 Line length in bytes, ignoring all colors. 

753 

754 """ 

755 

756 return self._len 

757 

758 def append_color(self, color: _Color, /): 

759 """ 

760 Append new color to this string. 

761 

762 This operation is lazy, the color will be appended if a non-empty string 

763 is appended after it. 

764 

765 :param color: 

766 color to append. 

767 

768 """ 

769 

770 self._active_color = color 

771 

772 def append_link(self, url: str | None, /): 

773 """ 

774 Append new link marker to this string. 

775 

776 This operation is lazy, the link marker will be appended if a non-empty string 

777 is appended after it.s 

778 

779 :param url: 

780 link url. 

781 

782 """ 

783 

784 self._active_url = url 

785 

786 def start_link(self, url: str, /): 

787 """ 

788 Start hyperlink with the given url. 

789 

790 :param url: 

791 link url. 

792 

793 """ 

794 

795 self._active_url = url 

796 

797 def end_link(self): 

798 """ 

799 End hyperlink. 

800 

801 """ 

802 

803 self._active_url = None 

804 

805 def append_str(self, s: str, /): 

806 """ 

807 Append new plain string to this string. 

808 

809 :param s: 

810 plain string to append. 

811 

812 """ 

813 

814 if not s: 

815 return 

816 if self._last_url != self._active_url: 

817 self._parts.append(LinkMarker(self._active_url)) 

818 self._last_url = self._active_url 

819 if self._last_color != self._active_color: 

820 self._parts.append(self._active_color) 

821 self._last_color = self._active_color 

822 self._parts.append(s) 

823 self._len += len(s) 

824 self.__dict__.pop("width", None) 

825 

826 def append_colorized_str(self, s: ColorizedString, /): 

827 """ 

828 Append new colorized string to this string. 

829 

830 :param s: 

831 colorized string to append. 

832 

833 """ 

834 if not s: 

835 # Nothing to append. 

836 return 

837 

838 parts = s._parts 

839 

840 # Cleanup color at the beginning of the string. 

841 for i, part in enumerate(parts): 

842 if part in (NO_WRAP_START, NO_WRAP_END) or isinstance(part, LinkMarker): 

843 continue 

844 elif isinstance(part, str): # pragma: no cover 

845 # We never hit this branch in normal conditions because colorized 

846 # strings always start with a color. The only way to trigger this 

847 # branch is to tamper with `_parts` and break colorized string 

848 # invariants. 

849 break 

850 

851 # First color in the appended string is the same as our last color. 

852 # We can remove it without changing the outcome. 

853 if part == self._last_color: 

854 if i == 0: 

855 parts = parts[i + 1 :] 

856 else: 

857 parts = parts[:i] + parts[i + 1 :] 

858 

859 break 

860 

861 if self._has_no_wrap: 

862 # We're in a no-wrap sequence, we don't need any more markers. 

863 parts = filter(lambda part: part not in (NO_WRAP_START, NO_WRAP_END), parts) 

864 

865 if self._active_url: 

866 # Current url overrides appended urls. 

867 parts = filter(lambda part: not isinstance(part, LinkMarker), parts) 

868 

869 # Ensure that current url marker is added to the string. 

870 # We don't need to do this with colors because `parts` already starts with 

871 # a correct color. 

872 if self._last_url != self._active_url: 

873 self._parts.append(LinkMarker(self._active_url)) 

874 self._last_url = self._active_url 

875 

876 self._parts.extend(parts) 

877 

878 if not self._has_no_wrap and s._has_no_wrap: 

879 self._has_no_wrap = True 

880 self.end_no_wrap() 

881 if not self._active_url and s._last_url: 

882 self._last_url = s._last_url 

883 

884 self._last_color = s._last_color 

885 self._len += s._len 

886 if (lw := self.__dict__.get("width")) and (rw := s.__dict__.get("width")): 

887 self.__dict__["width"] = lw + rw 

888 else: 

889 self.__dict__.pop("width", None) 

890 

891 def append_no_wrap(self, m: NoWrapMarker, /): 

892 """ 

893 Append a no-wrap marker. 

894 

895 :param m: 

896 no-wrap marker, will be dispatched 

897 to :meth:`~ColorizedString.start_no_wrap` 

898 or :meth:`~ColorizedString.end_no_wrap`. 

899 

900 """ 

901 

902 if m is NO_WRAP_START: 

903 self.start_no_wrap() 

904 else: 

905 self.end_no_wrap() 

906 

907 def start_no_wrap(self): 

908 """ 

909 Start a no-wrap region. 

910 

911 String parts within no-wrap regions are not wrapped on spaces; they can be 

912 hard-wrapped if `break_long_nowrap_words` is :data:`True`. Whitespaces and 

913 newlines in no-wrap regions are preserved regardless of `preserve_spaces` 

914 and `preserve_newlines` settings. 

915 

916 """ 

917 

918 if self._has_no_wrap: 

919 return 

920 

921 self._has_no_wrap = True 

922 self._parts.append(NO_WRAP_START) 

923 

924 def end_no_wrap(self): 

925 """ 

926 End a no-wrap region. 

927 

928 """ 

929 

930 if not self._has_no_wrap: 

931 return 

932 

933 if self._parts and self._parts[-1] is NO_WRAP_START: 

934 # Empty no-wrap sequence, just remove it. 

935 self._parts.pop() 

936 else: 

937 self._parts.append(NO_WRAP_END) 

938 

939 self._has_no_wrap = False 

940 

941 def extend( 

942 self, 

943 parts: _t.Iterable[str | ColorizedString | _Color | NoWrapMarker | LinkMarker], 

944 /, 

945 ): 

946 """ 

947 Extend string from iterable of raw parts. 

948 

949 :param parts: 

950 raw parts that will be appended to the string. 

951 

952 """ 

953 

954 for part in parts: 

955 self += part 

956 

957 def copy(self) -> ColorizedString: 

958 """ 

959 Copy this string. 

960 

961 :returns: 

962 copy of the string. 

963 

964 """ 

965 

966 return ColorizedString(self) 

967 

968 def _split_at(self, i: int, /) -> tuple[ColorizedString, ColorizedString]: 

969 l, r = ColorizedString(), ColorizedString() 

970 l.extend(self._parts[:i]) 

971 r._active_color = l._active_color 

972 r._active_url = l._active_url 

973 r._has_no_wrap = l._has_no_wrap # TODO: waat??? 

974 r.extend(self._parts[i:]) 

975 r._active_color = self._active_color 

976 return l, r 

977 

978 def with_base_color(self, base_color: _Color) -> ColorizedString: 

979 """ 

980 Apply the given color "under" all parts of this string. That is, all colors 

981 in this string will be combined with this color on the left: 

982 ``base_color | color``. 

983 

984 :param base_color: 

985 color that will be added under the string. 

986 :returns: 

987 new string with changed colors, or current string if base color 

988 is :attr:`~yuio.color.Color.NONE`. 

989 :example: 

990 :: 

991 

992 >>> s1 = yuio.string.ColorizedString([ 

993 ... "part 1", 

994 ... yuio.color.Color.FORE_GREEN, 

995 ... "part 2", 

996 ... ]) 

997 >>> s2 = s1.with_base_color( 

998 ... yuio.color.Color.FORE_RED 

999 ... | yuio.color.Color.STYLE_BOLD 

1000 ... ) 

1001 >>> s2 # doctest: +NORMALIZE_WHITESPACE 

1002 ColorizedString([<Color fore=<RED> bold=True>, 

1003 'part 1', 

1004 <Color fore=<GREEN> bold=True>, 

1005 'part 2']) 

1006 

1007 """ 

1008 

1009 if base_color == _Color.NONE: 

1010 return self 

1011 

1012 res = ColorizedString() 

1013 

1014 for part in self._parts: 

1015 if isinstance(part, _Color): 

1016 res.append_color(base_color | part) 

1017 else: 

1018 res += part 

1019 res._active_color = base_color | self._active_color 

1020 if self._last_color is not None: 

1021 res._last_color = base_color | self._last_color 

1022 

1023 return res 

1024 

1025 def as_code(self, color_support: yuio.color.ColorSupport, /) -> list[str]: 

1026 """ 

1027 Convert colors in this string to ANSI escape sequences. 

1028 

1029 :param color_support: 

1030 desired level of color support. 

1031 :returns: 

1032 raw parts of colorized string with all colors converted to ANSI 

1033 escape sequences. 

1034 

1035 """ 

1036 

1037 if color_support == yuio.color.ColorSupport.NONE: 

1038 return [part for part in self._parts if isinstance(part, str)] 

1039 else: 

1040 parts: list[str] = [] 

1041 for part in self: 

1042 if isinstance(part, LinkMarker): 

1043 parts.append("\x1b]8;;") 

1044 parts.append(part.url or "") 

1045 parts.append("\x1b\\") 

1046 elif isinstance(part, str): 

1047 parts.append(part) 

1048 elif isinstance(part, _Color): 

1049 parts.append(part.as_code(color_support)) 

1050 if self._last_color != _Color.NONE: 

1051 parts.append(_Color.NONE.as_code(color_support)) 

1052 if self._last_url is not None: 

1053 parts.append("\x1b]8;;\x1b\\") 

1054 return parts 

1055 

1056 def wrap( 

1057 self, 

1058 width: int, 

1059 /, 

1060 *, 

1061 preserve_spaces: bool = False, 

1062 preserve_newlines: bool = True, 

1063 break_long_words: bool = True, 

1064 break_long_nowrap_words: bool = False, 

1065 overflow: _t.Literal[False] | str = False, 

1066 indent: AnyString | int = "", 

1067 continuation_indent: AnyString | int | None = None, 

1068 ) -> list[ColorizedString]: 

1069 """ 

1070 Wrap a long line of text into multiple lines. 

1071 

1072 :param width: 

1073 desired wrapping width. 

1074 :param preserve_spaces: 

1075 if set to :data:`True`, all spaces are preserved. 

1076 Otherwise, consecutive spaces are collapsed into a single space. 

1077 

1078 Note that tabs always treated as a single whitespace. 

1079 :param preserve_newlines: 

1080 if set to :data:`True` (default), text is additionally wrapped 

1081 on newline sequences. When this happens, the newline sequence that wrapped 

1082 the line will be placed into :attr:`~ColorizedString.explicit_newline`. 

1083 

1084 If set to :data:`False`, newline sequences are treated as whitespaces. 

1085 

1086 .. list-table:: Whitespace sequences 

1087 :header-rows: 1 

1088 :stub-columns: 1 

1089 

1090 * - Sequence 

1091 - `preserve_newlines` 

1092 - Result 

1093 * - ``\\n``, ``\\r\\n``, ``\\r`` 

1094 - ``False`` 

1095 - Treated as a single whitespace. 

1096 * - ``\\n``, ``\\r\\n``, ``\\r`` 

1097 - ``True`` 

1098 - Creates a new line. 

1099 * - ``\\v``, ``\\v\\n``, ``\\v\\r\\n``, ``\\v\\r`` 

1100 - Any 

1101 - Always creates a new line. 

1102 

1103 :param break_long_words: 

1104 if set to :data:`True` (default), words that don't fit into a single line 

1105 will be split into multiple lines. 

1106 :param break_long_nowrap_words: 

1107 if set to :data:`True`, words in no-wrap regions that don't fit 

1108 into a single line will be split into multiple lines. 

1109 :param overflow: 

1110 a symbol that will be added to a line if it doesn't fit the given width. 

1111 Pass :data:`False` to keep the overflowing lines without modification. 

1112 :param indent: 

1113 a string that will be prepended before the first line. 

1114 :param continuation_indent: 

1115 a string that will be prepended before all subsequent lines. 

1116 Defaults to `indent`. 

1117 :returns: 

1118 a list of individual lines without newline characters at the end. 

1119 

1120 """ 

1121 

1122 return _TextWrapper( 

1123 width, 

1124 preserve_spaces=preserve_spaces, 

1125 preserve_newlines=preserve_newlines, 

1126 break_long_words=break_long_words, 

1127 break_long_nowrap_words=break_long_nowrap_words, 

1128 overflow=overflow, 

1129 indent=indent, 

1130 continuation_indent=continuation_indent, 

1131 ).wrap(self) 

1132 

1133 def indent( 

1134 self, 

1135 indent: AnyString | int = " ", 

1136 continuation_indent: AnyString | int | None = None, 

1137 ) -> ColorizedString: 

1138 """ 

1139 Indent this string. 

1140 

1141 :param indent: 

1142 this will be prepended to the first line in the string. 

1143 Defaults to two spaces. 

1144 :param continuation_indent: 

1145 this will be prepended to subsequent lines in the string. 

1146 Defaults to `indent`. 

1147 :returns: 

1148 indented string. 

1149 

1150 """ 

1151 

1152 nowrap_indent = ColorizedString() 

1153 nowrap_indent.start_no_wrap() 

1154 nowrap_continuation_indent = ColorizedString() 

1155 nowrap_continuation_indent.start_no_wrap() 

1156 if isinstance(indent, int): 

1157 nowrap_indent.append_str(" " * indent) 

1158 else: 

1159 nowrap_indent += indent 

1160 if continuation_indent is None: 

1161 nowrap_continuation_indent.append_colorized_str(nowrap_indent) 

1162 elif isinstance(continuation_indent, int): 

1163 nowrap_continuation_indent.append_str(" " * continuation_indent) 

1164 else: 

1165 nowrap_continuation_indent += continuation_indent 

1166 

1167 if not nowrap_indent and not nowrap_continuation_indent: 

1168 return self 

1169 

1170 res = ColorizedString() 

1171 

1172 needs_indent = True 

1173 for part in self._parts: 

1174 if not isinstance(part, str) or isinstance(part, Esc): 

1175 res += part 

1176 continue 

1177 

1178 for line in _WORDSEP_NL_RE.split(part): 

1179 if not line: 

1180 continue 

1181 if needs_indent: 

1182 url = res.active_url 

1183 res.end_link() 

1184 res.append_colorized_str(nowrap_indent) 

1185 res.append_link(url) 

1186 nowrap_indent = nowrap_continuation_indent 

1187 res.append_str(line) 

1188 needs_indent = line.endswith(("\n", "\r", "\v")) 

1189 

1190 return res 

1191 

1192 def percent_format(self, args: _t.Any, ctx: ReprContext) -> ColorizedString: 

1193 """ 

1194 Format colorized string as if with ``%``-formatting 

1195 (i.e. `printf-style formatting`__). 

1196 

1197 __ https://docs.python.org/3/library/stdtypes.html#printf-style-string-formatting 

1198 

1199 :param args: 

1200 arguments for formatting. Can be either a tuple of a mapping. Any other 

1201 value will be converted to a tuple of one element. 

1202 :param ctx: 

1203 :class:`ReprContext` that will be passed to ``__colorized_str__`` 

1204 and ``__colorized_repr__`` when formatting colorables. 

1205 :returns: 

1206 formatted string. 

1207 :raises: 

1208 :class:`TypeError`, :class:`ValueError`, :class:`KeyError` if formatting 

1209 fails. 

1210 

1211 """ 

1212 

1213 return _percent_format(self, args, ctx) 

1214 

1215 def __len__(self) -> int: 

1216 return self.len 

1217 

1218 def __bool__(self) -> bool: 

1219 return self.len > 0 

1220 

1221 def __iter__(self) -> _t.Iterator[_Color | NoWrapMarker | LinkMarker | str]: 

1222 return self._parts.__iter__() 

1223 

1224 def __add__(self, rhs: AnyString) -> ColorizedString: 

1225 copy = self.copy() 

1226 copy += rhs 

1227 return copy 

1228 

1229 def __radd__(self, lhs: AnyString) -> ColorizedString: 

1230 copy = ColorizedString(lhs) 

1231 copy += self 

1232 return copy 

1233 

1234 def __iadd__(self, rhs: AnyString) -> ColorizedString: 

1235 if isinstance(rhs, str): 

1236 self.append_str(rhs) 

1237 elif isinstance(rhs, ColorizedString): 

1238 self.append_colorized_str(rhs) 

1239 elif isinstance(rhs, _Color): 

1240 self.append_color(rhs) 

1241 elif rhs in (NO_WRAP_START, NO_WRAP_END): 

1242 self.append_no_wrap(rhs) 

1243 elif isinstance(rhs, LinkMarker): 

1244 self.append_link(rhs.url) 

1245 else: 

1246 self.extend(rhs) 

1247 

1248 return self 

1249 

1250 def __eq__(self, value: object) -> bool: 

1251 if isinstance(value, ColorizedString): 

1252 return self._parts == value._parts 

1253 else: 

1254 return NotImplemented 

1255 

1256 def __ne__(self, value: object) -> bool: 

1257 return not (self == value) 

1258 

1259 def __rich_repr__(self) -> RichReprResult: 

1260 yield None, self._parts 

1261 yield "explicit_newline", self._explicit_newline, "" 

1262 

1263 def __str__(self) -> str: 

1264 return "".join(c for c in self._parts if isinstance(c, str)) 

1265 

1266 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString: 

1267 return self 

1268 

1269 

1270AnyString: _t.TypeAlias = ( 

1271 str 

1272 | ColorizedString 

1273 | _Color 

1274 | NoWrapMarker 

1275 | LinkMarker 

1276 | _t.Iterable[str | ColorizedString | _Color | NoWrapMarker | LinkMarker] 

1277) 

1278""" 

1279Any string (i.e. a :class:`str`, a raw colorized string, or a normal colorized string). 

1280 

1281""" 

1282 

1283 

1284_S_SYNTAX = re.compile( 

1285 r""" 

1286 % # Percent 

1287 (?:\((?P<mapping>[^)]*)\))? # Mapping key 

1288 (?P<flag>[#0\-+ ]*) # Conversion Flag 

1289 (?P<width>\*|\d+)? # Field width 

1290 (?:\.(?P<precision>\*|\d*))? # Precision 

1291 [hlL]? # Unused length modifier 

1292 (?P<format>.) # Conversion type 

1293 """, 

1294 re.VERBOSE, 

1295) 

1296 

1297_F_SYNTAX = re.compile( 

1298 r""" 

1299 ^ 

1300 (?: # Options 

1301 (?: 

1302 (?P<fill>.)? 

1303 (?P<align>[<>=^]) 

1304 )? 

1305 (?P<flags>[+#]*) 

1306 (?P<zero>0)? 

1307 ) 

1308 (?: # Width 

1309 (?P<width>\d+)? 

1310 (?P<width_grouping>[,_])? 

1311 ) 

1312 (?: # Precision 

1313 \. 

1314 (?P<precision>\d+)? 

1315 (?P<precision_grouping>[,_])? 

1316 )? 

1317 (?: # Type 

1318 (?P<type>.) 

1319 )? 

1320 $ 

1321 """, 

1322 re.VERBOSE, 

1323) 

1324 

1325 

1326def _percent_format( 

1327 s: ColorizedString, args: object, ctx: ReprContext 

1328) -> ColorizedString: 

1329 seen_mapping = False 

1330 arg_index = 0 

1331 res = ColorizedString() 

1332 for part in s: 

1333 if isinstance(part, str): 

1334 pos = 0 

1335 for match in _S_SYNTAX.finditer(part): 

1336 if pos < match.start(): 

1337 res.append_str(part[pos : match.start()]) 

1338 seen_mapping = seen_mapping or bool(match.group("mapping")) 

1339 last_color = res.active_color 

1340 arg_index, replaced = _percent_format_repl( 

1341 match, args, arg_index, last_color, ctx 

1342 ) 

1343 res += replaced 

1344 res.append_color(last_color) 

1345 pos = match.end() 

1346 if pos < len(part): 

1347 res.append_str(part[pos:]) 

1348 else: 

1349 res += part 

1350 

1351 if (isinstance(args, tuple) and arg_index < len(args)) or ( 

1352 not isinstance(args, tuple) 

1353 and ( 

1354 not hasattr(args, "__getitem__") 

1355 or isinstance(args, (str, bytes, bytearray)) 

1356 ) 

1357 and not seen_mapping 

1358 and not arg_index 

1359 ): 

1360 raise TypeError("not all arguments converted during string formatting") 

1361 

1362 return res 

1363 

1364 

1365def _percent_format_repl( 

1366 match: _tx.StrReMatch, 

1367 args: object, 

1368 arg_index: int, 

1369 base_color: _Color, 

1370 ctx: ReprContext, 

1371) -> tuple[int, str | ColorizedString]: 

1372 if match.group("format") == "%": 

1373 if match.group(0) != "%%": 

1374 raise ValueError("unsupported format character '%'") 

1375 return arg_index, "%" 

1376 

1377 if match.group("format") in "rsa": 

1378 return _percent_format_repl_str(match, args, arg_index, base_color, ctx) 

1379 

1380 if mapping := match.group("mapping"): 

1381 try: 

1382 fmt_arg = args[mapping] # type: ignore 

1383 except TypeError: 

1384 raise TypeError("format requires a mapping") from None 

1385 fmt_arg, added_color = _unwrap_base_color(fmt_arg, ctx.theme) 

1386 if added_color: 

1387 fmt_args = {mapping: fmt_arg} 

1388 else: 

1389 fmt_args = args 

1390 elif isinstance(args, tuple): 

1391 try: 

1392 fmt_arg = args[arg_index] 

1393 except IndexError: 

1394 raise TypeError("not enough arguments for format string") 

1395 fmt_arg, added_color = _unwrap_base_color(fmt_arg, ctx.theme) 

1396 begin = arg_index + 1 

1397 end = arg_index = ( 

1398 arg_index 

1399 + 1 

1400 + (match.group("width") == "*") 

1401 + (match.group("precision") == "*") 

1402 ) 

1403 fmt_args = (fmt_arg,) + args[begin:end] 

1404 elif arg_index == 0: 

1405 fmt_args, added_color = _unwrap_base_color(args, ctx.theme) 

1406 arg_index += 1 

1407 else: 

1408 raise TypeError("not enough arguments for format string") 

1409 

1410 fmt = match.group(0) % fmt_args 

1411 if added_color: 

1412 added_color = ctx.to_color(added_color) 

1413 fmt = ColorizedString([base_color | added_color, fmt]) 

1414 return arg_index, fmt 

1415 

1416 

1417def _unwrap_base_color(x, theme: yuio.theme.Theme): 

1418 color = None 

1419 while isinstance(x, WithBaseColor): 

1420 x, base_color = x._msg, x._base_color 

1421 base_color = theme.to_color(base_color) 

1422 if color: 

1423 color = color | base_color 

1424 else: 

1425 color = base_color 

1426 else: 

1427 return x, color 

1428 

1429 

1430def _percent_format_repl_str( 

1431 match: _tx.StrReMatch, 

1432 args: object, 

1433 arg_index: int, 

1434 base_color: _Color, 

1435 ctx: ReprContext, 

1436) -> tuple[int, str | ColorizedString]: 

1437 if width_s := match.group("width"): 

1438 if width_s == "*": 

1439 if not isinstance(args, tuple): 

1440 raise TypeError("* wants int") 

1441 try: 

1442 width = args[arg_index] 

1443 arg_index += 1 

1444 except (KeyError, IndexError): 

1445 raise TypeError("not enough arguments for format string") 

1446 if not isinstance(width, int): 

1447 raise TypeError("* wants int") 

1448 else: 

1449 width = int(width_s) 

1450 else: 

1451 width = None 

1452 

1453 if precision_s := match.group("precision"): 

1454 if precision_s == "*": 

1455 if not isinstance(args, tuple): 

1456 raise TypeError("* wants int") 

1457 try: 

1458 precision = args[arg_index] 

1459 arg_index += 1 

1460 except (KeyError, IndexError): 

1461 raise TypeError("not enough arguments for format string") 

1462 if not isinstance(precision, int): 

1463 raise TypeError("* wants int") 

1464 else: 

1465 precision = int(precision_s) 

1466 else: 

1467 precision = None 

1468 

1469 if mapping := match.group("mapping"): 

1470 try: 

1471 fmt_arg = args[mapping] # type: ignore 

1472 except TypeError: 

1473 raise TypeError("format requires a mapping") from None 

1474 elif isinstance(args, tuple): 

1475 try: 

1476 fmt_arg = args[arg_index] 

1477 arg_index += 1 

1478 except IndexError: 

1479 raise TypeError("not enough arguments for format string") from None 

1480 elif arg_index == 0: 

1481 fmt_arg = args 

1482 arg_index += 1 

1483 else: 

1484 raise TypeError("not enough arguments for format string") 

1485 

1486 flag = match.group("flag") 

1487 multiline = "+" in flag 

1488 highlighted = "#" in flag 

1489 

1490 res = ctx.convert( 

1491 fmt_arg, 

1492 match.group("format"), # type: ignore 

1493 multiline=multiline, 

1494 highlighted=highlighted, 

1495 ) 

1496 

1497 align = match.group("flag") 

1498 if width is not None and width < 0: 

1499 width = -width 

1500 align = "<" 

1501 elif align == "-": 

1502 align = "<" 

1503 else: 

1504 align = ">" 

1505 res = _apply_format(res, width, precision, align, " ") 

1506 

1507 return arg_index, res.with_base_color(base_color) 

1508 

1509 

1510def _format_interpolation(interp: _Interpolation, ctx: ReprContext) -> ColorizedString: 

1511 value = interp.value 

1512 if ( 

1513 interp.conversion is not None 

1514 or getattr(type(value), "__format__", None) is object.__format__ 

1515 or isinstance(value, (str, ColorizedString)) 

1516 ): 

1517 value = ctx.convert(value, interp.conversion, interp.format_spec) 

1518 else: 

1519 value = ColorizedString(format(value, interp.format_spec)) 

1520 

1521 return value 

1522 

1523 

1524def _apply_format( 

1525 value: ColorizedString, 

1526 width: int | None, 

1527 precision: int | None, 

1528 align: str | None, 

1529 fill: str | None, 

1530): 

1531 if precision is not None and value.width > precision: 

1532 cut = ColorizedString() 

1533 for part in value: 

1534 if precision <= 0: 

1535 break 

1536 if isinstance(part, str): 

1537 part_width = line_width(part) 

1538 if part_width <= precision: 

1539 cut.append_str(part) 

1540 precision -= part_width 

1541 elif part.isascii(): 

1542 cut.append_str(part[:precision]) 

1543 break 

1544 else: 

1545 for j, ch in enumerate(part): 

1546 precision -= line_width(ch) 

1547 if precision == 0: 

1548 cut.append_str(part[: j + 1]) 

1549 break 

1550 elif precision < 0: 

1551 cut.append_str(part[:j]) 

1552 cut.append_str(" ") 

1553 break 

1554 break 

1555 else: 

1556 cut += part 

1557 value = cut 

1558 

1559 if width is not None and width > value.width: 

1560 fill = fill or " " 

1561 fill_width = line_width(fill) 

1562 spacing = width - value.width 

1563 spacing_fill = spacing // fill_width 

1564 spacing_space = spacing - spacing_fill * fill_width 

1565 value.append_color(_Color.NONE) 

1566 if not align or align == "<": 

1567 value = value + fill * spacing_fill + " " * spacing_space 

1568 elif align == ">": 

1569 value = fill * spacing_fill + " " * spacing_space + value 

1570 else: 

1571 left = spacing_fill // 2 

1572 right = spacing_fill - left 

1573 value = fill * left + value + fill * right + " " * spacing_space 

1574 

1575 return value 

1576 

1577 

1578__TAG_RE = re.compile( 

1579 r""" 

1580 <c (?P<tag_open>[a-z0-9 _/@:-]+)> # _Color tag open. 

1581 | </c> # _Color tag close. 

1582 | \\(?P<punct>[%(punct)s]) # Escape character. 

1583 | (?<!`)(`+)(?!`)(?P<code>.*?)(?<!`)\3(?!`) # Inline code block (backticks). 

1584 """ 

1585 % {"punct": re.escape(string.punctuation)}, 

1586 re.VERBOSE | re.MULTILINE, 

1587) 

1588__NEG_NUM_RE = re.compile(r"^-(0x[0-9a-fA-F]+|0b[01]+|\d+(e[+-]?\d+)?)$") 

1589__FLAG_RE = re.compile(r"^-[-a-zA-Z0-9_]*$") 

1590 

1591 

1592def colorize( 

1593 template: str | _Template, 

1594 /, 

1595 *args: _t.Any, 

1596 ctx: ReprContext, 

1597 default_color: _Color | str = _Color.NONE, 

1598) -> ColorizedString: 

1599 """colorize(line: str, /, *args: typing.Any, ctx: ReprContext, default_color: ~yuio.color.Color | str = Color.NONE, parse_cli_flags_in_backticks: bool = False) -> ColorizedString 

1600 colorize(line: ~string.templatelib.Template, /, *, ctx: ReprContext, default_color: ~yuio.color.Color | str = Color.NONE, parse_cli_flags_in_backticks: bool = False) -> ColorizedString 

1601 

1602 Parse color tags and produce a colorized string. 

1603 

1604 Apply ``default_color`` to the entire paragraph, and process color tags 

1605 and backticks within it. 

1606 

1607 :param line: 

1608 text to colorize. 

1609 :param args: 

1610 if given, string will be ``%``-formatted after parsing. 

1611 Can't be given if `line` is :class:`~string.templatelib.Template`. 

1612 :param ctx: 

1613 :class:`ReprContext` that will be used to look up color tags 

1614 and format arguments. 

1615 :param default_color: 

1616 color or color tag to apply to the entire text. 

1617 :returns: 

1618 a colorized string. 

1619 

1620 """ 

1621 

1622 interpolations: list[tuple[int, _Interpolation]] = [] 

1623 if isinstance(template, _Template): 

1624 if args: 

1625 raise TypeError("args can't be given with template") 

1626 line = "" 

1627 index = 0 

1628 for part, interp in zip(template.strings, template.interpolations): 

1629 line += part 

1630 # Each interpolation is replaced by a zero byte so that our regex knows 

1631 # there is something. 

1632 line += "\0" 

1633 index += len(part) + 1 

1634 interpolations.append((index, interp)) 

1635 line += template.strings[-1] 

1636 else: 

1637 line = template 

1638 

1639 default_color = ctx.to_color(default_color) 

1640 

1641 res = ColorizedString(default_color) 

1642 stack = [default_color] 

1643 last_pos = 0 

1644 last_interp = 0 

1645 

1646 def append_to_res(s: str, start: int): 

1647 nonlocal last_interp 

1648 

1649 index = 0 

1650 while ( 

1651 last_interp < len(interpolations) 

1652 and start + len(s) >= interpolations[last_interp][0] 

1653 ): 

1654 interp_start, interp = interpolations[last_interp] 

1655 res.append_str( 

1656 s[ 

1657 index : interp_start 

1658 - start 

1659 - 1 # This compensates for that `\0` we added above. 

1660 ] 

1661 ) 

1662 res.append_colorized_str( 

1663 _format_interpolation(interp, ctx).with_base_color(res.active_color) 

1664 ) 

1665 index = interp_start - start 

1666 last_interp += 1 

1667 res.append_str(s[index:]) 

1668 

1669 for tag in __TAG_RE.finditer(line): 

1670 append_to_res(line[last_pos : tag.start()], last_pos) 

1671 last_pos = tag.end() 

1672 

1673 if name := tag.group("tag_open"): 

1674 color = stack[-1] | ctx.get_color(name) 

1675 res.append_color(color) 

1676 stack.append(color) 

1677 elif code := tag.group("code"): 

1678 code = code.replace("\n", " ") 

1679 code_pos = tag.start("code") 

1680 if code.startswith(" ") and code.endswith(" ") and not code.isspace(): 

1681 code = code[1:-1] 

1682 code_pos += 1 

1683 if __FLAG_RE.match(code) and not __NEG_NUM_RE.match(code): 

1684 res.append_color(stack[-1] | ctx.get_color("flag")) 

1685 else: 

1686 res.append_color(stack[-1] | ctx.get_color("code")) 

1687 res.start_no_wrap() 

1688 append_to_res(code, code_pos) 

1689 res.end_no_wrap() 

1690 res.append_color(stack[-1]) 

1691 elif punct := tag.group("punct"): 

1692 append_to_res(punct, tag.start("punct")) 

1693 elif len(stack) > 1: 

1694 stack.pop() 

1695 res.append_color(stack[-1]) 

1696 

1697 append_to_res(line[last_pos:], last_pos) 

1698 

1699 if args: 

1700 return res.percent_format(args, ctx) 

1701 else: 

1702 return res 

1703 

1704 

1705def strip_color_tags(s: str) -> str: 

1706 """ 

1707 Remove all color tags from a string. 

1708 

1709 """ 

1710 

1711 raw: list[str] = [] 

1712 

1713 last_pos = 0 

1714 for tag in __TAG_RE.finditer(s): 

1715 raw.append(s[last_pos : tag.start()]) 

1716 last_pos = tag.end() 

1717 

1718 if code := tag.group("code"): 

1719 code = code.replace("\n", " ") 

1720 if code.startswith(" ") and code.endswith(" ") and not code.isspace(): 

1721 code = code[1:-1] 

1722 raw.append(code) 

1723 elif punct := tag.group("punct"): 

1724 raw.append(punct) 

1725 

1726 raw.append(s[last_pos:]) 

1727 

1728 return "".join(raw) 

1729 

1730 

1731class Esc(_UserString): 

1732 """ 

1733 A string that can't be broken during word wrapping even 

1734 if `break_long_nowrap_words` is :data:`True`. 

1735 

1736 """ 

1737 

1738 __slots__ = () 

1739 

1740 

1741_SPACE_TRANS = str.maketrans("\r\n\t\v\b\f", " ") 

1742 

1743_WORD_PUNCT = r'[\w!"\'&.,?]' 

1744_LETTER = r"[^\d\W]" 

1745_NOWHITESPACE = r"[^ \r\n\t\v\b\f]" 

1746 

1747# Copied from textwrap with some modifications in newline handling 

1748_WORDSEP_RE = re.compile( 

1749 r""" 

1750 ( # newlines and line feeds are matched one-by-one 

1751 (?:\r\n|\r|\n|\v\r\n|\v\r|\v\n|\v) 

1752 | # any whitespace 

1753 [ \t\b\f]+ 

1754 | # em-dash between words 

1755 (?<=%(wp)s) -{2,} (?=\w) 

1756 | # word, possibly hyphenated 

1757 %(nws)s+? (?: 

1758 # hyphenated word 

1759 -(?: (?<=%(lt)s{2}-) | (?<=%(lt)s-%(lt)s-)) 

1760 (?= %(lt)s -? %(lt)s) 

1761 | # end of word 

1762 (?=[ \r\n\t\v\b\f]|\Z) 

1763 | # em-dash 

1764 (?<=%(wp)s) (?=-{2,}\w) 

1765 ) 

1766 )""" 

1767 % {"wp": _WORD_PUNCT, "lt": _LETTER, "nws": _NOWHITESPACE}, 

1768 re.VERBOSE, 

1769) 

1770_WORDSEP_NL_RE = re.compile(r"(\r\n|\r|\n|\v\r\n|\v\r|\v\n|\v)") 

1771 

1772 

1773class _TextWrapper: 

1774 def __init__( 

1775 self, 

1776 width: int, 

1777 /, 

1778 *, 

1779 preserve_spaces: bool, 

1780 preserve_newlines: bool, 

1781 break_long_words: bool, 

1782 break_long_nowrap_words: bool, 

1783 overflow: _t.Literal[False] | str, 

1784 indent: AnyString | int, 

1785 continuation_indent: AnyString | int | None, 

1786 ): 

1787 self.width = width 

1788 self.preserve_spaces: bool = preserve_spaces 

1789 self.preserve_newlines: bool = preserve_newlines 

1790 self.break_long_words: bool = break_long_words 

1791 self.break_long_nowrap_words: bool = break_long_nowrap_words 

1792 self.overflow: _t.Literal[False] | str = overflow 

1793 

1794 self.indent = ColorizedString() 

1795 self.indent.start_no_wrap() 

1796 self.continuation_indent = ColorizedString() 

1797 self.continuation_indent.start_no_wrap() 

1798 if isinstance(indent, int): 

1799 self.indent.append_str(" " * indent) 

1800 else: 

1801 self.indent += indent 

1802 if continuation_indent is None: 

1803 self.continuation_indent.append_colorized_str(self.indent) 

1804 elif isinstance(continuation_indent, int): 

1805 self.continuation_indent.append_str(" " * continuation_indent) 

1806 else: 

1807 self.continuation_indent += continuation_indent 

1808 

1809 self.lines: list[ColorizedString] = [] 

1810 

1811 self.current_line = ColorizedString() 

1812 if self.indent: 

1813 self.current_line += self.indent 

1814 self.current_line_width: int = self.indent.width 

1815 self.at_line_start: bool = True 

1816 self.at_line_start_or_indent: bool = True 

1817 self.has_ellipsis: bool = False 

1818 self.add_spaces_before_word: int = 0 

1819 

1820 self.nowrap_start_index = None 

1821 self.nowrap_start_width = 0 

1822 self.nowrap_start_added_space = False 

1823 

1824 def _flush_line(self, explicit_newline=""): 

1825 self.current_line._explicit_newline = explicit_newline 

1826 self.lines.append(self.current_line) 

1827 

1828 next_line = ColorizedString() 

1829 

1830 if self.continuation_indent: 

1831 next_line += self.continuation_indent 

1832 

1833 next_line.append_color(self.current_line.active_color) 

1834 next_line.append_link(self.current_line.active_url) 

1835 

1836 self.current_line = next_line 

1837 self.current_line_width: int = self.continuation_indent.width 

1838 self.at_line_start = True 

1839 self.at_line_start_or_indent = True 

1840 self.has_ellipsis = False 

1841 self.nowrap_start_index = None 

1842 self.nowrap_start_width = 0 

1843 self.nowrap_start_added_space = False 

1844 self.add_spaces_before_word = 0 

1845 

1846 def _flush_line_part(self): 

1847 assert self.nowrap_start_index is not None 

1848 self.current_line, tail = self.current_line._split_at(self.nowrap_start_index) 

1849 tail_width = self.current_line_width - self.nowrap_start_width 

1850 if ( 

1851 self.nowrap_start_added_space 

1852 and self.current_line._parts 

1853 and self.current_line._parts[-1] == " " 

1854 ): 

1855 # Remove space that was added before no-wrap sequence. 

1856 self.current_line._parts.pop() 

1857 self._flush_line() 

1858 self.current_line += tail 

1859 self.current_line.append_color(tail.active_color) 

1860 self.current_line.append_link(tail.active_url) 

1861 self.current_line_width += tail_width 

1862 

1863 def _append_str(self, s: str): 

1864 self.current_line.append_str(s) 

1865 self.at_line_start = False 

1866 self.at_line_start_or_indent = self.at_line_start_or_indent and s.isspace() 

1867 

1868 def _append_word(self, word: str, word_width: int): 

1869 if ( 

1870 self.overflow is not False 

1871 and self.current_line_width + word_width > self.width 

1872 ): 

1873 if isinstance(word, Esc): 

1874 if self.overflow: 

1875 self._add_ellipsis() 

1876 return 

1877 

1878 word_head_len = word_head_width = 0 

1879 

1880 for c in word: 

1881 c_width = line_width(c) 

1882 if self.current_line_width + word_head_width + c_width > self.width: 

1883 break 

1884 word_head_len += 1 

1885 word_head_width += c_width 

1886 

1887 if word_head_len: 

1888 self._append_str(word[:word_head_len]) 

1889 self.has_ellipsis = False 

1890 self.current_line_width += word_head_width 

1891 

1892 if self.overflow: 

1893 self._add_ellipsis() 

1894 else: 

1895 self._append_str(word) 

1896 self.current_line_width += word_width 

1897 self.has_ellipsis = False 

1898 

1899 def _append_space(self): 

1900 if self.add_spaces_before_word: 

1901 word = " " * self.add_spaces_before_word 

1902 self._append_word(word, 1) 

1903 self.add_spaces_before_word = 0 

1904 

1905 def _add_ellipsis(self): 

1906 if self.has_ellipsis: 

1907 # Already has an ellipsis. 

1908 return 

1909 

1910 if self.current_line_width + 1 <= self.width: 

1911 # There's enough space on this line to add new ellipsis. 

1912 self._append_str(str(self.overflow)) 

1913 self.current_line_width += 1 

1914 self.has_ellipsis = True 

1915 elif not self.at_line_start: 

1916 # Modify last word on this line, if there is any. 

1917 parts = self.current_line._parts 

1918 for i in range(len(parts) - 1, -1, -1): 

1919 part = parts[i] 

1920 if isinstance(part, str): 

1921 if not isinstance(part, Esc): 

1922 parts[i] = f"{part[:-1]}{self.overflow}" 

1923 self.has_ellipsis = True 

1924 return 

1925 

1926 def _append_word_with_breaks(self, word: str, word_width: int): 

1927 while self.current_line_width + word_width > self.width: 

1928 word_head_len = word_head_width = 0 

1929 

1930 for c in word: 

1931 c_width = line_width(c) 

1932 if self.current_line_width + word_head_width + c_width > self.width: 

1933 break 

1934 word_head_len += 1 

1935 word_head_width += c_width 

1936 

1937 if self.at_line_start and not word_head_len: 

1938 if self.overflow: 

1939 return 

1940 else: 

1941 word_head_len = 1 

1942 word_head_width += line_width(word[:1]) 

1943 

1944 self._append_word(word[:word_head_len], word_head_width) 

1945 

1946 word = word[word_head_len:] 

1947 word_width -= word_head_width 

1948 

1949 self._flush_line() 

1950 

1951 if word: 

1952 self._append_word(word, word_width) 

1953 

1954 def wrap(self, text: ColorizedString) -> list[ColorizedString]: 

1955 nowrap = False 

1956 

1957 for part in text: 

1958 if isinstance(part, _Color): 

1959 if ( 

1960 self.add_spaces_before_word 

1961 and self.current_line_width + self.add_spaces_before_word 

1962 < self.width 

1963 ): 

1964 # Make sure any whitespace that was added before color 

1965 # is flushed. If it doesn't fit, we just forget it: the line 

1966 # will be wrapped soon anyways. 

1967 self._append_space() 

1968 self.add_spaces_before_word = 0 

1969 self.current_line.append_color(part) 

1970 continue 

1971 elif isinstance(part, LinkMarker): 

1972 if ( 

1973 self.add_spaces_before_word 

1974 and self.current_line_width + self.add_spaces_before_word 

1975 < self.width 

1976 ): 

1977 # Make sure any whitespace that was added before color 

1978 # is flushed. If it doesn't fit, we just forget it: the line 

1979 # will be wrapped soon anyways. 

1980 self._append_space() 

1981 self.add_spaces_before_word = 0 

1982 self.current_line.append_link(part.url) 

1983 continue 

1984 elif part is NO_WRAP_START: 

1985 if nowrap: # pragma: no cover 

1986 continue 

1987 if ( 

1988 self.add_spaces_before_word 

1989 and self.current_line_width + self.add_spaces_before_word 

1990 < self.width 

1991 ): 

1992 # Make sure any whitespace that was added before no-wrap 

1993 # is flushed. If it doesn't fit, we just forget it: the line 

1994 # will be wrapped soon anyways. 

1995 self._append_space() 

1996 self.nowrap_start_added_space = True 

1997 else: 

1998 self.nowrap_start_added_space = False 

1999 self.add_spaces_before_word = 0 

2000 if self.at_line_start: 

2001 self.nowrap_start_index = None 

2002 self.nowrap_start_width = 0 

2003 else: 

2004 self.nowrap_start_index = len(self.current_line._parts) 

2005 self.nowrap_start_width = self.current_line_width 

2006 nowrap = True 

2007 continue 

2008 elif part is NO_WRAP_END: 

2009 nowrap = False 

2010 self.nowrap_start_index = None 

2011 self.nowrap_start_width = 0 

2012 self.nowrap_start_added_space = False 

2013 continue 

2014 

2015 esc = False 

2016 if isinstance(part, Esc): 

2017 words = [Esc(part.translate(_SPACE_TRANS))] 

2018 esc = True 

2019 elif nowrap: 

2020 words = _WORDSEP_NL_RE.split(part) 

2021 else: 

2022 words = _WORDSEP_RE.split(part) 

2023 

2024 for word in words: 

2025 if not word: 

2026 # `_WORDSEP_RE` produces empty strings, skip them. 

2027 continue 

2028 

2029 if word.startswith(("\v", "\r", "\n")): 

2030 # `_WORDSEP_RE` yields one newline sequence at a time, we don't 

2031 # need to split the word further. 

2032 if nowrap or self.preserve_newlines or word.startswith("\v"): 

2033 self._flush_line(explicit_newline=word) 

2034 continue 

2035 else: 

2036 # Treat any newline sequence as a single space. 

2037 word = " " 

2038 

2039 isspace = not esc and word.isspace() 

2040 if isspace: 

2041 if ( 

2042 # Spaces are preserved in no-wrap sequences. 

2043 nowrap 

2044 # Spaces are explicitly preserved. 

2045 or self.preserve_spaces 

2046 # We preserve indentation even if `preserve_spaces` is `False`. 

2047 # We need to check that the previous line ended with an 

2048 # explicit newline, otherwise this is not an indent. 

2049 or ( 

2050 self.at_line_start_or_indent 

2051 and (not self.lines or self.lines[-1].explicit_newline) 

2052 ) 

2053 ): 

2054 word = word.translate(_SPACE_TRANS) 

2055 else: 

2056 self.add_spaces_before_word = len(word) 

2057 continue 

2058 

2059 word_width = line_width(word) 

2060 

2061 if self._try_fit_word(word, word_width): 

2062 # Word fits onto the current line. 

2063 continue 

2064 

2065 if self.nowrap_start_index is not None: 

2066 # Move the entire no-wrap sequence onto the new line. 

2067 self._flush_line_part() 

2068 

2069 if self._try_fit_word(word, word_width): 

2070 # Word fits onto the current line after we've moved 

2071 # no-wrap sequence. Nothing more to do. 

2072 continue 

2073 

2074 if ( 

2075 not self.at_line_start 

2076 and ( 

2077 # Spaces can be broken anywhere, so we don't break line 

2078 # for them: `_append_word_with_breaks` will do it for us. 

2079 # Note: `esc` implies `not isspace`, so all `esc` words 

2080 # outside of no-wrap sequences are handled by this check. 

2081 (not nowrap and not isspace) 

2082 # No-wrap sequences are broken in the middle of any word, 

2083 # so we don't need any special handling for them 

2084 # (again, `_append_word_with_breaks` will do breaking for us). 

2085 # An exception is `esc` words which can't be broken in the middle; 

2086 # if the break is possible at all, it must happen here. 

2087 or (nowrap and esc and self.break_long_nowrap_words) 

2088 ) 

2089 and not ( 

2090 # This is an esc word which wouldn't fit onto this line, nor onto 

2091 # the next line, and there's enough space for an ellipsis 

2092 # on this line (or it already has one). We don't need to break 

2093 # the line here: this word will be passed to `_append_word`, 

2094 # which will handle ellipsis for us. 

2095 self.overflow is not False 

2096 and esc 

2097 and self.continuation_indent.width + word_width > self.width 

2098 and ( 

2099 self.has_ellipsis 

2100 or self.current_line_width + self.add_spaces_before_word + 1 

2101 <= self.width 

2102 ) 

2103 ) 

2104 ): 

2105 # Flush a non-empty line. 

2106 self._flush_line() 

2107 

2108 # Note: `need_space_before_word` is always `False` at this point. 

2109 # `need_space_before_word` becomes `True` only when current line 

2110 # is non-empty, we're not in no-wrap sequence, and `preserve_spaces` 

2111 # is `False` (meaning `isspace` is also `False`). In such situation, 

2112 # we flush the line in the condition above. 

2113 if not esc and ( 

2114 (nowrap and self.break_long_nowrap_words) 

2115 or (not nowrap and (self.break_long_words or isspace)) 

2116 ): 

2117 # We will break the word in the middle if it doesn't fit. 

2118 self._append_word_with_breaks(word, word_width) 

2119 else: 

2120 self._append_word(word, word_width) 

2121 

2122 if self.current_line or not self.lines or self.lines[-1].explicit_newline: 

2123 self._flush_line() 

2124 

2125 return self.lines 

2126 

2127 def _try_fit_word(self, word: str, word_width: int): 

2128 if ( 

2129 self.current_line_width + word_width + self.add_spaces_before_word 

2130 <= self.width 

2131 ): 

2132 self._append_space() 

2133 self._append_word(word, word_width) 

2134 return True 

2135 else: 

2136 return False 

2137 

2138 

2139class _ReprContextState(Enum): 

2140 START = 0 

2141 """ 

2142 Initial state. 

2143 

2144 """ 

2145 

2146 CONTAINER_START = 1 

2147 """ 

2148 Right after a token starting a container was pushed. 

2149 

2150 """ 

2151 

2152 ITEM_START = 2 

2153 """ 

2154 Right after a token separating container items was pushed. 

2155 

2156 """ 

2157 

2158 NORMAL = 3 

2159 """ 

2160 In the middle of a container element. 

2161 

2162 """ 

2163 

2164 

2165@_t.final 

2166class ReprContext: 

2167 """ 

2168 Context object that tracks repr settings and ensures that recursive objects 

2169 are handled properly. 

2170 

2171 .. warning:: 

2172 

2173 :class:`~yuio.string.ReprContext`\\ s are not thread safe. As such, 

2174 you shouldn't create them for long term use. 

2175 

2176 :param term: 

2177 terminal that will be used to print formatted messages. 

2178 :param theme: 

2179 theme that will be used to format messages. 

2180 :param multiline: 

2181 indicates that values rendered via `rich repr protocol`_ 

2182 should be split into multiple lines. Default is :data:`False`. 

2183 :param highlighted: 

2184 indicates that values rendered via `rich repr protocol`_ 

2185 or via built-in :func:`repr` should be highlighted according to python syntax. 

2186 Default is :data:`False`. 

2187 :param max_depth: 

2188 maximum depth of nested containers, after which container's contents 

2189 are not rendered. Default is ``5``. 

2190 :param width: 

2191 maximum width of the content, used when wrapping text, rendering markdown, 

2192 or rendering horizontal rulers. If not given, defaults 

2193 to :attr:`Theme.fallback_width <yuio.theme.Theme.fallback_width>`. 

2194 

2195 .. _rich repr protocol: https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol 

2196 

2197 """ 

2198 

2199 def __init__( 

2200 self, 

2201 *, 

2202 term: yuio.term.Term, 

2203 theme: yuio.theme.Theme, 

2204 multiline: bool | None = None, 

2205 highlighted: bool | None = None, 

2206 max_depth: int | None = None, 

2207 width: int | None = None, 

2208 ): 

2209 self.term = term 

2210 """ 

2211 Current term. 

2212 

2213 """ 

2214 

2215 self.theme = theme 

2216 """ 

2217 Current theme. 

2218 

2219 """ 

2220 

2221 self.multiline: bool = multiline if multiline is not None else False 

2222 """ 

2223 Whether values rendered with :meth:`~ReprContext.repr` are split into multiple lines. 

2224 

2225 """ 

2226 

2227 self.highlighted: bool = highlighted if highlighted is not None else False 

2228 """ 

2229 Whether values rendered with :meth:`~ReprContext.repr` are highlighted. 

2230 

2231 """ 

2232 

2233 self.max_depth: int = max_depth if max_depth is not None else 5 

2234 """ 

2235 Maximum depth of nested containers, after which container's contents 

2236 are not rendered. 

2237 

2238 """ 

2239 

2240 self.width: int = max(width or theme.fallback_width, 1) 

2241 """ 

2242 Maximum width of the content, used when wrapping text or rendering markdown. 

2243 

2244 """ 

2245 

2246 self._seen: set[int] = set() 

2247 self._line = ColorizedString() 

2248 self._indent = 0 

2249 self._state = _ReprContextState.START 

2250 self._pending_sep = None 

2251 

2252 import yuio.hl 

2253 

2254 self._hl, _ = yuio.hl.get_highlighter("repr") 

2255 self._base_color = theme.get_color("msg/text:code/repr") 

2256 

2257 @staticmethod 

2258 def make_dummy(is_unicode: bool = True) -> ReprContext: 

2259 """ 

2260 Make a dummy repr context with default settings. 

2261 

2262 """ 

2263 

2264 return ReprContext( 

2265 term=yuio.term.Term.make_dummy(is_unicode=is_unicode), 

2266 theme=yuio.theme.Theme(), 

2267 ) 

2268 

2269 def get_color(self, paths: str, /) -> yuio.color.Color: 

2270 """ 

2271 Lookup a color by path. 

2272 

2273 """ 

2274 

2275 return self.theme.get_color(paths) 

2276 

2277 def to_color( 

2278 self, color_or_path: yuio.color.Color | str | None, / 

2279 ) -> yuio.color.Color: 

2280 """ 

2281 Convert color or color path to color. 

2282 

2283 """ 

2284 

2285 return self.theme.to_color(color_or_path) 

2286 

2287 def get_msg_decoration(self, name: str, /) -> str: 

2288 """ 

2289 Get message decoration by name. 

2290 

2291 """ 

2292 

2293 return self.theme.get_msg_decoration(name, is_unicode=self.term.is_unicode) 

2294 

2295 def _flush_sep(self, trim: bool = False): 

2296 if self._pending_sep is not None: 

2297 self._push_color("punct") 

2298 if trim: 

2299 self._pending_sep = self._pending_sep.rstrip() 

2300 self._line.append_str(self._pending_sep) 

2301 self._pending_sep = None 

2302 

2303 def _flush_line(self): 

2304 if self.multiline: 

2305 self._line.append_color(self._base_color) 

2306 self._line.append_str("\n") 

2307 if self._indent: 

2308 self._line.append_str(" " * self._indent) 

2309 

2310 def _flush_sep_and_line(self): 

2311 if self.multiline and self._state in [ 

2312 _ReprContextState.CONTAINER_START, 

2313 _ReprContextState.ITEM_START, 

2314 ]: 

2315 self._flush_sep(trim=True) 

2316 self._flush_line() 

2317 else: 

2318 self._flush_sep() 

2319 

2320 def _push_color(self, tag: str): 

2321 if self.highlighted: 

2322 self._line.append_color( 

2323 self._base_color | self.theme.to_color(f"hl/{tag}:repr") 

2324 ) 

2325 

2326 def _push_token(self, content: str, tag: str): 

2327 self._flush_sep_and_line() 

2328 

2329 self._push_color(tag) 

2330 self._line.append_str(content) 

2331 

2332 self._state = _ReprContextState.NORMAL 

2333 

2334 def _terminate_item(self, sep: str = ", "): 

2335 self._flush_sep() 

2336 self._pending_sep = sep 

2337 self._state = _ReprContextState.ITEM_START 

2338 

2339 def _start_container(self): 

2340 self._state = _ReprContextState.CONTAINER_START 

2341 self._indent += 1 

2342 

2343 def _end_container(self): 

2344 self._indent -= 1 

2345 

2346 if self._state in [_ReprContextState.NORMAL, _ReprContextState.ITEM_START]: 

2347 self._flush_line() 

2348 

2349 self._state = _ReprContextState.NORMAL 

2350 self._pending_sep = None 

2351 

2352 def repr( 

2353 self, 

2354 value: _t.Any, 

2355 /, 

2356 *, 

2357 multiline: bool | None = None, 

2358 highlighted: bool | None = None, 

2359 width: int | None = None, 

2360 max_depth: int | None = None, 

2361 ) -> ColorizedString: 

2362 """ 

2363 Convert value to colorized string using repr methods. 

2364 

2365 :param value: 

2366 value to be rendered. 

2367 :param multiline: 

2368 if given, overrides settings passed to :class:`ReprContext` for this call. 

2369 :param highlighted: 

2370 if given, overrides settings passed to :class:`ReprContext` for this call. 

2371 :param width: 

2372 if given, overrides settings passed to :class:`ReprContext` for this call. 

2373 :param max_depth: 

2374 if given, overrides settings passed to :class:`ReprContext` for this call. 

2375 :returns: 

2376 a colorized string containing representation of the `value`. 

2377 :raises: 

2378 this method does not raise any errors. If any inner object raises an 

2379 exception, this function returns a colorized string with 

2380 an error description. 

2381 

2382 """ 

2383 

2384 return self._print( 

2385 value, 

2386 multiline=multiline, 

2387 highlighted=highlighted, 

2388 use_str=False, 

2389 width=width, 

2390 max_depth=max_depth, 

2391 ) 

2392 

2393 def str( 

2394 self, 

2395 value: _t.Any, 

2396 /, 

2397 *, 

2398 multiline: bool | None = None, 

2399 highlighted: bool | None = None, 

2400 width: int | None = None, 

2401 max_depth: int | None = None, 

2402 ) -> ColorizedString: 

2403 """ 

2404 Convert value to colorized string. 

2405 

2406 :param value: 

2407 value to be rendered. 

2408 :param multiline: 

2409 if given, overrides settings passed to :class:`ReprContext` for this call. 

2410 :param highlighted: 

2411 if given, overrides settings passed to :class:`ReprContext` for this call. 

2412 :param width: 

2413 if given, overrides settings passed to :class:`ReprContext` for this call. 

2414 :param max_depth: 

2415 if given, overrides settings passed to :class:`ReprContext` for this call. 

2416 :returns: 

2417 a colorized string containing string representation of the `value`. 

2418 :raises: 

2419 this method does not raise any errors. If any inner object raises an 

2420 exception, this function returns a colorized string with 

2421 an error description. 

2422 

2423 """ 

2424 

2425 return self._print( 

2426 value, 

2427 multiline=multiline, 

2428 highlighted=highlighted, 

2429 use_str=True, 

2430 width=width, 

2431 max_depth=max_depth, 

2432 ) 

2433 

2434 def convert( 

2435 self, 

2436 value: _t.Any, 

2437 conversion: _t.Literal["a", "r", "s"] | None, 

2438 format_spec: str | None = None, 

2439 /, 

2440 *, 

2441 multiline: bool | None = None, 

2442 highlighted: bool | None = None, 

2443 width: int | None = None, 

2444 max_depth: int | None = None, 

2445 ): 

2446 """ 

2447 Perform string conversion, similar to :func:`string.templatelib.convert`, 

2448 and format the object with respect to the given `format_spec`. 

2449 

2450 :param value: 

2451 value to be converted. 

2452 :param conversion: 

2453 string conversion method: 

2454 

2455 - ``'s'`` calls :meth:`~ReprContext.str`, 

2456 - ``'r'`` calls :meth:`~ReprContext.repr`, 

2457 - ``'a'`` calls :meth:`~ReprContext.repr` and escapes non-ascii 

2458 characters. 

2459 :param format_spec: 

2460 formatting spec can override `multiline` and `highlighted`, and controls 

2461 width, alignment, fill chars, etc. See its syntax below. 

2462 :param multiline: 

2463 if given, overrides settings passed to :class:`ReprContext` for this call. 

2464 :param highlighted: 

2465 if given, overrides settings passed to :class:`ReprContext` for this call. 

2466 :param width: 

2467 if given, overrides settings passed to :class:`ReprContext` for this call. 

2468 :param max_depth: 

2469 if given, overrides settings passed to :class:`ReprContext` for this call. 

2470 :returns: 

2471 a colorized string containing string representation of the `value`. 

2472 :raises: 

2473 :class:`ValueError` if `conversion` or `format_spec` are invalid. 

2474 

2475 .. _t-string-spec: 

2476 

2477 **Format specification** 

2478 

2479 .. syntax:diagram:: 

2480 

2481 stack: 

2482 - optional: 

2483 - optional: 

2484 - optional: 

2485 - non_terminal: "fill" 

2486 href: "#t-string-spec-fill" 

2487 - non_terminal: "align" 

2488 href: "#t-string-spec-align" 

2489 - non_terminal: "flags" 

2490 href: "#t-string-spec-flags" 

2491 - optional: 

2492 - comment: "width" 

2493 href: "#t-string-spec-width" 

2494 - "[0-9]+" 

2495 - optional: 

2496 - comment: "precision" 

2497 href: "#t-string-spec-precision" 

2498 - "'.'" 

2499 - "[0-9]+" 

2500 - optional: 

2501 - comment: "conversion type" 

2502 href: "#t-string-spec-conversion-type" 

2503 - "'s'" 

2504 skip_bottom: true 

2505 skip: true 

2506 

2507 .. _t-string-spec-fill: 

2508 

2509 ``fill`` 

2510 Any character that will be used to extend string to the desired width. 

2511 

2512 .. _t-string-spec-align: 

2513 

2514 ``align`` 

2515 Controls alignment of a string when `width` is given: ``"<"`` for flushing 

2516 string left, ``">"`` for flushing string right, ``"^"`` for centering. 

2517 

2518 .. _t-string-spec-flags: 

2519 

2520 ``flags`` 

2521 One or several flags: ``"#"`` to enable highlighting, ``"+"`` to enable 

2522 multiline repr. 

2523 

2524 .. _t-string-spec-width: 

2525 

2526 ``width`` 

2527 If formatted string is narrower than this value, it will be extended and 

2528 aligned using `fill` and `align` settings. 

2529 

2530 .. _t-string-spec-precision: 

2531 

2532 ``precision`` 

2533 If formatted string is wider that this value, it will be cropped to this 

2534 width. 

2535 

2536 .. _t-string-spec-conversion-type: 

2537 

2538 ``conversion type`` 

2539 The only supported conversion type is ``"s"``. 

2540 

2541 """ 

2542 

2543 if format_spec: 

2544 match = _F_SYNTAX.match(format_spec) 

2545 if not match: 

2546 raise ValueError(f"invalid format specifier {format_spec!r}") 

2547 fill = match.group("fill") 

2548 align = match.group("align") 

2549 if align == "=": 

2550 raise ValueError("'=' alignment not allowed in string format specifier") 

2551 flags = match.group("flags") 

2552 if "#" in flags: 

2553 highlighted = True 

2554 if "+" in flags: 

2555 multiline = True 

2556 zero = match.group("zero") 

2557 if zero and not fill: 

2558 fill = zero 

2559 format_width = match.group("width") 

2560 if format_width: 

2561 format_width = int(format_width) 

2562 else: 

2563 format_width = None 

2564 format_width_grouping = match.group("width_grouping") 

2565 if format_width_grouping: 

2566 raise ValueError(f"cannot specify {format_width_grouping!r} with 's'") 

2567 format_precision = match.group("precision") 

2568 if format_precision: 

2569 format_precision = int(format_precision) 

2570 else: 

2571 format_precision = None 

2572 type = match.group("type") 

2573 if type and type != "s": 

2574 raise ValueError(f"unknown format code {type!r}") 

2575 else: 

2576 format_width = format_precision = align = fill = None 

2577 

2578 if conversion == "r": 

2579 res = self.repr( 

2580 value, 

2581 multiline=multiline, 

2582 highlighted=highlighted, 

2583 width=width, 

2584 max_depth=max_depth, 

2585 ) 

2586 elif conversion == "a": 

2587 res = ColorizedString() 

2588 for part in self.repr( 

2589 value, 

2590 multiline=multiline, 

2591 highlighted=highlighted, 

2592 width=width, 

2593 max_depth=max_depth, 

2594 ): 

2595 if isinstance(part, _UserString): 

2596 res += part._wrap( 

2597 part.encode(encoding="unicode_escape").decode("ascii") 

2598 ) 

2599 elif isinstance(part, str): 

2600 res += part.encode(encoding="unicode_escape").decode("ascii") 

2601 else: 

2602 res += part 

2603 elif not conversion or conversion == "s": 

2604 res = self.str( 

2605 value, 

2606 multiline=multiline, 

2607 highlighted=highlighted, 

2608 width=width, 

2609 max_depth=max_depth, 

2610 ) 

2611 else: 

2612 raise ValueError( 

2613 f"unknown conversion {conversion!r}, should be 'a', 'r', or 's'" 

2614 ) 

2615 

2616 return _apply_format(res, format_width, format_precision, align, fill) 

2617 

2618 def hl( 

2619 self, 

2620 value: str, 

2621 /, 

2622 *, 

2623 highlighted: bool | None = None, 

2624 ) -> ColorizedString: 

2625 """ 

2626 Highlight result of :func:`repr`. 

2627 

2628 :meth:`ReprContext.repr` does this automatically, but sometimes you need 

2629 to highlight a string without :func:`repr`-ing it one more time. 

2630 

2631 :param value: 

2632 result of :func:`repr` that needs highlighting. 

2633 :returns: 

2634 highlighted string. 

2635 

2636 """ 

2637 

2638 highlighted = highlighted if highlighted is not None else self.highlighted 

2639 

2640 if highlighted: 

2641 return self._hl.highlight( 

2642 value, theme=self.theme, syntax="repr", default_color=self._base_color 

2643 ) 

2644 else: 

2645 return ColorizedString(value) 

2646 

2647 @contextlib.contextmanager 

2648 def with_settings( 

2649 self, 

2650 *, 

2651 multiline: bool | None = None, 

2652 highlighted: bool | None = None, 

2653 width: int | None = None, 

2654 max_depth: int | None = None, 

2655 ): 

2656 """ 

2657 Temporarily replace settings of this context. 

2658 

2659 :param multiline: 

2660 if given, overrides settings passed to :class:`ReprContext` for this call. 

2661 :param highlighted: 

2662 if given, overrides settings passed to :class:`ReprContext` for this call. 

2663 :param width: 

2664 if given, overrides settings passed to :class:`ReprContext` for this call. 

2665 :param max_depth: 

2666 if given, overrides settings passed to :class:`ReprContext` for this call. 

2667 :returns: 

2668 a context manager that overrides settings. 

2669 

2670 """ 

2671 

2672 old_multiline, self.multiline = ( 

2673 self.multiline, 

2674 (self.multiline if multiline is None else multiline), 

2675 ) 

2676 old_highlighted, self.highlighted = ( 

2677 self.highlighted, 

2678 (self.highlighted if highlighted is None else highlighted), 

2679 ) 

2680 old_width, self.width = ( 

2681 self.width, 

2682 (self.width if width is None else max(width, 1)), 

2683 ) 

2684 old_max_depth, self.max_depth = ( 

2685 self.max_depth, 

2686 (self.max_depth if max_depth is None else max_depth), 

2687 ) 

2688 

2689 try: 

2690 yield 

2691 finally: 

2692 self.multiline = old_multiline 

2693 self.highlighted = old_highlighted 

2694 self.width = old_width 

2695 self.max_depth = old_max_depth 

2696 

2697 def _print( 

2698 self, 

2699 value: _t.Any, 

2700 multiline: bool | None, 

2701 highlighted: bool | None, 

2702 width: int | None, 

2703 max_depth: int | None, 

2704 use_str: bool, 

2705 ) -> ColorizedString: 

2706 old_line, self._line = self._line, ColorizedString() 

2707 old_state, self._state = self._state, _ReprContextState.START 

2708 old_pending_sep, self._pending_sep = self._pending_sep, None 

2709 

2710 try: 

2711 with self.with_settings( 

2712 multiline=multiline, 

2713 highlighted=highlighted, 

2714 width=width, 

2715 max_depth=max_depth, 

2716 ): 

2717 self._print_nested(value, use_str) 

2718 return self._line 

2719 except Exception as e: 

2720 yuio._logger.exception("error in repr context") 

2721 res = ColorizedString() 

2722 res.append_color(_Color.STYLE_INVERSE | _Color.FORE_RED) 

2723 res.append_str(f"{_tx.type_repr(type(e))}: {e}") 

2724 return res 

2725 finally: 

2726 self._line = old_line 

2727 self._state = old_state 

2728 self._pending_sep = old_pending_sep 

2729 

2730 def _print_nested(self, value: _t.Any, use_str: bool = False): 

2731 if id(value) in self._seen or self._indent > self.max_depth: 

2732 self._push_token("...", "more") 

2733 return 

2734 self._seen.add(id(value)) 

2735 old_indent = self._indent 

2736 try: 

2737 if use_str: 

2738 self._print_nested_as_str(value) 

2739 else: 

2740 self._print_nested_as_repr(value) 

2741 finally: 

2742 self._indent = old_indent 

2743 self._seen.remove(id(value)) 

2744 

2745 def _print_nested_as_str(self, value): 

2746 if isinstance(value, type): 

2747 # This is a type. 

2748 self._print_plain(value, convert=_tx.type_repr) 

2749 elif hasattr(value, "__colorized_str__"): 

2750 # Has `__colorized_str__`. 

2751 self._print_colorized_str(value) 

2752 elif getattr(type(value), "__str__", None) is not object.__str__: 

2753 # Has custom `__str__`. 

2754 self._print_plain(value, convert=str, hl=False) 

2755 else: 

2756 # Has default `__str__` which falls back to `__repr__`. 

2757 self._print_nested_as_repr(value) 

2758 

2759 def _print_nested_as_repr(self, value): 

2760 if isinstance(value, type): 

2761 # This is a type. 

2762 self._print_plain(value, convert=_tx.type_repr) 

2763 elif hasattr(value, "__colorized_repr__"): 

2764 # Has `__colorized_repr__`. 

2765 self._print_colorized_repr(value) 

2766 elif hasattr(value, "__rich_repr__"): 

2767 # Has `__rich_repr__`. 

2768 self._print_rich_repr(value) 

2769 elif isinstance(value, _CONTAINER_TYPES): 

2770 # Is a known container. 

2771 for ty, repr_fn in _CONTAINERS.items(): 

2772 if isinstance(value, ty): 

2773 if getattr(type(value), "__repr__", None) is ty.__repr__: 

2774 repr_fn(self, value) # type: ignore 

2775 else: 

2776 self._print_plain(value) 

2777 break 

2778 elif dataclasses.is_dataclass(value): 

2779 # Is a dataclass. 

2780 self._print_dataclass(value) 

2781 else: 

2782 # Fall back to regular `__repr__`. 

2783 self._print_plain(value) 

2784 

2785 def _print_plain(self, value, convert=None, hl=True): 

2786 convert = convert or repr 

2787 

2788 self._flush_sep_and_line() 

2789 

2790 if hl and self.highlighted: 

2791 self._line += self._hl.highlight( 

2792 convert(value), 

2793 theme=self.theme, 

2794 syntax="repr", 

2795 default_color=self._base_color, 

2796 ) 

2797 else: 

2798 self._line.append_str(convert(value)) 

2799 

2800 self._state = _ReprContextState.NORMAL 

2801 

2802 def _print_list(self, name: str, obrace: str, cbrace: str, items): 

2803 if name: 

2804 self._push_token(name, "type") 

2805 self._push_token(obrace, "punct") 

2806 if self._indent >= self.max_depth: 

2807 self._push_token("...", "more") 

2808 else: 

2809 self._start_container() 

2810 for item in items: 

2811 self._print_nested(item) 

2812 self._terminate_item() 

2813 self._end_container() 

2814 self._push_token(cbrace, "punct") 

2815 

2816 def _print_dict(self, name: str, obrace: str, cbrace: str, items): 

2817 if name: 

2818 self._push_token(name, "type") 

2819 self._push_token(obrace, "punct") 

2820 if self._indent >= self.max_depth: 

2821 self._push_token("...", "more") 

2822 else: 

2823 self._start_container() 

2824 for key, value in items: 

2825 self._print_nested(key) 

2826 self._push_token(": ", "punct") 

2827 self._print_nested(value) 

2828 self._terminate_item() 

2829 self._end_container() 

2830 self._push_token(cbrace, "punct") 

2831 

2832 def _print_defaultdict(self, value: collections.defaultdict[_t.Any, _t.Any]): 

2833 self._push_token("defaultdict", "type") 

2834 self._push_token("(", "punct") 

2835 if self._indent >= self.max_depth: 

2836 self._push_token("...", "more") 

2837 else: 

2838 self._start_container() 

2839 self._print_nested(value.default_factory) 

2840 self._terminate_item() 

2841 self._print_dict("", "{", "}", value.items()) 

2842 self._terminate_item() 

2843 self._end_container() 

2844 self._push_token(")", "punct") 

2845 

2846 def _print_dequeue(self, value: collections.deque[_t.Any]): 

2847 self._push_token("deque", "type") 

2848 self._push_token("(", "punct") 

2849 if self._indent >= self.max_depth: 

2850 self._push_token("...", "more") 

2851 else: 

2852 self._start_container() 

2853 self._print_list("", "[", "]", value) 

2854 self._terminate_item() 

2855 if value.maxlen is not None: 

2856 self._push_token("maxlen", "param") 

2857 self._push_token("=", "punct") 

2858 self._print_nested(value.maxlen) 

2859 self._terminate_item() 

2860 self._end_container() 

2861 self._push_token(")", "punct") 

2862 

2863 def _print_dataclass(self, value): 

2864 try: 

2865 # If dataclass has a custom repr, fall back to it. 

2866 # This code is copied from Rich, MIT License. 

2867 # See https://github.com/Textualize/rich/blob/master/LICENSE 

2868 has_custom_repr = value.__repr__.__code__.co_filename not in ( 

2869 dataclasses.__file__, 

2870 reprlib.__file__, 

2871 ) 

2872 except Exception: # pragma: no cover 

2873 has_custom_repr = True 

2874 

2875 if has_custom_repr: 

2876 self._print_plain(value) 

2877 return 

2878 

2879 self._push_token(value.__class__.__name__, "type") 

2880 self._push_token("(", "punct") 

2881 

2882 if self._indent >= self.max_depth: 

2883 self._push_token("...", "more") 

2884 else: 

2885 self._start_container() 

2886 for field in dataclasses.fields(value): 

2887 if not field.repr: 

2888 continue 

2889 self._push_token(field.name, "param") 

2890 self._push_token("=", "punct") 

2891 self._print_nested(getattr(value, field.name)) 

2892 self._terminate_item() 

2893 self._end_container() 

2894 

2895 self._push_token(")", "punct") 

2896 

2897 def _print_colorized_repr(self, value): 

2898 self._flush_sep_and_line() 

2899 

2900 res = value.__colorized_repr__(self) 

2901 if not isinstance(res, ColorizedString): 

2902 raise TypeError( 

2903 f"__colorized_repr__ returned non-colorized-string (type {_tx.type_repr(type(res))})" 

2904 ) 

2905 self._line += res 

2906 

2907 self._state = _ReprContextState.NORMAL 

2908 

2909 def _print_colorized_str(self, value): 

2910 self._flush_sep_and_line() 

2911 

2912 res = value.__colorized_str__(self) 

2913 if not isinstance(res, ColorizedString): 

2914 raise TypeError( 

2915 f"__colorized_str__ returned non-colorized-string (type {_tx.type_repr(type(res))})" 

2916 ) 

2917 self._line += res 

2918 self._state = _ReprContextState.NORMAL 

2919 

2920 def _print_rich_repr(self, value): 

2921 rich_repr = getattr(value, "__rich_repr__") 

2922 angular = getattr(rich_repr, "angular", False) 

2923 

2924 if angular: 

2925 self._push_token("<", "punct") 

2926 self._push_token(value.__class__.__name__, "type") 

2927 if angular: 

2928 self._push_token(" ", "space") 

2929 else: 

2930 self._push_token("(", "punct") 

2931 

2932 if self._indent >= self.max_depth: 

2933 self._push_token("...", "more") 

2934 else: 

2935 self._start_container() 

2936 args = rich_repr() 

2937 if args is None: 

2938 args = [] # `rich_repr` didn't yield? 

2939 for arg in args: 

2940 if isinstance(arg, tuple): 

2941 if len(arg) == 3: 

2942 key, child, default = arg 

2943 if default == child: 

2944 continue 

2945 elif len(arg) == 2: 

2946 key, child = arg 

2947 elif len(arg) == 1: 

2948 key, child = None, arg[0] 

2949 else: 

2950 key, child = None, arg 

2951 else: 

2952 key, child = None, arg 

2953 

2954 if key: 

2955 self._push_token(str(key), "param") 

2956 self._push_token("=", "punct") 

2957 self._print_nested(child) 

2958 self._terminate_item("" if angular else ", ") 

2959 self._end_container() 

2960 

2961 self._push_token(">" if angular else ")", "punct") 

2962 

2963 

2964_CONTAINERS = { 

2965 os._Environ: lambda c, o: c._print_dict("environ", "({", "})", o.items()), 

2966 collections.defaultdict: ReprContext._print_defaultdict, 

2967 collections.deque: ReprContext._print_dequeue, 

2968 collections.Counter: lambda c, o: c._print_dict("Counter", "({", "})", o.items()), 

2969 collections.UserList: lambda c, o: c._print_list("", "[", "]", o), 

2970 collections.UserDict: lambda c, o: c._print_dict("", "{", "}", o.items()), 

2971 list: lambda c, o: c._print_list("", "[", "]", o), 

2972 set: lambda c, o: c._print_list("", "{", "}", o), 

2973 frozenset: lambda c, o: c._print_list("frozenset", "({", "})", o), 

2974 tuple: lambda c, o: c._print_list("", "(", ")", o), 

2975 dict: lambda c, o: c._print_dict("", "{", "}", o.items()), 

2976 types.MappingProxyType: lambda _: lambda c, o: c._print_dict( 

2977 "mappingproxy", "({", "})", o.items() 

2978 ), 

2979} 

2980_CONTAINER_TYPES = tuple(_CONTAINERS) 

2981 

2982 

2983def _to_colorable(msg: _t.Any, args: tuple[_t.Any, ...] | None = None) -> Colorable: 

2984 """ 

2985 Convert generic `msg`, `args` tuple to a colorable. 

2986 

2987 If msg is a string, returns :class:`Format`. Otherwise, check that no arguments 

2988 were given, and returns `msg` unchanged. 

2989 

2990 """ 

2991 

2992 if isinstance(msg, (str, _Template)): 

2993 return Format(_t.cast(_t.LiteralString, msg), *(args or ())) 

2994 else: 

2995 if args: 

2996 raise TypeError( 

2997 f"non-string type {_tx.type_repr(type(msg))} can't have format arguments" 

2998 ) 

2999 return msg 

3000 

3001 

3002class _StrBase(abc.ABC): 

3003 def __str__(self) -> str: 

3004 import yuio.io 

3005 

3006 return str(yuio.io.make_repr_context().str(self)) 

3007 

3008 @abc.abstractmethod 

3009 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString: 

3010 raise NotImplementedError() 

3011 

3012 

3013@repr_from_rich 

3014class Format(_StrBase): 

3015 """Format(msg: typing.LiteralString, /, *args: typing.Any) 

3016 Format(msg: ~string.templatelib.Template, /) 

3017 

3018 Lazy wrapper that ``%``-formats the given message, 

3019 or formats a :class:`~string.templatelib.Template`. 

3020 

3021 This utility allows saving ``%``-formatted messages and templates and performing 

3022 actual formatting lazily when requested. Color tags and backticks 

3023 are handled as usual. 

3024 

3025 :param msg: 

3026 message to format. 

3027 :param args: 

3028 arguments for ``%``-formatting the message. 

3029 :example: 

3030 :: 

3031 

3032 >>> message = Format("Hello, `%s`!", "world") 

3033 >>> print(message) 

3034 Hello, world! 

3035 

3036 """ 

3037 

3038 @_t.overload 

3039 def __init__(self, msg: _t.LiteralString, /, *args: _t.Any): ... 

3040 @_t.overload 

3041 def __init__(self, msg: _Template, /): ... 

3042 def __init__(self, msg: str | _Template, /, *args: _t.Any): 

3043 self._msg: str | _Template = msg 

3044 self._args: tuple[_t.Any, ...] = args 

3045 

3046 def __rich_repr__(self) -> RichReprResult: 

3047 yield None, self._msg 

3048 yield from ((None, arg) for arg in self._args) 

3049 

3050 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString: 

3051 return colorize(self._msg, *self._args, ctx=ctx) 

3052 

3053 

3054@_t.final 

3055@repr_from_rich 

3056class Repr(_StrBase): 

3057 """ 

3058 Lazy wrapper that calls :meth:`~ReprContext.repr` on the given value. 

3059 

3060 :param value: 

3061 value to repr. 

3062 :param multiline: 

3063 if given, overrides settings passed to :class:`ReprContext` for this call. 

3064 :param highlighted: 

3065 if given, overrides settings passed to :class:`ReprContext` for this call. 

3066 :example: 

3067 .. code-block:: python 

3068 

3069 config = ... 

3070 yuio.io.info( 

3071 "Loaded config:\\n`%#+s`", yuio.string.Indent(yuio.string.Repr(config)) 

3072 ) 

3073 

3074 """ 

3075 

3076 def __init__( 

3077 self, 

3078 value: _t.Any, 

3079 /, 

3080 *, 

3081 multiline: bool | None = None, 

3082 highlighted: bool | None = None, 

3083 ): 

3084 self.value = value 

3085 self.multiline = multiline 

3086 self.highlighted = highlighted 

3087 

3088 def __rich_repr__(self) -> RichReprResult: 

3089 yield None, self.value 

3090 yield "multiline", self.multiline, None 

3091 yield "highlighted", self.highlighted, None 

3092 

3093 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString: 

3094 return ctx.repr( 

3095 self.value, multiline=self.multiline, highlighted=self.highlighted 

3096 ) 

3097 

3098 

3099@_t.final 

3100@repr_from_rich 

3101class TypeRepr(_StrBase): 

3102 """ 

3103 Lazy wrapper that calls :func:`annotationlib.type_repr` on the given value 

3104 and highlights the result. 

3105 

3106 :param ty: 

3107 type to format. 

3108 

3109 If `ty` is a string, :func:`annotationlib.type_repr` is not called on it, 

3110 allowing you to mix types and arbitrary descriptions. 

3111 :param highlighted: 

3112 if given, overrides settings passed to :class:`ReprContext` for this call. 

3113 :example: 

3114 .. invisible-code-block: python 

3115 

3116 value = ... 

3117 

3118 .. code-block:: python 

3119 

3120 yuio.io.error("Expected `str`, got `%s`", yuio.string.TypeRepr(type(value))) 

3121 

3122 """ 

3123 

3124 def __init__(self, ty: _t.Any, /, *, highlighted: bool | None = None): 

3125 self._ty = ty 

3126 self._highlighted = highlighted 

3127 

3128 def __rich_repr__(self) -> RichReprResult: 

3129 yield None, self._ty 

3130 yield "highlighted", self._highlighted, None 

3131 

3132 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString: 

3133 if not isinstance(self._ty, type) and isinstance( 

3134 self._ty, (str, ColorizedString) 

3135 ): 

3136 return ColorizedString(self._ty) 

3137 else: 

3138 return ctx.hl(_tx.type_repr(self._ty), highlighted=self._highlighted) 

3139 

3140 

3141@repr_from_rich 

3142class _JoinBase(_StrBase): 

3143 def __init__( 

3144 self, 

3145 collection: _t.Iterable[_t.Any], 

3146 /, 

3147 *, 

3148 sep: str = ", ", 

3149 sep_two: str | None = None, 

3150 sep_last: str | None = None, 

3151 fallback: AnyString = "", 

3152 color: str | _Color | None = "code", 

3153 ): 

3154 self.__collection = collection 

3155 self._sep = sep 

3156 self._sep_two = sep_two 

3157 self._sep_last = sep_last 

3158 self._fallback: AnyString = fallback 

3159 self._color = color 

3160 

3161 @functools.cached_property 

3162 def _collection(self): 

3163 return list(self.__collection) 

3164 

3165 @classmethod 

3166 def or_( 

3167 cls, 

3168 collection: _t.Iterable[_t.Any], 

3169 /, 

3170 *, 

3171 fallback: AnyString = "", 

3172 color: str | _Color | None = "code", 

3173 ) -> _t.Self: 

3174 """ 

3175 Shortcut for joining arguments using word "or" as the last separator. 

3176 

3177 :example: 

3178 :: 

3179 

3180 >>> print(yuio.string.JoinStr.or_([1, 2, 3])) 

3181 1, 2, or 3 

3182 

3183 """ 

3184 

3185 return cls( 

3186 collection, sep_last=", or ", sep_two=" or ", fallback=fallback, color=color 

3187 ) 

3188 

3189 @classmethod 

3190 def and_( 

3191 cls, 

3192 collection: _t.Iterable[_t.Any], 

3193 /, 

3194 *, 

3195 fallback: AnyString = "", 

3196 color: str | _Color | None = "code", 

3197 ) -> _t.Self: 

3198 """ 

3199 Shortcut for joining arguments using word "and" as the last separator. 

3200 

3201 :example: 

3202 :: 

3203 

3204 >>> print(yuio.string.JoinStr.and_([1, 2, 3])) 

3205 1, 2, and 3 

3206 

3207 """ 

3208 

3209 return cls( 

3210 collection, 

3211 sep_last=", and ", 

3212 sep_two=" and ", 

3213 fallback=fallback, 

3214 color=color, 

3215 ) 

3216 

3217 def __rich_repr__(self) -> RichReprResult: 

3218 yield None, self._collection 

3219 yield "sep", self._sep, ", " 

3220 yield "sep_two", self._sep_two, None 

3221 yield "sep_last", self._sep_last, None 

3222 yield "color", self._color, "code" 

3223 

3224 def _render( 

3225 self, 

3226 theme: yuio.theme.Theme, 

3227 to_str: _t.Callable[[_t.Any], ColorizedString], 

3228 ) -> ColorizedString: 

3229 res = ColorizedString() 

3230 color = theme.to_color(self._color) 

3231 

3232 size = len(self._collection) 

3233 if not size: 

3234 res += self._fallback 

3235 return res 

3236 elif size == 1: 

3237 return to_str(self._collection[0]).with_base_color(color) 

3238 elif size == 2: 

3239 res.append_colorized_str(to_str(self._collection[0]).with_base_color(color)) 

3240 res.append_str(self._sep if self._sep_two is None else self._sep_two) 

3241 res.append_colorized_str(to_str(self._collection[1]).with_base_color(color)) 

3242 return res 

3243 

3244 last_i = size - 1 

3245 

3246 sep = self._sep 

3247 sep_last = self._sep if self._sep_last is None else self._sep_last 

3248 

3249 do_sep = False 

3250 for i, value in enumerate(self._collection): 

3251 if do_sep: 

3252 if i == last_i: 

3253 res.append_str(sep_last) 

3254 else: 

3255 res.append_str(sep) 

3256 res.append_colorized_str(to_str(value).with_base_color(color)) 

3257 do_sep = True 

3258 return res 

3259 

3260 

3261@_t.final 

3262class JoinStr(_JoinBase): 

3263 """ 

3264 Lazy wrapper that calls :meth:`~ReprContext.str` on elements of the given collection, 

3265 then joins the results using the given separator. 

3266 

3267 :param collection: 

3268 collection that will be printed. 

3269 :param sep: 

3270 separator that's printed between elements of the collection. 

3271 :param sep_two: 

3272 separator that's used when there are only two elements in the collection. 

3273 Defaults to `sep`. 

3274 :param sep_last: 

3275 separator that's used between the last and prior-to-last element 

3276 of the collection. Defaults to `sep`. 

3277 :param fallback: 

3278 printed if collection is empty. 

3279 :param color: 

3280 color applied to elements of the collection. 

3281 :example: 

3282 .. code-block:: python 

3283 

3284 values = ["foo", "bar"] 

3285 yuio.io.info("Available values: %s", yuio.string.JoinStr(values)) 

3286 

3287 """ 

3288 

3289 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString: 

3290 return self._render(ctx.theme, ctx.str) 

3291 

3292 

3293@_t.final 

3294class JoinRepr(_JoinBase): 

3295 """ 

3296 Lazy wrapper that calls :meth:`~ReprContext.repr` on elements of the given collection, 

3297 then joins the results using the given separator. 

3298 

3299 :param collection: 

3300 collection that will be printed. 

3301 :param sep: 

3302 separator that's printed between elements of the collection. 

3303 :param sep_two: 

3304 separator that's used when there are only two elements in the collection. 

3305 Defaults to `sep`. 

3306 :param sep_last: 

3307 separator that's used between the last and prior-to-last element 

3308 of the collection. Defaults to `sep`. 

3309 :param fallback: 

3310 printed if collection is empty. 

3311 :param color: 

3312 color applied to elements of the collection. 

3313 :example: 

3314 .. code-block:: python 

3315 

3316 values = ["foo", "bar"] 

3317 yuio.io.info("Available values: %s", yuio.string.JoinRepr(values)) 

3318 

3319 """ 

3320 

3321 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString: 

3322 return self._render(ctx.theme, ctx.repr) 

3323 

3324 

3325And = JoinStr.and_ 

3326""" 

3327Shortcut for :meth:`JoinStr.and_`. 

3328 

3329""" 

3330 

3331 

3332Or = JoinStr.or_ 

3333""" 

3334Shortcut for :meth:`JoinStr.or_`. 

3335 

3336""" 

3337 

3338 

3339@_t.final 

3340@repr_from_rich 

3341class Stack(_StrBase): 

3342 """ 

3343 Lazy wrapper that joins multiple :obj:`Colorable` objects with newlines, 

3344 effectively stacking them one on top of another. 

3345 

3346 :param args: 

3347 colorables to stack. 

3348 :example: 

3349 :: 

3350 

3351 >>> print( 

3352 ... yuio.string.Stack( 

3353 ... yuio.string.Format("<c bold magenta>Example:</c>"), 

3354 ... yuio.string.Indent( 

3355 ... yuio.string.Hl( 

3356 ... \""" 

3357 ... { 

3358 ... "foo": "bar" 

3359 ... } 

3360 ... \""", 

3361 ... syntax="json", 

3362 ... ), 

3363 ... indent="-> ", 

3364 ... ), 

3365 ... ) 

3366 ... ) 

3367 Example: 

3368 -> { 

3369 -> "foo": "bar" 

3370 -> } 

3371 

3372 """ 

3373 

3374 def __init__(self, *args: Colorable): 

3375 self._args = args 

3376 

3377 def __rich_repr__(self) -> RichReprResult: 

3378 yield from ((None, arg) for arg in self._args) 

3379 

3380 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString: 

3381 res = ColorizedString() 

3382 sep = False 

3383 for arg in self._args: 

3384 if sep: 

3385 res.append_color(_Color.NONE) 

3386 res.append_str("\n") 

3387 res += ctx.str(arg) 

3388 sep = True 

3389 return res 

3390 

3391 

3392@_t.final 

3393@repr_from_rich 

3394class Link(_StrBase): 

3395 """ 

3396 Lazy wrapper that adds a hyperlink to whatever is passed to it. 

3397 

3398 :param msg: 

3399 link body. 

3400 :param url: 

3401 link url, should be properly urlencoded. 

3402 

3403 """ 

3404 

3405 def __init__(self, msg: Colorable, /, *, url: str): 

3406 self._msg = msg 

3407 self._url = url 

3408 

3409 @classmethod 

3410 def from_path(cls, msg: Colorable, /, *, path: str | pathlib.Path) -> _t.Self: 

3411 """ 

3412 Create a link to a local file. 

3413 

3414 Ensures that file path is absolute and properly formatted. 

3415 

3416 :param msg: 

3417 link body. 

3418 :param path: 

3419 path to a file. 

3420 

3421 """ 

3422 

3423 url = pathlib.Path(path).expanduser().absolute().as_uri() 

3424 return cls(msg, url=url) 

3425 

3426 def __rich_repr__(self) -> RichReprResult: 

3427 yield None, self._msg 

3428 yield "url", self._url 

3429 

3430 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString: 

3431 res = ColorizedString() 

3432 res.start_link(self._url) 

3433 res.append_colorized_str(ctx.str(self._msg)) 

3434 if not ctx.term.supports_colors: 

3435 res.start_no_wrap() 

3436 res.append_str(" [") 

3437 res.append_str(self._url) 

3438 res.append_str("]") 

3439 res.end_no_wrap() 

3440 res.end_link() 

3441 return res 

3442 

3443 

3444@_t.final 

3445@repr_from_rich 

3446class Indent(_StrBase): 

3447 """ 

3448 Lazy wrapper that indents the message during formatting. 

3449 

3450 .. seealso:: 

3451 

3452 :meth:`ColorizedString.indent`. 

3453 

3454 :param msg: 

3455 message to indent. 

3456 :param indent: 

3457 this will be prepended to the first line in the string. 

3458 Defaults to two spaces. 

3459 :param continuation_indent: 

3460 this will be prepended to subsequent lines in the string. 

3461 Defaults to `indent`. 

3462 :example: 

3463 .. code-block:: python 

3464 

3465 config = ... 

3466 yuio.io.info( 

3467 "Loaded config:\\n`%#+s`", yuio.string.Indent(yuio.string.Repr(config)) 

3468 ) 

3469 

3470 """ 

3471 

3472 def __init__( 

3473 self, 

3474 msg: Colorable, 

3475 /, 

3476 *, 

3477 indent: AnyString | int = " ", 

3478 continuation_indent: AnyString | int | None = None, 

3479 ): 

3480 self._msg = msg 

3481 self._indent: AnyString | int = indent 

3482 self._continuation_indent: AnyString | int | None = continuation_indent 

3483 

3484 def __rich_repr__(self) -> RichReprResult: 

3485 yield None, self._msg 

3486 yield "indent", self._indent, " " 

3487 yield "continuation_indent", self._continuation_indent, None 

3488 

3489 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString: 

3490 if isinstance(self._indent, int): 

3491 indent = ColorizedString(" " * self._indent) 

3492 else: 

3493 indent = ColorizedString(self._indent) 

3494 if self._continuation_indent is None: 

3495 continuation_indent = indent 

3496 elif isinstance(self._continuation_indent, int): 

3497 continuation_indent = ColorizedString(" " * self._continuation_indent) 

3498 else: 

3499 continuation_indent = ColorizedString(self._continuation_indent) 

3500 

3501 indent_width = max(indent.width, continuation_indent.width) 

3502 width = max(1, ctx.width - indent_width) 

3503 

3504 return ctx.str(self._msg, width=width).indent(indent, continuation_indent) 

3505 

3506 

3507@_t.final 

3508@repr_from_rich 

3509class Md(_StrBase): 

3510 """Md(msg: typing.LiteralString, /, *args, width: int | None | yuio.Missing = yuio.MISSING, dedent: bool = True, allow_headings: bool = True) 

3511 Md(msg: str, /, *, width: int | None | yuio.Missing = yuio.MISSING, dedent: bool = True, allow_headings: bool = True) 

3512 

3513 Lazy wrapper that renders markdown during formatting. 

3514 

3515 :param md: 

3516 text to format. 

3517 :param width: 

3518 if given, overrides settings passed to :class:`ReprContext` for this call. 

3519 :param dedent: 

3520 whether to remove leading indent from text. 

3521 :param allow_headings: 

3522 whether to render headings as actual headings or as paragraphs. 

3523 

3524 """ 

3525 

3526 def __init__( 

3527 self, 

3528 md: str, 

3529 /, 

3530 *, 

3531 width: int | None = None, 

3532 dedent: bool = True, 

3533 allow_headings: bool = True, 

3534 ): 

3535 self._md: str = md 

3536 self._width: int | None = width 

3537 self._dedent: bool = dedent 

3538 self._allow_headings: bool = allow_headings 

3539 

3540 def __rich_repr__(self) -> RichReprResult: 

3541 yield None, self._md 

3542 yield "width", self._width, yuio.MISSING 

3543 yield "dedent", self._dedent, True 

3544 yield "allow_headings", self._allow_headings, True 

3545 

3546 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString: 

3547 import yuio.doc 

3548 import yuio.md 

3549 

3550 width = self._width or ctx.width 

3551 with ctx.with_settings(width=width): 

3552 formatter = yuio.doc.Formatter( 

3553 ctx, 

3554 allow_headings=self._allow_headings, 

3555 ) 

3556 

3557 res = ColorizedString() 

3558 res.start_no_wrap() 

3559 sep = False 

3560 for line in formatter.format(yuio.md.parse(self._md, dedent=self._dedent)): 

3561 if sep: 

3562 res += "\n" 

3563 res += line 

3564 sep = True 

3565 res.end_no_wrap() 

3566 

3567 return res 

3568 

3569 

3570@_t.final 

3571@repr_from_rich 

3572class Rst(_StrBase): 

3573 """Rst(msg: typing.LiteralString, /, *args, width: int | None | yuio.Missing = yuio.MISSING, dedent: bool = True, allow_headings: bool = True) 

3574 Rst(msg: str, /, *, width: int | None | yuio.Missing = yuio.MISSING, dedent: bool = True, allow_headings: bool = True) 

3575 

3576 Lazy wrapper that renders ReStructuredText during formatting. 

3577 

3578 :param rst: 

3579 text to format. 

3580 :param width: 

3581 if given, overrides settings passed to :class:`ReprContext` for this call. 

3582 :param dedent: 

3583 whether to remove leading indent from text. 

3584 :param allow_headings: 

3585 whether to render headings as actual headings or as paragraphs. 

3586 

3587 """ 

3588 

3589 def __init__( 

3590 self, 

3591 rst: str, 

3592 /, 

3593 *, 

3594 width: int | None = None, 

3595 dedent: bool = True, 

3596 allow_headings: bool = True, 

3597 ): 

3598 self._rst: str = rst 

3599 self._width: int | None = width 

3600 self._dedent: bool = dedent 

3601 self._allow_headings: bool = allow_headings 

3602 

3603 def __rich_repr__(self) -> RichReprResult: 

3604 yield None, self._rst 

3605 yield "width", self._width, yuio.MISSING 

3606 yield "dedent", self._dedent, True 

3607 yield "allow_headings", self._allow_headings, True 

3608 

3609 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString: 

3610 import yuio.doc 

3611 import yuio.rst 

3612 

3613 width = self._width or ctx.width 

3614 with ctx.with_settings(width=width): 

3615 formatter = yuio.doc.Formatter( 

3616 ctx, 

3617 allow_headings=self._allow_headings, 

3618 ) 

3619 

3620 res = ColorizedString() 

3621 res.start_no_wrap() 

3622 sep = False 

3623 for line in formatter.format( 

3624 yuio.rst.parse(self._rst, dedent=self._dedent) 

3625 ): 

3626 if sep: 

3627 res += "\n" 

3628 res += line 

3629 sep = True 

3630 res.end_no_wrap() 

3631 

3632 return res 

3633 

3634 

3635@_t.final 

3636@repr_from_rich 

3637class Hl(_StrBase): 

3638 """Hl(code: typing.LiteralString, /, *args, syntax: str, dedent: bool = True) 

3639 Hl(code: str, /, *, syntax: str, dedent: bool = True) 

3640 

3641 Lazy wrapper that highlights code during formatting. 

3642 

3643 :param md: 

3644 code to highlight. 

3645 :param args: 

3646 arguments for ``%``-formatting the highlighted code. 

3647 :param syntax: 

3648 name of syntax or a :class:`~yuio.hl.SyntaxHighlighter` instance. 

3649 :param dedent: 

3650 whether to remove leading indent from code. 

3651 

3652 """ 

3653 

3654 @_t.overload 

3655 def __init__( 

3656 self, 

3657 code: _t.LiteralString, 

3658 /, 

3659 *args: _t.Any, 

3660 syntax: str, 

3661 dedent: bool = True, 

3662 ): ... 

3663 @_t.overload 

3664 def __init__( 

3665 self, 

3666 code: str, 

3667 /, 

3668 *, 

3669 syntax: str, 

3670 dedent: bool = True, 

3671 ): ... 

3672 def __init__( 

3673 self, 

3674 code: str, 

3675 /, 

3676 *args: _t.Any, 

3677 syntax: str, 

3678 dedent: bool = True, 

3679 ): 

3680 self._code: str = code 

3681 self._args: tuple[_t.Any, ...] = args 

3682 self._syntax: str = syntax 

3683 self._dedent: bool = dedent 

3684 

3685 def __rich_repr__(self) -> RichReprResult: 

3686 yield None, self._code 

3687 yield from ((None, arg) for arg in self._args) 

3688 yield "syntax", self._syntax 

3689 yield "dedent", self._dedent, True 

3690 

3691 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString: 

3692 import yuio.hl 

3693 

3694 highlighter, syntax_name = yuio.hl.get_highlighter(self._syntax) 

3695 code = self._code 

3696 if self._dedent: 

3697 code = _dedent(code) 

3698 code = code.rstrip() 

3699 

3700 res = ColorizedString() 

3701 res.start_no_wrap() 

3702 res += highlighter.highlight(code, theme=ctx.theme, syntax=syntax_name) 

3703 res.end_no_wrap() 

3704 if self._args: 

3705 res = res.percent_format(self._args, ctx) 

3706 

3707 return res 

3708 

3709 

3710@_t.final 

3711@repr_from_rich 

3712class Wrap(_StrBase): 

3713 """ 

3714 Lazy wrapper that wraps the message during formatting. 

3715 

3716 .. seealso:: 

3717 

3718 :meth:`ColorizedString.wrap`. 

3719 

3720 :param msg: 

3721 message to wrap. 

3722 :param width: 

3723 if given, overrides settings passed to :class:`ReprContext` for this call. 

3724 :param preserve_spaces: 

3725 if set to :data:`True`, all spaces are preserved. 

3726 Otherwise, consecutive spaces are collapsed when newline break occurs. 

3727 

3728 Note that tabs always treated as a single whitespace. 

3729 :param preserve_newlines: 

3730 if set to :data:`True` (default), text is additionally wrapped 

3731 on newline sequences. When this happens, the newline sequence that wrapped 

3732 the line will be placed into :attr:`~ColorizedString.explicit_newline`. 

3733 

3734 If set to :data:`False`, newline sequences are treated as whitespaces. 

3735 :param break_long_words: 

3736 if set to :data:`True` (default), words that don't fit into a single line 

3737 will be split into multiple lines. 

3738 :param overflow: 

3739 Pass :data:`True` to trim overflowing lines and replace them with ellipsis. 

3740 :param break_long_nowrap_words: 

3741 if set to :data:`True`, words in no-wrap regions that don't fit 

3742 into a single line will be split into multiple lines. 

3743 :param indent: 

3744 this will be prepended to the first line in the string. 

3745 Defaults to two spaces. 

3746 :param continuation_indent: 

3747 this will be prepended to subsequent lines in the string. 

3748 Defaults to `indent`. 

3749 

3750 """ 

3751 

3752 def __init__( 

3753 self, 

3754 msg: Colorable, 

3755 /, 

3756 *, 

3757 width: int | None = None, 

3758 preserve_spaces: bool = False, 

3759 preserve_newlines: bool = True, 

3760 break_long_words: bool = True, 

3761 break_long_nowrap_words: bool = False, 

3762 overflow: bool | str = False, 

3763 indent: AnyString | int = "", 

3764 continuation_indent: AnyString | int | None = None, 

3765 ): 

3766 self._msg = msg 

3767 self._width: int | None = width 

3768 self._preserve_spaces = preserve_spaces 

3769 self._preserve_newlines = preserve_newlines 

3770 self._break_long_words = break_long_words 

3771 self._break_long_nowrap_words = break_long_nowrap_words 

3772 self._overflow = overflow 

3773 self._indent: AnyString | int = indent 

3774 self._continuation_indent: AnyString | int | None = continuation_indent 

3775 

3776 def __rich_repr__(self) -> RichReprResult: 

3777 yield None, self._msg 

3778 yield "width", self._width, None 

3779 yield "indent", self._indent, "" 

3780 yield "continuation_indent", self._continuation_indent, None 

3781 yield "preserve_spaces", self._preserve_spaces, None 

3782 yield "preserve_newlines", self._preserve_newlines, True 

3783 yield "break_long_words", self._break_long_words, True 

3784 yield "break_long_nowrap_words", self._break_long_nowrap_words, False 

3785 

3786 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString: 

3787 if isinstance(self._indent, int): 

3788 indent = ColorizedString(" " * self._indent) 

3789 else: 

3790 indent = ColorizedString(self._indent) 

3791 if self._continuation_indent is None: 

3792 continuation_indent = indent 

3793 elif isinstance(self._continuation_indent, int): 

3794 continuation_indent = ColorizedString(" " * self._continuation_indent) 

3795 else: 

3796 continuation_indent = ColorizedString(self._continuation_indent) 

3797 

3798 width = self._width or ctx.width 

3799 indent_width = max(indent.width, continuation_indent.width) 

3800 inner_width = max(1, width - indent_width) 

3801 

3802 overflow = self._overflow 

3803 if overflow is True: 

3804 overflow = ctx.get_msg_decoration("overflow") 

3805 

3806 res = ColorizedString() 

3807 res.start_no_wrap() 

3808 sep = False 

3809 for line in ctx.str(self._msg, width=inner_width).wrap( 

3810 width, 

3811 preserve_spaces=self._preserve_spaces, 

3812 preserve_newlines=self._preserve_newlines, 

3813 break_long_words=self._break_long_words, 

3814 break_long_nowrap_words=self._break_long_nowrap_words, 

3815 overflow=overflow, 

3816 indent=indent, 

3817 continuation_indent=continuation_indent, 

3818 ): 

3819 if sep: 

3820 res.append_str("\n") 

3821 res.append_colorized_str(line) 

3822 sep = True 

3823 res.end_no_wrap() 

3824 

3825 return res 

3826 

3827 

3828@_t.final 

3829@repr_from_rich 

3830class WithBaseColor(_StrBase): 

3831 """ 

3832 Lazy wrapper that applies the given color "under" the given colorable. 

3833 That is, all colors in the rendered colorable will be combined with this color 

3834 on the left: ``base_color | color``. 

3835 

3836 .. seealso:: 

3837 

3838 :meth:`ColorizedString.with_base_color`. 

3839 

3840 :param msg: 

3841 message to highlight. 

3842 :param base_color: 

3843 color that will be added under the message. 

3844 

3845 """ 

3846 

3847 def __init__( 

3848 self, 

3849 msg: Colorable, 

3850 /, 

3851 *, 

3852 base_color: str | _Color, 

3853 ): 

3854 self._msg = msg 

3855 self._base_color = base_color 

3856 

3857 def __rich_repr__(self) -> RichReprResult: 

3858 yield None, self._msg 

3859 yield "base_color", self._base_color 

3860 

3861 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString: 

3862 return ctx.str(self._msg).with_base_color(ctx.to_color(self._base_color)) 

3863 

3864 

3865@repr_from_rich 

3866class Hr(_StrBase): 

3867 """Hr(msg: Colorable = "", /, *, weight: int | str = 1, overflow: bool | str = True, **kwargs) 

3868 

3869 Produces horizontal ruler when converted to string. 

3870 

3871 :param msg: 

3872 any colorable that will be placed in the middle of the ruler. 

3873 :param weight: 

3874 weight or style of the ruler: 

3875 

3876 - ``0`` prints no ruler (but still prints centered text), 

3877 - ``1`` prints normal ruler, 

3878 - ``2`` prints bold ruler. 

3879 

3880 Additional styles can be added through 

3881 :attr:`Theme.msg_decorations <yuio.theme.Theme.msg_decorations_unicode>`. 

3882 :param width: 

3883 if given, overrides settings passed to :class:`ReprContext` for this call. 

3884 :param overflow: 

3885 pass :data:`False` to disable trimming `msg` to terminal width. 

3886 :param kwargs: 

3887 Other keyword arguments override corresponding decorations from the theme: 

3888 

3889 :`left_start`: 

3890 start of the ruler to the left of the message. 

3891 :`left_middle`: 

3892 filler of the ruler to the left of the message. 

3893 :`left_end`: 

3894 end of the ruler to the left of the message. 

3895 :`middle`: 

3896 filler of the ruler that's used if `msg` is empty. 

3897 :`right_start`: 

3898 start of the ruler to the right of the message. 

3899 :`right_middle`: 

3900 filler of the ruler to the right of the message. 

3901 :`right_end`: 

3902 end of the ruler to the right of the message. 

3903 

3904 """ 

3905 

3906 def __init__( 

3907 self, 

3908 msg: Colorable = "", 

3909 /, 

3910 *, 

3911 width: int | None = None, 

3912 overflow: bool | str = True, 

3913 weight: int | str = 1, 

3914 left_start: str | None = None, 

3915 left_middle: str | None = None, 

3916 left_end: str | None = None, 

3917 middle: str | None = None, 

3918 right_start: str | None = None, 

3919 right_middle: str | None = None, 

3920 right_end: str | None = None, 

3921 ): 

3922 self._msg = msg 

3923 self._width = width 

3924 self._overflow = overflow 

3925 self._weight = weight 

3926 self._left_start = left_start 

3927 self._left_middle = left_middle 

3928 self._left_end = left_end 

3929 self._middle = middle 

3930 self._right_start = right_start 

3931 self._right_middle = right_middle 

3932 self._right_end = right_end 

3933 

3934 def __rich_repr__(self) -> RichReprResult: 

3935 yield None, self._msg, None 

3936 yield "weight", self._weight, None 

3937 yield "width", self._width, None 

3938 yield "overflow", self._overflow, None 

3939 yield "left_start", self._left_start, None 

3940 yield "left_middle", self._left_middle, None 

3941 yield "left_end", self._left_end, None 

3942 yield "middle", self._middle, None 

3943 yield "right_start", self._right_start, None 

3944 yield "right_middle", self._right_middle, None 

3945 yield "right_end", self._right_end, None 

3946 

3947 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString: 

3948 width = self._width or ctx.width 

3949 

3950 color = ctx.get_color(f"msg/decoration:hr/{self._weight}") 

3951 

3952 res = ColorizedString(color) 

3953 res.start_no_wrap() 

3954 

3955 msg = ctx.str(self._msg) 

3956 if not msg: 

3957 res.append_str(self._make_whole(width, ctx)) 

3958 return res 

3959 

3960 overflow = self._overflow 

3961 if overflow is True: 

3962 overflow = ctx.get_msg_decoration("overflow") 

3963 

3964 sep = False 

3965 for line in msg.wrap( 

3966 width, preserve_spaces=True, break_long_words=False, overflow=overflow 

3967 ): 

3968 if sep: 

3969 res.append_color(yuio.color.Color.NONE) 

3970 res.append_str("\n") 

3971 res.append_color(color) 

3972 

3973 line_w = line.width 

3974 line_w_fill = max(0, width - line_w) 

3975 line_w_fill_l = line_w_fill // 2 

3976 line_w_fill_r = line_w_fill - line_w_fill_l 

3977 if not line_w_fill_l and not line_w_fill_r: 

3978 res.append_colorized_str(line) 

3979 return res 

3980 

3981 res.append_str(self._make_left(line_w_fill_l, ctx)) 

3982 res.append_colorized_str(line) 

3983 res.append_str(self._make_right(line_w_fill_r, ctx)) 

3984 

3985 sep = True 

3986 

3987 return res 

3988 

3989 def _make_left(self, w: int, ctx: ReprContext): 

3990 weight = self._weight 

3991 start = ( 

3992 self._left_start 

3993 if self._left_start is not None 

3994 else ctx.get_msg_decoration(f"hr/{weight}/left_start") 

3995 ) 

3996 middle = ( 

3997 self._left_middle 

3998 if self._left_middle is not None 

3999 else ctx.get_msg_decoration(f"hr/{weight}/left_middle") 

4000 ) or " " 

4001 end = ( 

4002 self._left_end 

4003 if self._left_end is not None 

4004 else ctx.get_msg_decoration(f"hr/{weight}/left_end") 

4005 ) 

4006 

4007 return _make_left(w, start, middle, end) 

4008 

4009 def _make_right(self, w: int, ctx: ReprContext): 

4010 weight = self._weight 

4011 start = ( 

4012 self._right_start 

4013 if self._right_start is not None 

4014 else ctx.get_msg_decoration(f"hr/{weight}/right_start") 

4015 ) 

4016 middle = ( 

4017 self._right_middle 

4018 if self._right_middle is not None 

4019 else ctx.get_msg_decoration(f"hr/{weight}/right_middle") 

4020 ) or " " 

4021 end = ( 

4022 self._right_end 

4023 if self._right_end is not None 

4024 else ctx.get_msg_decoration(f"hr/{weight}/right_end") 

4025 ) 

4026 

4027 return _make_right(w, start, middle, end) 

4028 

4029 def _make_whole(self, w: int, ctx: ReprContext): 

4030 weight = self._weight 

4031 start = ( 

4032 self._left_start 

4033 if self._left_start is not None 

4034 else ctx.get_msg_decoration(f"hr/{weight}/left_start") 

4035 ) 

4036 middle = ( 

4037 self._middle 

4038 if self._middle is not None 

4039 else ctx.get_msg_decoration(f"hr/{weight}/middle") 

4040 ) or " " 

4041 end = ( 

4042 self._right_end 

4043 if self._right_end is not None 

4044 else ctx.get_msg_decoration(f"hr/{weight}/right_end") 

4045 ) 

4046 

4047 start_w = line_width(start) 

4048 middle_w = line_width(middle) 

4049 end_w = line_width(end) 

4050 

4051 if w >= start_w: 

4052 w -= start_w 

4053 else: 

4054 start = "" 

4055 if w >= end_w: 

4056 w -= end_w 

4057 else: 

4058 end = "" 

4059 middle_times = w // middle_w 

4060 w -= middle_times * middle_w 

4061 middle *= middle_times 

4062 return start + middle + end + " " * w 

4063 

4064 

4065def _make_left( 

4066 w: int, 

4067 start: str, 

4068 middle: str, 

4069 end: str, 

4070): 

4071 start_w = line_width(start) 

4072 middle_w = line_width(middle) 

4073 end_w = line_width(end) 

4074 

4075 if w >= end_w: 

4076 w -= end_w 

4077 else: 

4078 end = "" 

4079 if w >= start_w: 

4080 w -= start_w 

4081 else: 

4082 start = "" 

4083 middle_times = w // middle_w 

4084 w -= middle_times * middle_w 

4085 middle *= middle_times 

4086 return start + middle + end + " " * w 

4087 

4088 

4089def _make_right( 

4090 w: int, 

4091 start: str, 

4092 middle: str, 

4093 end: str, 

4094): 

4095 start_w = line_width(start) 

4096 middle_w = line_width(middle) 

4097 end_w = line_width(end) 

4098 

4099 if w >= start_w: 

4100 w -= start_w 

4101 else: 

4102 start = "" 

4103 if w >= end_w: 

4104 w -= end_w 

4105 else: 

4106 end = "" 

4107 middle_times = w // middle_w 

4108 w -= middle_times * middle_w 

4109 middle *= middle_times 

4110 return " " * w + start + middle + end