Coverage for yuio / string.py: 98%

1447 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-05 11:41 +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.. autoclass:: Link 

17 :members: 

18 

19 

20.. _pretty-protocol: 

21 

22Pretty printing protocol 

23------------------------ 

24 

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

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

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

28 

29.. autoclass:: ReprContext 

30 :members: 

31 

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

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

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

35 

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

37that defines one of the following special methods: 

38 

39``__colorized_str__``, ``__colorized_repr__`` 

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

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

42 

43 .. tip:: 

44 

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

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

47 

48 **Example:** 

49 

50 .. code-block:: python 

51 

52 class MyObject: 

53 def __init__(self, value): 

54 self.value = value 

55 

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

57 result = yuio.string.ColorizedString() 

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

59 result += "MyObject" 

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

61 result += "(" 

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

63 result += ")" 

64 return result 

65 

66``__rich_repr__`` 

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

68 describing object's arguments: 

69 

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

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

72 is not equal to default, 

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

74 

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

76 

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

78 

79 **Example:** 

80 

81 .. code-block:: python 

82 

83 class MyObject: 

84 def __init__(self, value1, value2): 

85 self.value1 = value1 

86 self.value2 = value2 

87 

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

89 yield "value1", self.value1 

90 yield "value2", self.value2 

91 

92.. type:: RichReprResult 

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

94 

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

96 allows tuples, not arbitrary values. 

97 

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

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

100 

101 

102.. type:: ColorizedStrProtocol 

103 

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

105 

106.. type:: ColorizedReprProtocol 

107 

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

109 

110.. type:: RichReprProtocol 

111 

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

113 

114.. type:: Printable 

115 

116 Any object that supports printing. 

117 

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

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

120 

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

122 

123.. type:: Colorable 

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

125 

126 An object that supports colorized printing. 

127 

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

129 :class:`ColorableProtocol`. Additionally, you can pass any object that has 

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

131 your intent to print it. 

132 

133.. type:: ToColorable 

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

135 

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

137 :class:`Format`. 

138 

139.. autofunction:: repr_from_rich 

140 

141 

142.. _formatting-utilities: 

143 

144Formatting utilities 

145-------------------- 

146 

147.. autoclass:: Format 

148 :members: 

149 

150.. autoclass:: Repr 

151 :members: 

152 

153.. autoclass:: TypeRepr 

154 :members: 

155 

156.. autoclass:: JoinStr 

157 :members: 

158 :inherited-members: 

159 

160.. autoclass:: JoinRepr 

161 :members: 

162 :inherited-members: 

163 

164.. autofunction:: And 

165 

166.. autofunction:: Or 

167 

168.. autoclass:: Stack 

169 :members: 

170 

171.. autoclass:: Indent 

172 :members: 

173 

174.. autoclass:: Md 

175 :members: 

176 

177.. autoclass:: Hl 

178 :members: 

179 

180.. autoclass:: Wrap 

181 :members: 

182 

183.. autoclass:: WithBaseColor 

184 :members: 

185 

186.. autoclass:: Hr 

187 :members: 

188 

189 

190Parsing color tags 

191------------------ 

192 

193.. autofunction:: colorize 

194 

195.. autofunction:: strip_color_tags 

196 

197 

198Helpers 

199------- 

200 

201.. autofunction:: line_width 

202 

203.. type:: AnyString 

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

205 

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

207 or a normal colorized string). 

208 

209.. autodata:: NO_WRAP_START 

210 

211.. autodata:: NO_WRAP_END 

212 

213.. type:: NoWrapMarker 

214 NoWrapStart 

215 NoWrapEnd 

216 

217 Type of a no-wrap marker. 

218 

219""" 

220 

221from __future__ import annotations 

222 

223import abc 

224import collections 

225import contextlib 

226import dataclasses 

227import functools 

228import os 

229import pathlib 

230import re 

231import reprlib 

232import string 

233import sys 

234import types 

235import unicodedata 

236from enum import Enum 

237 

238import yuio 

239import yuio.color 

240import yuio.term 

241import yuio.theme 

242from yuio.color import Color as _Color 

243from yuio.util import UserString as _UserString 

244from yuio.util import dedent as _dedent 

245 

246import yuio._typing_ext as _tx 

247from typing import TYPE_CHECKING 

248 

249if TYPE_CHECKING: 

250 import typing_extensions as _t 

251else: 

252 from yuio import _typing as _t 

253 

254if TYPE_CHECKING: 

255 import yuio.md 

256 

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

258 from string.templatelib import Interpolation as _Interpolation 

259 from string.templatelib import Template as _Template 

260else: 

261 

262 class _Interpolation: ... 

263 

264 class _Template: ... 

265 

266 _Interpolation.__module__ = "string.templatelib" 

267 _Interpolation.__name__ = "Interpolation" 

268 _Interpolation.__qualname__ = "Interpolation" 

269 _Template.__module__ = "string.templatelib" 

270 _Template.__name__ = "Template" 

271 _Template.__qualname__ = "Template" 

272 

273 

274__all__ = [ 

275 "NO_WRAP_END", 

276 "NO_WRAP_START", 

277 "And", 

278 "AnyString", 

279 "Colorable", 

280 "ColorizedReprProtocol", 

281 "ColorizedStrProtocol", 

282 "ColorizedString", 

283 "Esc", 

284 "Format", 

285 "Hl", 

286 "Hr", 

287 "Indent", 

288 "JoinRepr", 

289 "JoinStr", 

290 "Link", 

291 "Md", 

292 "NoWrapEnd", 

293 "NoWrapMarker", 

294 "NoWrapStart", 

295 "Or", 

296 "Printable", 

297 "Repr", 

298 "ReprContext", 

299 "RichReprProtocol", 

300 "RichReprResult", 

301 "Stack", 

302 "ToColorable", 

303 "TypeRepr", 

304 "WithBaseColor", 

305 "Wrap", 

306 "colorize", 

307 "line_width", 

308 "repr_from_rich", 

309 "strip_color_tags", 

310] 

311 

312 

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

314 """ 

315 Calculates string width when the string is displayed 

316 in a terminal. 

317 

318 This function makes effort to detect wide characters 

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

320 with extended grapheme clusters, and so it may fail 

321 for emojis with modifiers, or other complex characters. 

322 

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

324 of four code points: 

325 

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

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

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

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

330 

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

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

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

334 

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

336 6 

337 

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

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

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

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

342 

343 """ 

344 

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

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

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

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

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

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

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

352 

353 if s.isascii(): 

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

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

356 return len(s) 

357 else: 

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

359 return sum( 

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

361 for c in s 

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

363 ) 

364 

365 

366RichReprResult: _t.TypeAlias = _t.Iterable[ 

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

368] 

369""" 

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

371 

372""" 

373 

374 

375@_t.runtime_checkable 

376class ColorizedStrProtocol(_t.Protocol): 

377 """ 

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

379 

380 """ 

381 

382 @abc.abstractmethod 

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

384 

385 

386@_t.runtime_checkable 

387class ColorizedReprProtocol(_t.Protocol): 

388 """ 

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

390 

391 """ 

392 

393 @abc.abstractmethod 

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

395 

396 

397@_t.runtime_checkable 

398class RichReprProtocol(_t.Protocol): 

399 """ 

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

401 

402 """ 

403 

404 @abc.abstractmethod 

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

406 

407 

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

409""" 

410Any object that supports printing. 

411 

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

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

414 

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

416 

417""" 

418 

419 

420Colorable: _t.TypeAlias = ( 

421 Printable 

422 | ColorizedStrProtocol 

423 | ColorizedReprProtocol 

424 | RichReprProtocol 

425 | str 

426 | BaseException 

427) 

428""" 

429Any object that supports colorized printing. 

430 

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

432:class:`ColorableProtocol`. Additionally, you can pass any object that has 

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

434your intent to print it. 

435 

436""" 

437 

438ToColorable: _t.TypeAlias = Colorable | _Template 

439""" 

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

441:class:`Format`. 

442 

443""" 

444 

445 

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

447 

448 

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

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

451 

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

453 

454 :param cls: 

455 class that needs ``__repr__``. 

456 :returns: 

457 always returns `cls`. 

458 :example: 

459 .. code-block:: python 

460 

461 @yuio.string.repr_from_rich 

462 class MyClass: 

463 def __init__(self, value): 

464 self.value = value 

465 

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

467 yield "value", self.value 

468 

469 :: 

470 

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

472 MyClass(value='plush!') 

473 

474 

475 """ 

476 

477 setattr(cls, "__repr__", _repr_from_rich_impl) 

478 return cls 

479 

480 

481def _repr_from_rich_impl(self: RichReprProtocol): 

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

483 args = rich_repr() 

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

485 else: 

486 args = [] 

487 angular = False 

488 

489 if args is None: 

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

491 

492 res = [] 

493 

494 if angular: 

495 res.append("<") 

496 res.append(self.__class__.__name__) 

497 if angular: 

498 res.append(" ") 

499 else: 

500 res.append("(") 

501 

502 sep = False 

503 for arg in args: 

504 if isinstance(arg, tuple): 

505 if len(arg) == 3: 

506 key, child, default = arg 

507 if default == child: 

508 continue 

509 elif len(arg) == 2: 

510 key, child = arg 

511 elif len(arg) == 1: 

512 key, child = None, arg[0] 

513 else: 

514 key, child = None, arg 

515 else: 

516 key, child = None, arg 

517 

518 if sep: 

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

520 if key: 

521 res.append(str(key)) 

522 res.append("=") 

523 res.append(repr(child)) 

524 sep = True 

525 

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

527 

528 return "".join(res) 

529 

530 

531class _NoWrapMarker(Enum): 

532 """ 

533 Type for a no-wrap marker. 

534 

535 """ 

536 

537 NO_WRAP_START = "<no_wrap_start>" 

538 NO_WRAP_END = "<no_wrap_end>" 

539 

540 def __repr__(self): 

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

542 

543 def __str__(self) -> str: 

544 return self.value # pragma: no cover 

545 

546 

547NoWrapStart: _t.TypeAlias = _t.Literal[_NoWrapMarker.NO_WRAP_START] 

548""" 

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

550 

551""" 

552 

553NO_WRAP_START: NoWrapStart = _NoWrapMarker.NO_WRAP_START 

554""" 

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

556 

557""" 

558 

559 

560NoWrapEnd: _t.TypeAlias = _t.Literal[_NoWrapMarker.NO_WRAP_END] 

561""" 

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

563 

564""" 

565 

566NO_WRAP_END: NoWrapEnd = _NoWrapMarker.NO_WRAP_END 

567""" 

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

569 

570""" 

571 

572NoWrapMarker: _t.TypeAlias = NoWrapStart | NoWrapEnd 

573""" 

574Type of a no-wrap marker. 

575 

576""" 

577 

578 

579@_t.final 

580@repr_from_rich 

581class ColorizedString: 

582 """ColorizedString(content: AnyString = '', /) 

583 

584 A string with colors. 

585 

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

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

588 

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

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

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

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

593 

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

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

596 

597 :param content: 

598 initial content of the string. Can be :class:`str`, color, no-wrap marker, 

599 or another colorized string. 

600 

601 

602 **String combination semantics** 

603 

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

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

606 

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

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

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

610 this region will be terminated after appending. 

611 

612 Thus, appending a colorized string does not change current color 

613 or no-wrap setting:: 

614 

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

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

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

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

619 >>> s1 # doctest: +NORMALIZE_WHITESPACE 

620 ColorizedString([yuio.string.NO_WRAP_START, 

621 <Color fore=<RED>>, 

622 'red nowrap text']) 

623 

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

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

626 >>> s2 += "green text " 

627 >>> s2 += s1 

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

629 >>> s2 # doctest: +NORMALIZE_WHITESPACE 

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

631 'green text ', 

632 yuio.string.NO_WRAP_START, 

633 <Color fore=<RED>>, 

634 'red nowrap text', 

635 yuio.string.NO_WRAP_END, 

636 <Color fore=<GREEN>>, 

637 ' green text continues']) 

638 

639 """ 

640 

641 # Invariants: 

642 # 

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

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

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

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

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

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

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

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

651 # `end-no-wrap` yet. 

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

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

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

655 

656 def __init__( 

657 self, 

658 content: AnyString = "", 

659 /, 

660 *, 

661 _isolate_colors: bool = True, 

662 ): 

663 if isinstance(content, ColorizedString): 

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

665 self._last_color = content._last_color 

666 self._active_color = content._active_color 

667 self._explicit_newline = content._explicit_newline 

668 self._len = content._len 

669 self._has_no_wrap = content._has_no_wrap 

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

671 self.__dict__["width"] = width 

672 else: 

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

674 self._active_color = _Color.NONE 

675 self._last_color: _Color | None = None 

676 self._explicit_newline: str = "" 

677 self._len = 0 

678 self._has_no_wrap = False 

679 

680 if not _isolate_colors: 

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

682 self._last_color = self._active_color 

683 

684 if content: 

685 self += content 

686 

687 @property 

688 def explicit_newline(self) -> str: 

689 """ 

690 Explicit newline indicates that a line of a wrapped text 

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

692 

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

694 

695 """ 

696 

697 return self._explicit_newline 

698 

699 @property 

700 def active_color(self) -> _Color: 

701 """ 

702 Last color appended to this string. 

703 

704 """ 

705 

706 return self._active_color 

707 

708 @functools.cached_property 

709 def width(self) -> int: 

710 """ 

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

712 

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

714 

715 """ 

716 

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

718 

719 @property 

720 def len(self) -> int: 

721 """ 

722 Line length in bytes, ignoring all colors. 

723 

724 """ 

725 

726 return self._len 

727 

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

729 """ 

730 Append new color to this string. 

731 

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

733 is appended after it. 

734 

735 :param color: 

736 color to append. 

737 

738 """ 

739 

740 self._active_color = color 

741 

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

743 """ 

744 Append new plain string to this string. 

745 

746 :param s: 

747 plain string to append. 

748 

749 """ 

750 

751 if not s: 

752 return 

753 if self._last_color != self._active_color: 

754 self._parts.append(self._active_color) 

755 self._last_color = self._active_color 

756 self._parts.append(s) 

757 self._len += len(s) 

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

759 

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

761 """ 

762 Append new colorized string to this string. 

763 

764 :param s: 

765 colorized string to append. 

766 

767 """ 

768 if not s: 

769 # Nothing to append. 

770 return 

771 

772 parts = s._parts 

773 

774 # Cleanup color at the beginning of the string. 

775 for i, part in enumerate(parts): 

776 if part in (NO_WRAP_START, NO_WRAP_END): 

777 continue 

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

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

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

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

782 # invariants. 

783 break 

784 

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

786 # We can remove it without changing the outcome. 

787 if part == self._last_color: 

788 if i == 0: 

789 parts = parts[i + 1 :] 

790 else: 

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

792 

793 break 

794 

795 if self._has_no_wrap: 

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

797 self._parts.extend( 

798 part for part in parts if part not in (NO_WRAP_START, NO_WRAP_END) 

799 ) 

800 else: 

801 # We're not in a no-wrap sequence. We preserve no-wrap regions from the 

802 # appended string, but we make sure that they don't affect anything 

803 # appended after. 

804 self._parts.extend(parts) 

805 if s._has_no_wrap: 

806 self._has_no_wrap = True 

807 self.end_no_wrap() 

808 

809 self._last_color = s._last_color 

810 self._len += s._len 

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

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

813 else: 

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

815 

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

817 """ 

818 Append a no-wrap marker. 

819 

820 :param m: 

821 no-wrap marker, will be dispatched 

822 to :meth:`~ColorizedString.start_no_wrap` 

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

824 

825 """ 

826 

827 if m is NO_WRAP_START: 

828 self.start_no_wrap() 

829 else: 

830 self.end_no_wrap() 

831 

832 def start_no_wrap(self): 

833 """ 

834 Start a no-wrap region. 

835 

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

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

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

839 and `preserve_newlines` settings. 

840 

841 """ 

842 

843 if self._has_no_wrap: 

844 return 

845 

846 self._has_no_wrap = True 

847 self._parts.append(NO_WRAP_START) 

848 

849 def end_no_wrap(self): 

850 """ 

851 End a no-wrap region. 

852 

853 """ 

854 

855 if not self._has_no_wrap: 

856 return 

857 

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

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

860 self._parts.pop() 

861 else: 

862 self._parts.append(NO_WRAP_END) 

863 

864 self._has_no_wrap = False 

865 

866 def extend( 

867 self, 

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

869 /, 

870 ): 

871 """ 

872 Extend string from iterable of raw parts. 

873 

874 :param parts: 

875 raw parts that will be appended to the string. 

876 

877 """ 

878 

879 for part in parts: 

880 self += part 

881 

882 def copy(self) -> ColorizedString: 

883 """ 

884 Copy this string. 

885 

886 :returns: 

887 copy of the string. 

888 

889 """ 

890 

891 return ColorizedString(self) 

892 

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

894 l, r = ColorizedString(), ColorizedString() 

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

896 r._active_color = l._active_color 

897 r._has_no_wrap = l._has_no_wrap 

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

899 r._active_color = self._active_color 

900 return l, r 

901 

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

903 """ 

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

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

906 ``base_color | color``. 

907 

908 :param base_color: 

909 color that will be added under the string. 

910 :returns: 

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

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

913 :example: 

914 :: 

915 

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

917 ... "part 1", 

918 ... yuio.color.Color.FORE_GREEN, 

919 ... "part 2", 

920 ... ]) 

921 >>> s2 = s1.with_base_color( 

922 ... yuio.color.Color.FORE_RED 

923 ... | yuio.color.Color.STYLE_BOLD 

924 ... ) 

925 >>> s2 # doctest: +NORMALIZE_WHITESPACE 

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

927 'part 1', 

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

929 'part 2']) 

930 

931 """ 

932 

933 if base_color == _Color.NONE: 

934 return self 

935 

936 res = ColorizedString() 

937 

938 for part in self._parts: 

939 if isinstance(part, _Color): 

940 res.append_color(base_color | part) 

941 else: 

942 res += part 

943 res._active_color = base_color | self._active_color 

944 if self._last_color is not None: 

945 res._last_color = base_color | self._last_color 

946 

947 return res 

948 

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

950 """ 

951 Convert colors in this string to ANSI escape sequences. 

952 

953 :param color_support: 

954 desired level of color support. 

955 :returns: 

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

957 escape sequences. 

958 

959 """ 

960 

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

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

963 else: 

964 parts: list[str] = [] 

965 cur_link: Link | None = None 

966 for part in self: 

967 if isinstance(part, Link): 

968 if not cur_link: 

969 cur_link = part 

970 elif cur_link.url == part.url: 

971 cur_link += part 

972 else: 

973 parts.append(cur_link.as_code(color_support)) 

974 cur_link = part 

975 continue 

976 elif cur_link: 

977 parts.append(cur_link.as_code(color_support)) 

978 cur_link = None 

979 if isinstance(part, _Color): 

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

981 elif isinstance(part, str): 

982 parts.append(part) 

983 if cur_link: 

984 parts.append(cur_link) 

985 if self._last_color != _Color.NONE: 

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

987 return parts 

988 

989 def wrap( 

990 self, 

991 width: int, 

992 /, 

993 *, 

994 preserve_spaces: bool = False, 

995 preserve_newlines: bool = True, 

996 break_long_words: bool = True, 

997 break_long_nowrap_words: bool = False, 

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

999 indent: AnyString | int = "", 

1000 continuation_indent: AnyString | int | None = None, 

1001 ) -> list[ColorizedString]: 

1002 """ 

1003 Wrap a long line of text into multiple lines. 

1004 

1005 :param width: 

1006 desired wrapping width. 

1007 :param preserve_spaces: 

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

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

1010 

1011 Note that tabs always treated as a single whitespace. 

1012 :param preserve_newlines: 

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

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

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

1016 

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

1018 

1019 .. list-table:: Whitespace sequences 

1020 :header-rows: 1 

1021 :stub-columns: 1 

1022 

1023 * - Sequence 

1024 - `preserve_newlines` 

1025 - Result 

1026 * - ``\\n``, ``\\r\\n``, ``\\r`` 

1027 - ``False`` 

1028 - Treated as a single whitespace. 

1029 * - ``\\n``, ``\\r\\n``, ``\\r`` 

1030 - ``True`` 

1031 - Creates a new line. 

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

1033 - Any 

1034 - Always creates a new line. 

1035 

1036 :param break_long_words: 

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

1038 will be split into multiple lines. 

1039 :param break_long_nowrap_words: 

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

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

1042 :param overflow: 

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

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

1045 :param indent: 

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

1047 :param continuation_indent: 

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

1049 Defaults to `indent`. 

1050 :returns: 

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

1052 

1053 """ 

1054 

1055 return _TextWrapper( 

1056 width, 

1057 preserve_spaces=preserve_spaces, 

1058 preserve_newlines=preserve_newlines, 

1059 break_long_words=break_long_words, 

1060 break_long_nowrap_words=break_long_nowrap_words, 

1061 overflow=overflow, 

1062 indent=indent, 

1063 continuation_indent=continuation_indent, 

1064 ).wrap(self) 

1065 

1066 def indent( 

1067 self, 

1068 indent: AnyString | int = " ", 

1069 continuation_indent: AnyString | int | None = None, 

1070 ) -> ColorizedString: 

1071 """ 

1072 Indent this string. 

1073 

1074 :param indent: 

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

1076 Defaults to two spaces. 

1077 :param continuation_indent: 

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

1079 Defaults to `indent`. 

1080 :returns: 

1081 indented string. 

1082 

1083 """ 

1084 

1085 nowrap_indent = ColorizedString() 

1086 nowrap_indent.start_no_wrap() 

1087 nowrap_continuation_indent = ColorizedString() 

1088 nowrap_continuation_indent.start_no_wrap() 

1089 if isinstance(indent, int): 

1090 nowrap_indent.append_str(" " * indent) 

1091 else: 

1092 nowrap_indent += indent 

1093 if continuation_indent is None: 

1094 nowrap_continuation_indent.append_colorized_str(nowrap_indent) 

1095 elif isinstance(continuation_indent, int): 

1096 nowrap_continuation_indent.append_str(" " * continuation_indent) 

1097 else: 

1098 nowrap_continuation_indent += continuation_indent 

1099 

1100 if not nowrap_indent and not nowrap_continuation_indent: 

1101 return self 

1102 

1103 res = ColorizedString() 

1104 

1105 needs_indent = True 

1106 for part in self._parts: 

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

1108 res += part 

1109 continue 

1110 

1111 for line in _split_keep_link(part, _WORDSEP_NL_RE): 

1112 if not line: 

1113 continue 

1114 if needs_indent: 

1115 res.append_colorized_str(nowrap_indent) 

1116 nowrap_indent = nowrap_continuation_indent 

1117 res.append_str(line) 

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

1119 

1120 return res 

1121 

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

1123 """ 

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

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

1126 

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

1128 

1129 :param args: 

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

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

1132 :param ctx: 

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

1134 and ``__colorized_repr__`` when formatting colorables. 

1135 :returns: 

1136 formatted string. 

1137 :raises: 

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

1139 fails. 

1140 

1141 """ 

1142 

1143 return _percent_format(self, args, ctx) 

1144 

1145 def __len__(self) -> int: 

1146 return self.len 

1147 

1148 def __bool__(self) -> bool: 

1149 return self.len > 0 

1150 

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

1152 return self._parts.__iter__() 

1153 

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

1155 copy = self.copy() 

1156 copy += rhs 

1157 return copy 

1158 

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

1160 copy = ColorizedString(lhs) 

1161 copy += self 

1162 return copy 

1163 

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

1165 if isinstance(rhs, str): 

1166 self.append_str(rhs) 

1167 elif isinstance(rhs, ColorizedString): 

1168 self.append_colorized_str(rhs) 

1169 elif isinstance(rhs, _Color): 

1170 self.append_color(rhs) 

1171 elif rhs in (NO_WRAP_START, NO_WRAP_END): 

1172 self.append_no_wrap(rhs) 

1173 else: 

1174 self.extend(rhs) 

1175 

1176 return self 

1177 

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

1179 if isinstance(value, ColorizedString): 

1180 return self._parts == value._parts 

1181 else: 

1182 return NotImplemented 

1183 

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

1185 return not (self == value) 

1186 

1187 def __rich_repr__(self) -> RichReprResult: 

1188 yield None, self._parts 

1189 yield "explicit_newline", self._explicit_newline, "" 

1190 

1191 def __str__(self) -> str: 

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

1193 

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

1195 return self 

1196 

1197 

1198AnyString: _t.TypeAlias = ( 

1199 str 

1200 | ColorizedString 

1201 | _Color 

1202 | NoWrapMarker 

1203 | _t.Iterable[str | ColorizedString | _Color | NoWrapMarker] 

1204) 

1205""" 

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

1207 

1208""" 

1209 

1210 

1211_S_SYNTAX = re.compile( 

1212 r""" 

1213 % # Percent 

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

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

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

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

1218 [hlL]? # Unused length modifier 

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

1220 """, 

1221 re.VERBOSE, 

1222) 

1223 

1224_F_SYNTAX = re.compile( 

1225 r""" 

1226 ^ 

1227 (?: # Options 

1228 (?: 

1229 (?P<fill>.)? 

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

1231 )? 

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

1233 (?P<zero>0)? 

1234 ) 

1235 (?: # Width 

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

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

1238 ) 

1239 (?: # Precision 

1240 \. 

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

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

1243 )? 

1244 (?: # Type 

1245 (?P<type>.) 

1246 )? 

1247 $ 

1248 """, 

1249 re.VERBOSE, 

1250) 

1251 

1252 

1253def _percent_format( 

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

1255) -> ColorizedString: 

1256 seen_mapping = False 

1257 arg_index = 0 

1258 res = ColorizedString() 

1259 for part in s: 

1260 if isinstance(part, str): 

1261 pos = 0 

1262 for match in _S_SYNTAX.finditer(part): 

1263 if pos < match.start(): 

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

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

1266 last_color = res.active_color 

1267 arg_index, replaced = _percent_format_repl( 

1268 match, args, arg_index, last_color, ctx 

1269 ) 

1270 res += replaced 

1271 res.append_color(last_color) 

1272 pos = match.end() 

1273 if pos < len(part): 

1274 res.append_str(part[pos:]) 

1275 else: 

1276 res += part 

1277 

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

1279 not isinstance(args, tuple) 

1280 and ( 

1281 not hasattr(args, "__getitem__") 

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

1283 ) 

1284 and not seen_mapping 

1285 and not arg_index 

1286 ): 

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

1288 

1289 return res 

1290 

1291 

1292def _percent_format_repl( 

1293 match: _tx.StrReMatch, 

1294 args: object, 

1295 arg_index: int, 

1296 base_color: _Color, 

1297 ctx: ReprContext, 

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

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

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

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

1302 return arg_index, "%" 

1303 

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

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

1306 

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

1308 try: 

1309 fmt_arg = args[mapping] # type: ignore 

1310 except TypeError: 

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

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

1313 if added_color: 

1314 fmt_args = {mapping: fmt_arg} 

1315 else: 

1316 fmt_args = args 

1317 elif isinstance(args, tuple): 

1318 try: 

1319 fmt_arg = args[arg_index] 

1320 except IndexError: 

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

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

1323 begin = arg_index + 1 

1324 end = arg_index = ( 

1325 arg_index 

1326 + 1 

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

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

1329 ) 

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

1331 elif arg_index == 0: 

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

1333 arg_index += 1 

1334 else: 

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

1336 

1337 fmt = match.group(0) % fmt_args 

1338 if added_color: 

1339 added_color = ctx.to_color(added_color) 

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

1341 return arg_index, fmt 

1342 

1343 

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

1345 color = None 

1346 while isinstance(x, WithBaseColor): 

1347 x, base_color = x._msg, x._base_color 

1348 base_color = theme.to_color(base_color) 

1349 if color: 

1350 color = color | base_color 

1351 else: 

1352 color = base_color 

1353 else: 

1354 return x, color 

1355 

1356 

1357def _percent_format_repl_str( 

1358 match: _tx.StrReMatch, 

1359 args: object, 

1360 arg_index: int, 

1361 base_color: _Color, 

1362 ctx: ReprContext, 

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

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

1365 if width_s == "*": 

1366 if not isinstance(args, tuple): 

1367 raise TypeError("* wants int") 

1368 try: 

1369 width = args[arg_index] 

1370 arg_index += 1 

1371 except (KeyError, IndexError): 

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

1373 if not isinstance(width, int): 

1374 raise TypeError("* wants int") 

1375 else: 

1376 width = int(width_s) 

1377 else: 

1378 width = None 

1379 

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

1381 if precision_s == "*": 

1382 if not isinstance(args, tuple): 

1383 raise TypeError("* wants int") 

1384 try: 

1385 precision = args[arg_index] 

1386 arg_index += 1 

1387 except (KeyError, IndexError): 

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

1389 if not isinstance(precision, int): 

1390 raise TypeError("* wants int") 

1391 else: 

1392 precision = int(precision_s) 

1393 else: 

1394 precision = None 

1395 

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

1397 try: 

1398 fmt_arg = args[mapping] # type: ignore 

1399 except TypeError: 

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

1401 elif isinstance(args, tuple): 

1402 try: 

1403 fmt_arg = args[arg_index] 

1404 arg_index += 1 

1405 except IndexError: 

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

1407 elif arg_index == 0: 

1408 fmt_arg = args 

1409 arg_index += 1 

1410 else: 

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

1412 

1413 flag = match.group("flag") 

1414 multiline = "+" in flag 

1415 highlighted = "#" in flag 

1416 

1417 res = ctx.convert( 

1418 fmt_arg, 

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

1420 multiline=multiline, 

1421 highlighted=highlighted, 

1422 ) 

1423 

1424 align = match.group("flag") 

1425 if width is not None and width < 0: 

1426 width = -width 

1427 align = "<" 

1428 elif align == "-": 

1429 align = "<" 

1430 else: 

1431 align = ">" 

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

1433 

1434 return arg_index, res.with_base_color(base_color) 

1435 

1436 

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

1438 value = interp.value 

1439 if ( 

1440 interp.conversion is not None 

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

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

1443 ): 

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

1445 else: 

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

1447 

1448 return value 

1449 

1450 

1451def _apply_format( 

1452 value: ColorizedString, 

1453 width: int | None, 

1454 precision: int | None, 

1455 align: str | None, 

1456 fill: str | None, 

1457): 

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

1459 cut = ColorizedString() 

1460 for part in value: 

1461 if precision <= 0: 

1462 break 

1463 if isinstance(part, str): 

1464 part_width = line_width(part) 

1465 if part_width <= precision: 

1466 cut.append_str(part) 

1467 precision -= part_width 

1468 elif part.isascii(): 

1469 cut.append_str(part[:precision]) 

1470 break 

1471 else: 

1472 for j, ch in enumerate(part): 

1473 precision -= line_width(ch) 

1474 if precision == 0: 

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

1476 break 

1477 elif precision < 0: 

1478 cut.append_str(part[:j]) 

1479 cut.append_str(" ") 

1480 break 

1481 break 

1482 else: 

1483 cut += part 

1484 value = cut 

1485 

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

1487 fill = fill or " " 

1488 fill_width = line_width(fill) 

1489 spacing = width - value.width 

1490 spacing_fill = spacing // fill_width 

1491 spacing_space = spacing - spacing_fill * fill_width 

1492 value.append_color(_Color.NONE) 

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

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

1495 elif align == ">": 

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

1497 else: 

1498 left = spacing_fill // 2 

1499 right = spacing_fill - left 

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

1501 

1502 return value 

1503 

1504 

1505__TAG_RE = re.compile( 

1506 r""" 

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

1508 | </c> # _Color tag close. 

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

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

1511 """ 

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

1513 re.VERBOSE | re.MULTILINE, 

1514) 

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

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

1517 

1518 

1519def colorize( 

1520 template: str | _Template, 

1521 /, 

1522 *args: _t.Any, 

1523 ctx: ReprContext, 

1524 default_color: _Color | str = _Color.NONE, 

1525 parse_cli_flags_in_backticks: bool = False, 

1526) -> ColorizedString: 

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

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

1529 

1530 Parse color tags and produce a colorized string. 

1531 

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

1533 and backticks within it. 

1534 

1535 :param line: 

1536 text to colorize. 

1537 :param args: 

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

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

1540 :param ctx: 

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

1542 and format arguments. 

1543 :param default_color: 

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

1545 :returns: 

1546 a colorized string. 

1547 

1548 """ 

1549 

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

1551 if isinstance(template, _Template): 

1552 if args: 

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

1554 line = "" 

1555 index = 0 

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

1557 line += part 

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

1559 # there is something. 

1560 line += "\0" 

1561 index += len(part) + 1 

1562 interpolations.append((index, interp)) 

1563 line += template.strings[-1] 

1564 else: 

1565 line = template 

1566 

1567 default_color = ctx.to_color(default_color) 

1568 

1569 res = ColorizedString(default_color) 

1570 stack = [default_color] 

1571 last_pos = 0 

1572 last_interp = 0 

1573 

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

1575 nonlocal last_interp 

1576 

1577 index = 0 

1578 while ( 

1579 last_interp < len(interpolations) 

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

1581 ): 

1582 interp_start, interp = interpolations[last_interp] 

1583 res.append_str( 

1584 s[ 

1585 index : interp_start 

1586 - start 

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

1588 ] 

1589 ) 

1590 res.append_colorized_str( 

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

1592 ) 

1593 index = interp_start - start 

1594 last_interp += 1 

1595 res.append_str(s[index:]) 

1596 

1597 for tag in __TAG_RE.finditer(line): 

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

1599 last_pos = tag.end() 

1600 

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

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

1603 res.append_color(color) 

1604 stack.append(color) 

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

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

1607 code_pos = tag.start("code") 

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

1609 code = code[1:-1] 

1610 code_pos += 1 

1611 if ( 

1612 parse_cli_flags_in_backticks 

1613 and __FLAG_RE.match(code) 

1614 and not __NEG_NUM_RE.match(code) 

1615 ): 

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

1617 else: 

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

1619 res.start_no_wrap() 

1620 append_to_res(code, code_pos) 

1621 res.end_no_wrap() 

1622 res.append_color(stack[-1]) 

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

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

1625 elif len(stack) > 1: 

1626 stack.pop() 

1627 res.append_color(stack[-1]) 

1628 

1629 append_to_res(line[last_pos:], last_pos) 

1630 

1631 if args: 

1632 return res.percent_format(args, ctx) 

1633 else: 

1634 return res 

1635 

1636 

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

1638 """ 

1639 Remove all color tags from a string. 

1640 

1641 """ 

1642 

1643 raw: list[str] = [] 

1644 

1645 last_pos = 0 

1646 for tag in __TAG_RE.finditer(s): 

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

1648 last_pos = tag.end() 

1649 

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

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

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

1653 code = code[1:-1] 

1654 raw.append(code) 

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

1656 raw.append(punct) 

1657 

1658 raw.append(s[last_pos:]) 

1659 

1660 return "".join(raw) 

1661 

1662 

1663class Esc(_UserString): 

1664 """ 

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

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

1667 

1668 """ 

1669 

1670 __slots__ = () 

1671 

1672 

1673class Link(_UserString): 

1674 """ 

1675 A :class:`str` wrapper with an attached hyperlink. 

1676 

1677 :param args: 

1678 arguments for :class:`str` constructor. 

1679 :param url: 

1680 link, should be properly urlencoded. 

1681 

1682 """ 

1683 

1684 __slots__ = ("__url",) 

1685 

1686 def __new__(cls, *args, url: str, **kwargs): 

1687 res = super().__new__(cls, *args, **kwargs) 

1688 res.__url = url 

1689 return res 

1690 

1691 @classmethod 

1692 def from_path(cls, *args, path: str | pathlib.Path) -> _t.Self: 

1693 """ 

1694 Create a link to a local file. 

1695 

1696 Ensures that file path is absolute and properly formatted. 

1697 

1698 :param args: 

1699 arguments for :class:`str` constructor. 

1700 :param path: 

1701 path to a file. 

1702 

1703 """ 

1704 

1705 path = pathlib.Path(path).expanduser().absolute().as_uri() 

1706 return cls(*args, url=path) 

1707 

1708 @property 

1709 def url(self): 

1710 """ 

1711 Target link. 

1712 

1713 """ 

1714 

1715 return self.__url 

1716 

1717 def as_code(self, color_support: yuio.color.ColorSupport): 

1718 """ 

1719 Convert this link into an ANSI escape code with respect to the given 

1720 terminal capabilities. 

1721 

1722 :param color_support: 

1723 level of color support of a terminal. 

1724 :returns: 

1725 string text with ANSI codes that add a hyperlink to it. 

1726 

1727 """ 

1728 

1729 if color_support < yuio.color.ColorSupport.ANSI_TRUE: 

1730 return str(self) 

1731 else: 

1732 return f"\x1b]8;;{self.__url}\x1b\\{self}\x1b]8;;\x1b\\" 

1733 

1734 def _wrap(self, data: str): 

1735 return self.__class__(data, url=self.__url) 

1736 

1737 def __repr__(self) -> str: 

1738 return f"Link({super().__repr__()}, url={self.url!r})" 

1739 

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

1741 return ColorizedString(self) 

1742 

1743 

1744def _split_keep_link(s: str, r: _tx.StrRePattern): 

1745 if isinstance(s, Link): 

1746 url = s.url 

1747 ctor = lambda x: Link(x, url=url) 

1748 else: 

1749 ctor = s.__class__ 

1750 return [ctor(part) for part in r.split(s)] 

1751 

1752 

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

1754 

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

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

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

1758 

1759# Copied from textwrap with some modifications in newline handling 

1760_WORDSEP_RE = re.compile( 

1761 r""" 

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

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

1764 | # any whitespace 

1765 [ \t\b\f]+ 

1766 | # em-dash between words 

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

1768 | # word, possibly hyphenated 

1769 %(nws)s+? (?: 

1770 # hyphenated word 

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

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

1773 | # end of word 

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

1775 | # em-dash 

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

1777 ) 

1778 )""" 

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

1780 re.VERBOSE, 

1781) 

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

1783 

1784 

1785class _TextWrapper: 

1786 def __init__( 

1787 self, 

1788 width: int, 

1789 /, 

1790 *, 

1791 preserve_spaces: bool, 

1792 preserve_newlines: bool, 

1793 break_long_words: bool, 

1794 break_long_nowrap_words: bool, 

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

1796 indent: AnyString | int, 

1797 continuation_indent: AnyString | int | None, 

1798 ): 

1799 self.width = width 

1800 self.preserve_spaces: bool = preserve_spaces 

1801 self.preserve_newlines: bool = preserve_newlines 

1802 self.break_long_words: bool = break_long_words 

1803 self.break_long_nowrap_words: bool = break_long_nowrap_words 

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

1805 

1806 self.indent = ColorizedString() 

1807 self.indent.start_no_wrap() 

1808 self.continuation_indent = ColorizedString() 

1809 self.continuation_indent.start_no_wrap() 

1810 if isinstance(indent, int): 

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

1812 else: 

1813 self.indent += indent 

1814 if continuation_indent is None: 

1815 self.continuation_indent.append_colorized_str(self.indent) 

1816 elif isinstance(continuation_indent, int): 

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

1818 else: 

1819 self.continuation_indent += continuation_indent 

1820 

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

1822 

1823 self.current_line = ColorizedString() 

1824 if self.indent: 

1825 self.current_line += self.indent 

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

1827 self.at_line_start: bool = True 

1828 self.at_line_start_or_indent: bool = True 

1829 self.has_ellipsis: bool = False 

1830 self.add_spaces_before_word: int = 0 

1831 self.space_before_word_url = None 

1832 

1833 self.nowrap_start_index = None 

1834 self.nowrap_start_width = 0 

1835 self.nowrap_start_added_space = False 

1836 

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

1838 self.current_line._explicit_newline = explicit_newline 

1839 self.lines.append(self.current_line) 

1840 

1841 self.current_line = ColorizedString(self.current_line.active_color) 

1842 

1843 if self.continuation_indent: 

1844 self.current_line += self.continuation_indent 

1845 

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

1847 self.at_line_start = True 

1848 self.at_line_start_or_indent = True 

1849 self.has_ellipsis = False 

1850 self.nowrap_start_index = None 

1851 self.nowrap_start_width = 0 

1852 self.nowrap_start_added_space = False 

1853 self.add_spaces_before_word = 0 

1854 self.space_before_word_url = None 

1855 

1856 def _flush_line_part(self): 

1857 assert self.nowrap_start_index is not None 

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

1859 tail_width = self.current_line_width - self.nowrap_start_width 

1860 if ( 

1861 self.nowrap_start_added_space 

1862 and self.current_line._parts 

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

1864 ): 

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

1866 self.current_line._parts.pop() 

1867 self._flush_line() 

1868 self.current_line += tail 

1869 self.current_line.append_color(tail.active_color) 

1870 self.current_line_width += tail_width 

1871 

1872 def _append_str(self, s: str): 

1873 self.current_line.append_str(s) 

1874 self.at_line_start = False 

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

1876 

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

1878 if ( 

1879 self.overflow is not False 

1880 and self.current_line_width + word_width > self.width 

1881 ): 

1882 if isinstance(word, Esc): 

1883 if self.overflow: 

1884 self._add_ellipsis() 

1885 return 

1886 

1887 word_head_len = word_head_width = 0 

1888 

1889 for c in word: 

1890 c_width = line_width(c) 

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

1892 break 

1893 word_head_len += 1 

1894 word_head_width += c_width 

1895 

1896 if word_head_len: 

1897 self._append_str(word[:word_head_len]) 

1898 self.has_ellipsis = False 

1899 self.current_line_width += word_head_width 

1900 

1901 if self.overflow: 

1902 self._add_ellipsis() 

1903 else: 

1904 self._append_str(word) 

1905 self.current_line_width += word_width 

1906 self.has_ellipsis = False 

1907 

1908 def _append_space(self): 

1909 if self.add_spaces_before_word: 

1910 word = " " * self.add_spaces_before_word 

1911 if self.space_before_word_url: 

1912 word = Link(word, url=self.space_before_word_url) 

1913 self._append_word(word, 1) 

1914 self.add_spaces_before_word = 0 

1915 self.space_before_word_url = None 

1916 

1917 def _add_ellipsis(self): 

1918 if self.has_ellipsis: 

1919 # Already has an ellipsis. 

1920 return 

1921 

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

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

1924 self._append_str(str(self.overflow)) 

1925 self.current_line_width += 1 

1926 self.has_ellipsis = True 

1927 elif not self.at_line_start: 

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

1929 parts = self.current_line._parts 

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

1931 part = parts[i] 

1932 if isinstance(part, str): 

1933 if not isinstance(part, (Esc, Link)): 

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

1935 self.has_ellipsis = True 

1936 return 

1937 

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

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

1940 word_head_len = word_head_width = 0 

1941 

1942 for c in word: 

1943 c_width = line_width(c) 

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

1945 break 

1946 word_head_len += 1 

1947 word_head_width += c_width 

1948 

1949 if self.at_line_start and not word_head_len: 

1950 if self.overflow: 

1951 return 

1952 else: 

1953 word_head_len = 1 

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

1955 

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

1957 

1958 word = word[word_head_len:] 

1959 word_width -= word_head_width 

1960 

1961 self._flush_line() 

1962 

1963 if word: 

1964 self._append_word(word, word_width) 

1965 

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

1967 nowrap = False 

1968 

1969 for part in text: 

1970 if isinstance(part, _Color): 

1971 if ( 

1972 self.add_spaces_before_word 

1973 and self.current_line_width + self.add_spaces_before_word 

1974 < self.width 

1975 ): 

1976 # Make sure any whitespace that was added before color 

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

1978 # will be wrapped soon anyways. 

1979 self._append_space() 

1980 self.add_spaces_before_word = 0 

1981 self.space_before_word_url = None 

1982 self.current_line.append_color(part) 

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 self.space_before_word_url = None 

2001 if self.at_line_start: 

2002 self.nowrap_start_index = None 

2003 self.nowrap_start_width = 0 

2004 else: 

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

2006 self.nowrap_start_width = self.current_line_width 

2007 nowrap = True 

2008 continue 

2009 elif part is NO_WRAP_END: 

2010 nowrap = False 

2011 self.nowrap_start_index = None 

2012 self.nowrap_start_width = 0 

2013 self.nowrap_start_added_space = False 

2014 continue 

2015 

2016 esc = False 

2017 if isinstance(part, Esc): 

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

2019 esc = True 

2020 elif nowrap: 

2021 words = _split_keep_link(part, _WORDSEP_NL_RE) 

2022 else: 

2023 words = _split_keep_link(part, _WORDSEP_RE) 

2024 

2025 for word in words: 

2026 if not word: 

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

2028 continue 

2029 

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

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

2032 # need to split the word further. 

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

2034 self._flush_line(explicit_newline=word) 

2035 continue 

2036 else: 

2037 # Treat any newline sequence as a single space. 

2038 word = " " 

2039 

2040 isspace = not esc and word.isspace() 

2041 if isspace: 

2042 if ( 

2043 # Spaces are preserved in no-wrap sequences. 

2044 nowrap 

2045 # Spaces are explicitly preserved. 

2046 or self.preserve_spaces 

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

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

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

2050 or ( 

2051 self.at_line_start_or_indent 

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

2053 ) 

2054 ): 

2055 word = word.translate(_SPACE_TRANS) 

2056 else: 

2057 self.add_spaces_before_word = len(word) 

2058 self.space_before_word_url = ( 

2059 word.url if isinstance(word, Link) else None 

2060 ) 

2061 continue 

2062 

2063 word_width = line_width(word) 

2064 

2065 if self._try_fit_word(word, word_width): 

2066 # Word fits onto the current line. 

2067 continue 

2068 

2069 if self.nowrap_start_index is not None: 

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

2071 self._flush_line_part() 

2072 

2073 if self._try_fit_word(word, word_width): 

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

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

2076 continue 

2077 

2078 if ( 

2079 not self.at_line_start 

2080 and ( 

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

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

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

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

2085 (not nowrap and not isspace) 

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

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

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

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

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

2091 or (nowrap and esc and self.break_long_nowrap_words) 

2092 ) 

2093 and not ( 

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

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

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

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

2098 # which will handle ellipsis for us. 

2099 self.overflow is not False 

2100 and esc 

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

2102 and ( 

2103 self.has_ellipsis 

2104 or self.current_line_width + self.add_spaces_before_word + 1 

2105 <= self.width 

2106 ) 

2107 ) 

2108 ): 

2109 # Flush a non-empty line. 

2110 self._flush_line() 

2111 

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

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

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

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

2116 # we flush the line in the condition above. 

2117 if not esc and ( 

2118 (nowrap and self.break_long_nowrap_words) 

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

2120 ): 

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

2122 self._append_word_with_breaks(word, word_width) 

2123 else: 

2124 self._append_word(word, word_width) 

2125 

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

2127 self._flush_line() 

2128 

2129 return self.lines 

2130 

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

2132 if ( 

2133 self.current_line_width + word_width + self.add_spaces_before_word 

2134 <= self.width 

2135 ): 

2136 self._append_space() 

2137 self._append_word(word, word_width) 

2138 return True 

2139 else: 

2140 return False 

2141 

2142 

2143class _ReprContextState(Enum): 

2144 START = 0 

2145 """ 

2146 Initial state. 

2147 

2148 """ 

2149 

2150 CONTAINER_START = 1 

2151 """ 

2152 Right after a token starting a container was pushed. 

2153 

2154 """ 

2155 

2156 ITEM_START = 2 

2157 """ 

2158 Right after a token separating container items was pushed. 

2159 

2160 """ 

2161 

2162 NORMAL = 3 

2163 """ 

2164 In the middle of a container element. 

2165 

2166 """ 

2167 

2168 

2169@_t.final 

2170class ReprContext: 

2171 """ 

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

2173 are handled properly. 

2174 

2175 :param term: 

2176 terminal that will be used to print formatted messages. 

2177 :param theme: 

2178 theme that will be used to format messages. 

2179 :param multiline: 

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

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

2182 :param highlighted: 

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

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

2185 Default is :data:`False`. 

2186 :param max_depth: 

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

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

2189 :param width: 

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

2191 or rendering horizontal rulers. If not given, defaults 

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

2193 

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

2195 

2196 """ 

2197 

2198 def __init__( 

2199 self, 

2200 *, 

2201 term: yuio.term.Term, 

2202 theme: yuio.theme.Theme, 

2203 multiline: bool | None = None, 

2204 highlighted: bool | None = None, 

2205 max_depth: int | None = None, 

2206 width: int | None = None, 

2207 ): 

2208 self.term = term 

2209 """ 

2210 Current term. 

2211 

2212 """ 

2213 

2214 self.theme = theme 

2215 """ 

2216 Current theme. 

2217 

2218 """ 

2219 

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

2221 """ 

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

2223 

2224 """ 

2225 

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

2227 """ 

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

2229 

2230 """ 

2231 

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

2233 """ 

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

2235 are not rendered. 

2236 

2237 """ 

2238 

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

2240 """ 

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

2242 

2243 """ 

2244 

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

2246 self._line = ColorizedString() 

2247 self._indent = 0 

2248 self._state = _ReprContextState.START 

2249 self._pending_sep = None 

2250 

2251 import yuio.md 

2252 

2253 self._hl = yuio.md.SyntaxHighlighter.get_highlighter("repr") 

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

2255 

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

2257 """ 

2258 Lookup a color by path. 

2259 

2260 """ 

2261 

2262 return self.theme.get_color(paths) 

2263 

2264 def to_color( 

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

2266 ) -> yuio.color.Color: 

2267 """ 

2268 Convert color or color path to color. 

2269 

2270 """ 

2271 

2272 return self.theme.to_color(color_or_path) 

2273 

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

2275 """ 

2276 Get message decoration by name. 

2277 

2278 """ 

2279 

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

2281 

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

2283 if self._pending_sep is not None: 

2284 self._push_color("punct") 

2285 if trim: 

2286 self._pending_sep = self._pending_sep.rstrip() 

2287 self._line.append_str(self._pending_sep) 

2288 self._pending_sep = None 

2289 

2290 def _flush_line(self): 

2291 if self.multiline: 

2292 self._line.append_color(self._base_color) 

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

2294 if self._indent: 

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

2296 

2297 def _flush_sep_and_line(self): 

2298 if self.multiline and self._state in [ 

2299 _ReprContextState.CONTAINER_START, 

2300 _ReprContextState.ITEM_START, 

2301 ]: 

2302 self._flush_sep(trim=True) 

2303 self._flush_line() 

2304 else: 

2305 self._flush_sep() 

2306 

2307 def _push_color(self, tag: str): 

2308 if self.highlighted: 

2309 self._line.append_color( 

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

2311 ) 

2312 

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

2314 self._flush_sep_and_line() 

2315 

2316 self._push_color(tag) 

2317 self._line.append_str(content) 

2318 

2319 self._state = _ReprContextState.NORMAL 

2320 

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

2322 self._flush_sep() 

2323 self._pending_sep = sep 

2324 self._state = _ReprContextState.ITEM_START 

2325 

2326 def _start_container(self): 

2327 self._state = _ReprContextState.CONTAINER_START 

2328 self._indent += 1 

2329 

2330 def _end_container(self): 

2331 self._indent -= 1 

2332 

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

2334 self._flush_line() 

2335 

2336 self._state = _ReprContextState.NORMAL 

2337 self._pending_sep = None 

2338 

2339 def repr( 

2340 self, 

2341 value: _t.Any, 

2342 /, 

2343 *, 

2344 multiline: bool | None = None, 

2345 highlighted: bool | None = None, 

2346 width: int | None = None, 

2347 max_depth: int | None = None, 

2348 ) -> ColorizedString: 

2349 """ 

2350 Convert value to colorized string using repr methods. 

2351 

2352 :param value: 

2353 value to be rendered. 

2354 :param multiline: 

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

2356 :param highlighted: 

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

2358 :param width: 

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

2360 :param max_depth: 

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

2362 :returns: 

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

2364 :raises: 

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

2366 exception, this function returns a colorized string with 

2367 an error description. 

2368 

2369 """ 

2370 

2371 return self._print( 

2372 value, 

2373 multiline=multiline, 

2374 highlighted=highlighted, 

2375 use_str=False, 

2376 width=width, 

2377 max_depth=max_depth, 

2378 ) 

2379 

2380 def str( 

2381 self, 

2382 value: _t.Any, 

2383 /, 

2384 *, 

2385 multiline: bool | None = None, 

2386 highlighted: bool | None = None, 

2387 width: int | None = None, 

2388 max_depth: int | None = None, 

2389 ) -> ColorizedString: 

2390 """ 

2391 Convert value to colorized string. 

2392 

2393 :param value: 

2394 value to be rendered. 

2395 :param multiline: 

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

2397 :param highlighted: 

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

2399 :param width: 

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

2401 :param max_depth: 

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

2403 :returns: 

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

2405 :raises: 

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

2407 exception, this function returns a colorized string with 

2408 an error description. 

2409 

2410 """ 

2411 

2412 return self._print( 

2413 value, 

2414 multiline=multiline, 

2415 highlighted=highlighted, 

2416 use_str=True, 

2417 width=width, 

2418 max_depth=max_depth, 

2419 ) 

2420 

2421 def convert( 

2422 self, 

2423 value: _t.Any, 

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

2425 format_spec: str | None = None, 

2426 /, 

2427 *, 

2428 multiline: bool | None = None, 

2429 highlighted: bool | None = None, 

2430 width: int | None = None, 

2431 max_depth: int | None = None, 

2432 ): 

2433 """ 

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

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

2436 

2437 :param value: 

2438 value to be converted. 

2439 :param conversion: 

2440 string conversion method: 

2441 

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

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

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

2445 characters. 

2446 :param format_spec: 

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

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

2449 :param multiline: 

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

2451 :param highlighted: 

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

2453 :param width: 

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

2455 :param max_depth: 

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

2457 :returns: 

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

2459 :raises: 

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

2461 

2462 .. _t-string-spec: 

2463 

2464 **Format specification** 

2465 

2466 .. syntax:diagram:: 

2467 

2468 stack: 

2469 - optional: 

2470 - optional: 

2471 - optional: 

2472 - non_terminal: "fill" 

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

2474 - non_terminal: "align" 

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

2476 - non_terminal: "flags" 

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

2478 - optional: 

2479 - comment: "width" 

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

2481 - "[0-9]+" 

2482 - optional: 

2483 - comment: "precision" 

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

2485 - "'.'" 

2486 - "[0-9]+" 

2487 - optional: 

2488 - comment: "conversion type" 

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

2490 - "'s'" 

2491 skip_bottom: true 

2492 skip: true 

2493 

2494 .. _t-string-spec-fill: 

2495 

2496 ``fill`` 

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

2498 

2499 .. _t-string-spec-align: 

2500 

2501 ``align`` 

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

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

2504 

2505 .. _t-string-spec-flags: 

2506 

2507 ``flags`` 

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

2509 multiline repr. 

2510 

2511 .. _t-string-spec-width: 

2512 

2513 ``width`` 

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

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

2516 

2517 .. _t-string-spec-precision: 

2518 

2519 ``precision`` 

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

2521 width. 

2522 

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

2524 

2525 ``conversion type`` 

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

2527 

2528 """ 

2529 

2530 if format_spec: 

2531 match = _F_SYNTAX.match(format_spec) 

2532 if not match: 

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

2534 fill = match.group("fill") 

2535 align = match.group("align") 

2536 if align == "=": 

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

2538 flags = match.group("flags") 

2539 if "#" in flags: 

2540 highlighted = True 

2541 if "+" in flags: 

2542 multiline = True 

2543 zero = match.group("zero") 

2544 if zero and not fill: 

2545 fill = zero 

2546 format_width = match.group("width") 

2547 if format_width: 

2548 format_width = int(format_width) 

2549 else: 

2550 format_width = None 

2551 format_width_grouping = match.group("width_grouping") 

2552 if format_width_grouping: 

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

2554 format_precision = match.group("precision") 

2555 if format_precision: 

2556 format_precision = int(format_precision) 

2557 else: 

2558 format_precision = None 

2559 type = match.group("type") 

2560 if type and type != "s": 

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

2562 else: 

2563 format_width = format_precision = align = fill = None 

2564 

2565 if conversion == "r": 

2566 res = self.repr( 

2567 value, 

2568 multiline=multiline, 

2569 highlighted=highlighted, 

2570 width=width, 

2571 max_depth=max_depth, 

2572 ) 

2573 elif conversion == "a": 

2574 res = ColorizedString() 

2575 for part in self.repr( 

2576 value, 

2577 multiline=multiline, 

2578 highlighted=highlighted, 

2579 width=width, 

2580 max_depth=max_depth, 

2581 ): 

2582 if isinstance(part, _UserString): 

2583 res += part._wrap( 

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

2585 ) 

2586 elif isinstance(part, str): 

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

2588 else: 

2589 res += part 

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

2591 res = self.str( 

2592 value, 

2593 multiline=multiline, 

2594 highlighted=highlighted, 

2595 width=width, 

2596 max_depth=max_depth, 

2597 ) 

2598 else: 

2599 raise ValueError( 

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

2601 ) 

2602 

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

2604 

2605 def hl( 

2606 self, 

2607 value: str, 

2608 /, 

2609 *, 

2610 highlighted: bool | None = None, 

2611 ) -> ColorizedString: 

2612 """ 

2613 Highlight result of :func:`repr`. 

2614 

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

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

2617 

2618 :param value: 

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

2620 :returns: 

2621 highlighted string. 

2622 

2623 """ 

2624 

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

2626 

2627 if highlighted: 

2628 return self._hl.highlight(self.theme, value, default_color=self._base_color) 

2629 else: 

2630 return ColorizedString(value) 

2631 

2632 @contextlib.contextmanager 

2633 def with_settings( 

2634 self, 

2635 *, 

2636 multiline: bool | None = None, 

2637 highlighted: bool | None = None, 

2638 width: int | None = None, 

2639 max_depth: int | None = None, 

2640 ): 

2641 """ 

2642 Temporarily replace settings of this context. 

2643 

2644 :param multiline: 

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

2646 :param highlighted: 

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

2648 :param width: 

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

2650 :param max_depth: 

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

2652 :returns: 

2653 a context manager that overrides settings. 

2654 

2655 """ 

2656 

2657 old_multiline, self.multiline = ( 

2658 self.multiline, 

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

2660 ) 

2661 old_highlighted, self.highlighted = ( 

2662 self.highlighted, 

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

2664 ) 

2665 old_width, self.width = ( 

2666 self.width, 

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

2668 ) 

2669 old_max_depth, self.max_depth = ( 

2670 self.max_depth, 

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

2672 ) 

2673 

2674 try: 

2675 yield 

2676 finally: 

2677 self.multiline = old_multiline 

2678 self.highlighted = old_highlighted 

2679 self.width = old_width 

2680 self.max_depth = old_max_depth 

2681 

2682 def _print( 

2683 self, 

2684 value: _t.Any, 

2685 multiline: bool | None, 

2686 highlighted: bool | None, 

2687 width: int | None, 

2688 max_depth: int | None, 

2689 use_str: bool, 

2690 ) -> ColorizedString: 

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

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

2693 old_pending_sep, self._pending_sep = self._pending_sep, None 

2694 

2695 try: 

2696 with self.with_settings( 

2697 multiline=multiline, 

2698 highlighted=highlighted, 

2699 width=width, 

2700 max_depth=max_depth, 

2701 ): 

2702 self._print_nested(value, use_str) 

2703 return self._line 

2704 except Exception as e: 

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

2706 res = ColorizedString() 

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

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

2709 return res 

2710 finally: 

2711 self._line = old_line 

2712 self._state = old_state 

2713 self._pending_sep = old_pending_sep 

2714 

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

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

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

2718 return 

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

2720 old_indent = self._indent 

2721 try: 

2722 if use_str: 

2723 self._print_nested_as_str(value) 

2724 else: 

2725 self._print_nested_as_repr(value) 

2726 finally: 

2727 self._indent = old_indent 

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

2729 

2730 def _print_nested_as_str(self, value): 

2731 if isinstance(value, type): 

2732 # This is a type. 

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

2734 elif hasattr(value, "__colorized_str__"): 

2735 # Has `__colorized_str__`. 

2736 self._print_colorized_str(value) 

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

2738 # Has custom `__str__`. 

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

2740 else: 

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

2742 self._print_nested_as_repr(value) 

2743 

2744 def _print_nested_as_repr(self, value): 

2745 if isinstance(value, type): 

2746 # This is a type. 

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

2748 elif hasattr(value, "__colorized_repr__"): 

2749 # Has `__colorized_repr__`. 

2750 self._print_colorized_repr(value) 

2751 elif hasattr(value, "__rich_repr__"): 

2752 # Has `__rich_repr__`. 

2753 self._print_rich_repr(value) 

2754 elif isinstance(value, _CONTAINER_TYPES): 

2755 # Is a known container. 

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

2757 if isinstance(value, ty): 

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

2759 repr_fn(self, value) # type: ignore 

2760 else: 

2761 self._print_plain(value) 

2762 break 

2763 elif dataclasses.is_dataclass(value): 

2764 # Is a dataclass. 

2765 self._print_dataclass(value) 

2766 else: 

2767 # Fall back to regular `__repr__`. 

2768 self._print_plain(value) 

2769 

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

2771 convert = convert or repr 

2772 

2773 self._flush_sep_and_line() 

2774 

2775 if hl and self.highlighted: 

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

2777 self.theme, convert(value), default_color=self._base_color 

2778 ) 

2779 else: 

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

2781 

2782 self._state = _ReprContextState.NORMAL 

2783 

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

2785 if name: 

2786 self._push_token(name, "type") 

2787 self._push_token(obrace, "punct") 

2788 if self._indent >= self.max_depth: 

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

2790 else: 

2791 self._start_container() 

2792 for item in items: 

2793 self._print_nested(item) 

2794 self._terminate_item() 

2795 self._end_container() 

2796 self._push_token(cbrace, "punct") 

2797 

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

2799 if name: 

2800 self._push_token(name, "type") 

2801 self._push_token(obrace, "punct") 

2802 if self._indent >= self.max_depth: 

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

2804 else: 

2805 self._start_container() 

2806 for key, value in items: 

2807 self._print_nested(key) 

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

2809 self._print_nested(value) 

2810 self._terminate_item() 

2811 self._end_container() 

2812 self._push_token(cbrace, "punct") 

2813 

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

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

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

2817 if self._indent >= self.max_depth: 

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

2819 else: 

2820 self._start_container() 

2821 self._print_nested(value.default_factory) 

2822 self._terminate_item() 

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

2824 self._terminate_item() 

2825 self._end_container() 

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

2827 

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

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

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

2831 if self._indent >= self.max_depth: 

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

2833 else: 

2834 self._start_container() 

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

2836 self._terminate_item() 

2837 if value.maxlen is not None: 

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

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

2840 self._print_nested(value.maxlen) 

2841 self._terminate_item() 

2842 self._end_container() 

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

2844 

2845 def _print_dataclass(self, value): 

2846 try: 

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

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

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

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

2851 dataclasses.__file__, 

2852 reprlib.__file__, 

2853 ) 

2854 except Exception: # pragma: no cover 

2855 has_custom_repr = True 

2856 

2857 if has_custom_repr: 

2858 self._print_plain(value) 

2859 return 

2860 

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

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

2863 

2864 if self._indent >= self.max_depth: 

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

2866 else: 

2867 self._start_container() 

2868 for field in dataclasses.fields(value): 

2869 if not field.repr: 

2870 continue 

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

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

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

2874 self._terminate_item() 

2875 self._end_container() 

2876 

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

2878 

2879 def _print_colorized_repr(self, value): 

2880 self._flush_sep_and_line() 

2881 

2882 res = value.__colorized_repr__(self) 

2883 if not isinstance(res, ColorizedString): 

2884 raise TypeError( 

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

2886 ) 

2887 self._line += res 

2888 

2889 self._state = _ReprContextState.NORMAL 

2890 

2891 def _print_colorized_str(self, value): 

2892 self._flush_sep_and_line() 

2893 

2894 res = value.__colorized_str__(self) 

2895 if not isinstance(res, ColorizedString): 

2896 raise TypeError( 

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

2898 ) 

2899 self._line += res 

2900 self._state = _ReprContextState.NORMAL 

2901 

2902 def _print_rich_repr(self, value): 

2903 rich_repr = getattr(value, "__rich_repr__") 

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

2905 

2906 if angular: 

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

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

2909 if angular: 

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

2911 else: 

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

2913 

2914 if self._indent >= self.max_depth: 

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

2916 else: 

2917 self._start_container() 

2918 args = rich_repr() 

2919 if args is None: 

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

2921 for arg in args: 

2922 if isinstance(arg, tuple): 

2923 if len(arg) == 3: 

2924 key, child, default = arg 

2925 if default == child: 

2926 continue 

2927 elif len(arg) == 2: 

2928 key, child = arg 

2929 elif len(arg) == 1: 

2930 key, child = None, arg[0] 

2931 else: 

2932 key, child = None, arg 

2933 else: 

2934 key, child = None, arg 

2935 

2936 if key: 

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

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

2939 self._print_nested(child) 

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

2941 self._end_container() 

2942 

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

2944 

2945 

2946_CONTAINERS = { 

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

2948 collections.defaultdict: ReprContext._print_defaultdict, 

2949 collections.deque: ReprContext._print_dequeue, 

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

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

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

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

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

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

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

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

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

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

2960 ), 

2961} 

2962_CONTAINER_TYPES = tuple(_CONTAINERS) 

2963 

2964 

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

2966 """ 

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

2968 

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

2970 were given, and returns `msg` unchanged. 

2971 

2972 """ 

2973 

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

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

2976 else: 

2977 if args: 

2978 raise TypeError( 

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

2980 ) 

2981 return msg 

2982 

2983 

2984class _StrBase(abc.ABC): 

2985 def __str__(self) -> str: 

2986 import yuio.io 

2987 

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

2989 

2990 @abc.abstractmethod 

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

2992 raise NotImplementedError() 

2993 

2994 

2995@repr_from_rich 

2996class Format(_StrBase): 

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

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

2999 

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

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

3002 

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

3004 actual formatting lazily when requested. Color tags and backticks 

3005 are handled as usual. 

3006 

3007 :param msg: 

3008 message to format. 

3009 :param args: 

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

3011 :example: 

3012 :: 

3013 

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

3015 >>> print(message) 

3016 Hello, world! 

3017 

3018 """ 

3019 

3020 @_t.overload 

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

3022 @_t.overload 

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

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

3025 self._msg: str | _Template = msg 

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

3027 

3028 def __rich_repr__(self) -> RichReprResult: 

3029 yield None, self._msg 

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

3031 

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

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

3034 

3035 

3036@_t.final 

3037@repr_from_rich 

3038class Repr(_StrBase): 

3039 """ 

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

3041 

3042 :param value: 

3043 value to repr. 

3044 :param multiline: 

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

3046 :param highlighted: 

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

3048 :example: 

3049 .. code-block:: python 

3050 

3051 config = ... 

3052 yuio.io.info( 

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

3054 ) 

3055 

3056 """ 

3057 

3058 def __init__( 

3059 self, 

3060 value: _t.Any, 

3061 /, 

3062 *, 

3063 multiline: bool | None = None, 

3064 highlighted: bool | None = None, 

3065 ): 

3066 self.value = value 

3067 self.multiline = multiline 

3068 self.highlighted = highlighted 

3069 

3070 def __rich_repr__(self) -> RichReprResult: 

3071 yield None, self.value 

3072 yield "multiline", self.multiline, None 

3073 yield "highlighted", self.highlighted, None 

3074 

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

3076 return ctx.repr( 

3077 self.value, multiline=self.multiline, highlighted=self.highlighted 

3078 ) 

3079 

3080 

3081@_t.final 

3082@repr_from_rich 

3083class TypeRepr(_StrBase): 

3084 """ 

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

3086 and highlights the result. 

3087 

3088 :param ty: 

3089 type to format. 

3090 

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

3092 allowing you to mix types and arbitrary descriptions. 

3093 :param highlighted: 

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

3095 :example: 

3096 .. invisible-code-block: python 

3097 

3098 value = ... 

3099 

3100 .. code-block:: python 

3101 

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

3103 

3104 """ 

3105 

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

3107 self._ty = ty 

3108 self._highlighted = highlighted 

3109 

3110 def __rich_repr__(self) -> RichReprResult: 

3111 yield None, self._ty 

3112 yield "highlighted", self._highlighted, None 

3113 

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

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

3116 self._ty, (str, ColorizedString) 

3117 ): 

3118 return ColorizedString(self._ty) 

3119 else: 

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

3121 

3122 

3123@repr_from_rich 

3124class _JoinBase(_StrBase): 

3125 def __init__( 

3126 self, 

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

3128 /, 

3129 *, 

3130 sep: str = ", ", 

3131 sep_two: str | None = None, 

3132 sep_last: str | None = None, 

3133 fallback: AnyString = "", 

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

3135 ): 

3136 self.__collection = collection 

3137 self._sep = sep 

3138 self._sep_two = sep_two 

3139 self._sep_last = sep_last 

3140 self._fallback: AnyString = fallback 

3141 self._color = color 

3142 

3143 @functools.cached_property 

3144 def _collection(self): 

3145 return list(self.__collection) 

3146 

3147 @classmethod 

3148 def or_( 

3149 cls, 

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

3151 /, 

3152 *, 

3153 fallback: AnyString = "", 

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

3155 ) -> _t.Self: 

3156 """ 

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

3158 

3159 :example: 

3160 :: 

3161 

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

3163 1, 2, or 3 

3164 

3165 """ 

3166 

3167 return cls( 

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

3169 ) 

3170 

3171 @classmethod 

3172 def and_( 

3173 cls, 

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

3175 /, 

3176 *, 

3177 fallback: AnyString = "", 

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

3179 ) -> _t.Self: 

3180 """ 

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

3182 

3183 :example: 

3184 :: 

3185 

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

3187 1, 2, and 3 

3188 

3189 """ 

3190 

3191 return cls( 

3192 collection, 

3193 sep_last=", and ", 

3194 sep_two=" and ", 

3195 fallback=fallback, 

3196 color=color, 

3197 ) 

3198 

3199 def __rich_repr__(self) -> RichReprResult: 

3200 yield None, self._collection 

3201 yield "sep", self._sep, ", " 

3202 yield "sep_two", self._sep_two, None 

3203 yield "sep_last", self._sep_last, None 

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

3205 

3206 def _render( 

3207 self, 

3208 theme: yuio.theme.Theme, 

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

3210 ) -> ColorizedString: 

3211 res = ColorizedString() 

3212 color = theme.to_color(self._color) 

3213 

3214 size = len(self._collection) 

3215 if not size: 

3216 res += self._fallback 

3217 return res 

3218 elif size == 1: 

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

3220 elif size == 2: 

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

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

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

3224 return res 

3225 

3226 last_i = size - 1 

3227 

3228 sep = self._sep 

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

3230 

3231 do_sep = False 

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

3233 if do_sep: 

3234 if i == last_i: 

3235 res.append_str(sep_last) 

3236 else: 

3237 res.append_str(sep) 

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

3239 do_sep = True 

3240 return res 

3241 

3242 

3243@_t.final 

3244class JoinStr(_JoinBase): 

3245 """ 

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

3247 then joins the results using the given separator. 

3248 

3249 :param collection: 

3250 collection that will be printed. 

3251 :param sep: 

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

3253 :param sep_two: 

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

3255 Defaults to `sep`. 

3256 :param sep_last: 

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

3258 of the collection. Defaults to `sep`. 

3259 :param fallback: 

3260 printed if collection is empty. 

3261 :param color: 

3262 color applied to elements of the collection. 

3263 :example: 

3264 .. code-block:: python 

3265 

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

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

3268 

3269 """ 

3270 

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

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

3273 

3274 

3275@_t.final 

3276class JoinRepr(_JoinBase): 

3277 """ 

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

3279 then joins the results using the given separator. 

3280 

3281 :param collection: 

3282 collection that will be printed. 

3283 :param sep: 

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

3285 :param sep_two: 

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

3287 Defaults to `sep`. 

3288 :param sep_last: 

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

3290 of the collection. Defaults to `sep`. 

3291 :param fallback: 

3292 printed if collection is empty. 

3293 :param color: 

3294 color applied to elements of the collection. 

3295 :example: 

3296 .. code-block:: python 

3297 

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

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

3300 

3301 """ 

3302 

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

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

3305 

3306 

3307And = JoinStr.and_ 

3308""" 

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

3310 

3311""" 

3312 

3313 

3314Or = JoinStr.or_ 

3315""" 

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

3317 

3318""" 

3319 

3320 

3321@_t.final 

3322@repr_from_rich 

3323class Stack(_StrBase): 

3324 """ 

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

3326 effectively stacking them one on top of another. 

3327 

3328 :param args: 

3329 colorables to stack. 

3330 :example: 

3331 :: 

3332 

3333 >>> print( 

3334 ... yuio.string.Stack( 

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

3336 ... yuio.string.Indent( 

3337 ... yuio.string.Hl( 

3338 ... \""" 

3339 ... { 

3340 ... "foo": "bar" 

3341 ... } 

3342 ... \""", 

3343 ... syntax="json", 

3344 ... ), 

3345 ... indent="-> ", 

3346 ... ), 

3347 ... ) 

3348 ... ) 

3349 Example: 

3350 -> { 

3351 -> "foo": "bar" 

3352 -> } 

3353 

3354 """ 

3355 

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

3357 self._args = args 

3358 

3359 def __rich_repr__(self) -> RichReprResult: 

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

3361 

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

3363 res = ColorizedString() 

3364 sep = False 

3365 for arg in self._args: 

3366 if sep: 

3367 res.append_color(_Color.NONE) 

3368 res.append_str("\n") 

3369 res += ctx.str(arg) 

3370 sep = True 

3371 return res 

3372 

3373 

3374@_t.final 

3375@repr_from_rich 

3376class Indent(_StrBase): 

3377 """ 

3378 Lazy wrapper that indents the message during formatting. 

3379 

3380 .. seealso:: 

3381 

3382 :meth:`ColorizedString.indent`. 

3383 

3384 :param msg: 

3385 message to indent. 

3386 :param indent: 

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

3388 Defaults to two spaces. 

3389 :param continuation_indent: 

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

3391 Defaults to `indent`. 

3392 :example: 

3393 .. code-block:: python 

3394 

3395 config = ... 

3396 yuio.io.info( 

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

3398 ) 

3399 

3400 """ 

3401 

3402 def __init__( 

3403 self, 

3404 msg: Colorable, 

3405 /, 

3406 *, 

3407 indent: AnyString | int = " ", 

3408 continuation_indent: AnyString | int | None = None, 

3409 ): 

3410 self._msg = msg 

3411 self._indent: AnyString | int = indent 

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

3413 

3414 def __rich_repr__(self) -> RichReprResult: 

3415 yield None, self._msg 

3416 yield "indent", self._indent, " " 

3417 yield "continuation_indent", self._continuation_indent, None 

3418 

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

3420 if isinstance(self._indent, int): 

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

3422 else: 

3423 indent = ColorizedString(self._indent) 

3424 if self._continuation_indent is None: 

3425 continuation_indent = indent 

3426 elif isinstance(self._continuation_indent, int): 

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

3428 else: 

3429 continuation_indent = ColorizedString(self._continuation_indent) 

3430 

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

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

3433 

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

3435 

3436 

3437@_t.final 

3438@repr_from_rich 

3439class Md(_StrBase): 

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

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

3442 

3443 Lazy wrapper that renders markdown during formatting. 

3444 

3445 :param md: 

3446 markdown to format. 

3447 :param args: 

3448 arguments for ``%``-formatting the rendered markdown. 

3449 :param width: 

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

3451 :param dedent: 

3452 whether to remove leading indent from markdown. 

3453 :param allow_headings: 

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

3455 

3456 """ 

3457 

3458 @_t.overload 

3459 def __init__( 

3460 self, 

3461 md: _t.LiteralString, 

3462 /, 

3463 *args: _t.Any, 

3464 width: int | None = None, 

3465 dedent: bool = True, 

3466 allow_headings: bool = True, 

3467 ): ... 

3468 @_t.overload 

3469 def __init__( 

3470 self, 

3471 md: str, 

3472 /, 

3473 *, 

3474 width: int | None = None, 

3475 dedent: bool = True, 

3476 allow_headings: bool = True, 

3477 ): ... 

3478 def __init__( 

3479 self, 

3480 md: str, 

3481 /, 

3482 *args: _t.Any, 

3483 width: int | None = None, 

3484 dedent: bool = True, 

3485 allow_headings: bool = True, 

3486 ): 

3487 self._md: str = md 

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

3489 self._width: int | None = width 

3490 self._dedent: bool = dedent 

3491 self._allow_headings: bool = allow_headings 

3492 

3493 def __rich_repr__(self) -> RichReprResult: 

3494 yield None, self._md 

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

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

3497 yield "dedent", self._dedent, True 

3498 yield "allow_headings", self._allow_headings, True 

3499 

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

3501 import yuio.md 

3502 

3503 width = self._width or ctx.width 

3504 with ctx.with_settings(width=width): 

3505 formatter = yuio.md.MdFormatter( 

3506 ctx, 

3507 allow_headings=self._allow_headings, 

3508 ) 

3509 

3510 res = ColorizedString() 

3511 res.start_no_wrap() 

3512 sep = False 

3513 for line in formatter.format(self._md, dedent=self._dedent): 

3514 if sep: 

3515 res += "\n" 

3516 res += line 

3517 sep = True 

3518 res.end_no_wrap() 

3519 if self._args: 

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

3521 

3522 return res 

3523 

3524 

3525@_t.final 

3526@repr_from_rich 

3527class Hl(_StrBase): 

3528 """Hl(code: typing.LiteralString, /, *args, syntax: str | yuio.md.SyntaxHighlighter, dedent: bool = True) 

3529 Hl(code: str, /, *, syntax: str | yuio.md.SyntaxHighlighter, dedent: bool = True) 

3530 

3531 Lazy wrapper that highlights code during formatting. 

3532 

3533 :param md: 

3534 code to highlight. 

3535 :param args: 

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

3537 :param syntax: 

3538 name of syntax or a :class:`~yuio.md.SyntaxHighlighter` instance. 

3539 :param dedent: 

3540 whether to remove leading indent from code. 

3541 

3542 """ 

3543 

3544 @_t.overload 

3545 def __init__( 

3546 self, 

3547 code: _t.LiteralString, 

3548 /, 

3549 *args: _t.Any, 

3550 syntax: str | yuio.md.SyntaxHighlighter, 

3551 dedent: bool = True, 

3552 ): ... 

3553 @_t.overload 

3554 def __init__( 

3555 self, 

3556 code: str, 

3557 /, 

3558 *, 

3559 syntax: str | yuio.md.SyntaxHighlighter, 

3560 dedent: bool = True, 

3561 ): ... 

3562 def __init__( 

3563 self, 

3564 code: str, 

3565 /, 

3566 *args: _t.Any, 

3567 syntax: str | yuio.md.SyntaxHighlighter, 

3568 dedent: bool = True, 

3569 ): 

3570 self._code: str = code 

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

3572 self._syntax: str | yuio.md.SyntaxHighlighter = syntax 

3573 self._dedent: bool = dedent 

3574 

3575 def __rich_repr__(self) -> RichReprResult: 

3576 yield None, self._code 

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

3578 yield "syntax", self._syntax 

3579 yield "dedent", self._dedent, True 

3580 

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

3582 import yuio.md 

3583 

3584 syntax = ( 

3585 self._syntax 

3586 if isinstance(self._syntax, yuio.md.SyntaxHighlighter) 

3587 else yuio.md.SyntaxHighlighter.get_highlighter(self._syntax) 

3588 ) 

3589 code = self._code 

3590 if self._dedent: 

3591 code = _dedent(code) 

3592 code = code.rstrip() 

3593 

3594 res = ColorizedString() 

3595 res.start_no_wrap() 

3596 res += syntax.highlight(ctx.theme, code) 

3597 res.end_no_wrap() 

3598 if self._args: 

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

3600 

3601 return res 

3602 

3603 

3604@_t.final 

3605@repr_from_rich 

3606class Wrap(_StrBase): 

3607 """ 

3608 Lazy wrapper that wraps the message during formatting. 

3609 

3610 .. seealso:: 

3611 

3612 :meth:`ColorizedString.wrap`. 

3613 

3614 :param msg: 

3615 message to wrap. 

3616 :param width: 

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

3618 :param preserve_spaces: 

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

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

3621 

3622 Note that tabs always treated as a single whitespace. 

3623 :param preserve_newlines: 

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

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

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

3627 

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

3629 :param break_long_words: 

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

3631 will be split into multiple lines. 

3632 :param overflow: 

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

3634 :param break_long_nowrap_words: 

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

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

3637 :param indent: 

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

3639 Defaults to two spaces. 

3640 :param continuation_indent: 

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

3642 Defaults to `indent`. 

3643 

3644 """ 

3645 

3646 def __init__( 

3647 self, 

3648 msg: Colorable, 

3649 /, 

3650 *, 

3651 width: int | None = None, 

3652 preserve_spaces: bool = False, 

3653 preserve_newlines: bool = True, 

3654 break_long_words: bool = True, 

3655 break_long_nowrap_words: bool = False, 

3656 overflow: bool | str = False, 

3657 indent: AnyString | int = "", 

3658 continuation_indent: AnyString | int | None = None, 

3659 ): 

3660 self._msg = msg 

3661 self._width: int | None = width 

3662 self._preserve_spaces = preserve_spaces 

3663 self._preserve_newlines = preserve_newlines 

3664 self._break_long_words = break_long_words 

3665 self._break_long_nowrap_words = break_long_nowrap_words 

3666 self._overflow = overflow 

3667 self._indent: AnyString | int = indent 

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

3669 

3670 def __rich_repr__(self) -> RichReprResult: 

3671 yield None, self._msg 

3672 yield "width", self._width, None 

3673 yield "indent", self._indent, "" 

3674 yield "continuation_indent", self._continuation_indent, None 

3675 yield "preserve_spaces", self._preserve_spaces, None 

3676 yield "preserve_newlines", self._preserve_newlines, True 

3677 yield "break_long_words", self._break_long_words, True 

3678 yield "break_long_nowrap_words", self._break_long_nowrap_words, False 

3679 

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

3681 if isinstance(self._indent, int): 

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

3683 else: 

3684 indent = ColorizedString(self._indent) 

3685 if self._continuation_indent is None: 

3686 continuation_indent = indent 

3687 elif isinstance(self._continuation_indent, int): 

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

3689 else: 

3690 continuation_indent = ColorizedString(self._continuation_indent) 

3691 

3692 width = self._width or ctx.width 

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

3694 inner_width = max(1, width - indent_width) 

3695 

3696 overflow = self._overflow 

3697 if overflow is True: 

3698 overflow = ctx.get_msg_decoration("overflow") 

3699 

3700 res = ColorizedString() 

3701 res.start_no_wrap() 

3702 sep = False 

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

3704 width, 

3705 preserve_spaces=self._preserve_spaces, 

3706 preserve_newlines=self._preserve_newlines, 

3707 break_long_words=self._break_long_words, 

3708 break_long_nowrap_words=self._break_long_nowrap_words, 

3709 overflow=overflow, 

3710 indent=indent, 

3711 continuation_indent=continuation_indent, 

3712 ): 

3713 if sep: 

3714 res.append_str("\n") 

3715 res.append_colorized_str(line) 

3716 sep = True 

3717 res.end_no_wrap() 

3718 

3719 return res 

3720 

3721 

3722@_t.final 

3723@repr_from_rich 

3724class WithBaseColor(_StrBase): 

3725 """ 

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

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

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

3729 

3730 .. seealso:: 

3731 

3732 :meth:`ColorizedString.with_base_color`. 

3733 

3734 :param msg: 

3735 message to highlight. 

3736 :param base_color: 

3737 color that will be added under the message. 

3738 

3739 """ 

3740 

3741 def __init__( 

3742 self, 

3743 msg: Colorable, 

3744 /, 

3745 *, 

3746 base_color: str | _Color, 

3747 ): 

3748 self._msg = msg 

3749 self._base_color = base_color 

3750 

3751 def __rich_repr__(self) -> RichReprResult: 

3752 yield None, self._msg 

3753 yield "base_color", self._base_color 

3754 

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

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

3757 

3758 

3759@repr_from_rich 

3760class Hr(_StrBase): 

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

3762 

3763 Produces horizontal ruler when converted to string. 

3764 

3765 :param msg: 

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

3767 :param weight: 

3768 weight or style of the ruler: 

3769 

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

3771 - ``1`` prints normal ruler, 

3772 - ``2`` prints bold ruler. 

3773 

3774 Additional styles can be added through 

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

3776 :param width: 

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

3778 :param overflow: 

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

3780 :param kwargs: 

3781 Other keyword arguments override corresponding decorations from the theme: 

3782 

3783 :`left_start`: 

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

3785 :`left_middle`: 

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

3787 :`left_end`: 

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

3789 :`middle`: 

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

3791 :`right_start`: 

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

3793 :`right_middle`: 

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

3795 :`right_end`: 

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

3797 

3798 """ 

3799 

3800 def __init__( 

3801 self, 

3802 msg: Colorable = "", 

3803 /, 

3804 *, 

3805 width: int | None = None, 

3806 overflow: bool | str = True, 

3807 weight: int | str = 1, 

3808 left_start: str | None = None, 

3809 left_middle: str | None = None, 

3810 left_end: str | None = None, 

3811 middle: str | None = None, 

3812 right_start: str | None = None, 

3813 right_middle: str | None = None, 

3814 right_end: str | None = None, 

3815 ): 

3816 self._msg = msg 

3817 self._width = width 

3818 self._overflow = overflow 

3819 self._weight = weight 

3820 self._left_start = left_start 

3821 self._left_middle = left_middle 

3822 self._left_end = left_end 

3823 self._middle = middle 

3824 self._right_start = right_start 

3825 self._right_middle = right_middle 

3826 self._right_end = right_end 

3827 

3828 def __rich_repr__(self) -> RichReprResult: 

3829 yield None, self._msg, None 

3830 yield "weight", self._weight, None 

3831 yield "width", self._width, None 

3832 yield "overflow", self._overflow, None 

3833 yield "left_start", self._left_start, None 

3834 yield "left_middle", self._left_middle, None 

3835 yield "left_end", self._left_end, None 

3836 yield "middle", self._middle, None 

3837 yield "right_start", self._right_start, None 

3838 yield "right_middle", self._right_middle, None 

3839 yield "right_end", self._right_end, None 

3840 

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

3842 width = self._width or ctx.width 

3843 

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

3845 

3846 res = ColorizedString(color) 

3847 res.start_no_wrap() 

3848 

3849 msg = ctx.str(self._msg) 

3850 if not msg: 

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

3852 return res 

3853 

3854 overflow = self._overflow 

3855 if overflow is True: 

3856 overflow = ctx.get_msg_decoration("overflow") 

3857 

3858 sep = False 

3859 for line in msg.wrap( 

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

3861 ): 

3862 if sep: 

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

3864 res.append_str("\n") 

3865 res.append_color(color) 

3866 

3867 line_w = line.width 

3868 line_w_fill = max(0, width - line_w) 

3869 line_w_fill_l = line_w_fill // 2 

3870 line_w_fill_r = line_w_fill - line_w_fill_l 

3871 if not line_w_fill_l and not line_w_fill_r: 

3872 res.append_colorized_str(line) 

3873 return res 

3874 

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

3876 res.append_colorized_str(line) 

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

3878 

3879 sep = True 

3880 

3881 return res 

3882 

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

3884 weight = self._weight 

3885 start = ( 

3886 self._left_start 

3887 if self._left_start is not None 

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

3889 ) 

3890 middle = ( 

3891 self._left_middle 

3892 if self._left_middle is not None 

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

3894 ) or " " 

3895 end = ( 

3896 self._left_end 

3897 if self._left_end is not None 

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

3899 ) 

3900 

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

3902 

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

3904 weight = self._weight 

3905 start = ( 

3906 self._right_start 

3907 if self._right_start is not None 

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

3909 ) 

3910 middle = ( 

3911 self._right_middle 

3912 if self._right_middle is not None 

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

3914 ) or " " 

3915 end = ( 

3916 self._right_end 

3917 if self._right_end is not None 

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

3919 ) 

3920 

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

3922 

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

3924 weight = self._weight 

3925 start = ( 

3926 self._left_start 

3927 if self._left_start is not None 

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

3929 ) 

3930 middle = ( 

3931 self._middle 

3932 if self._middle is not None 

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

3934 ) or " " 

3935 end = ( 

3936 self._right_end 

3937 if self._right_end is not None 

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

3939 ) 

3940 

3941 start_w = line_width(start) 

3942 middle_w = line_width(middle) 

3943 end_w = line_width(end) 

3944 

3945 if w >= start_w: 

3946 w -= start_w 

3947 else: 

3948 start = "" 

3949 if w >= end_w: 

3950 w -= end_w 

3951 else: 

3952 end = "" 

3953 middle_times = w // middle_w 

3954 w -= middle_times * middle_w 

3955 middle *= middle_times 

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

3957 

3958 

3959def _make_left( 

3960 w: int, 

3961 start: str, 

3962 middle: str, 

3963 end: str, 

3964): 

3965 start_w = line_width(start) 

3966 middle_w = line_width(middle) 

3967 end_w = line_width(end) 

3968 

3969 if w >= end_w: 

3970 w -= end_w 

3971 else: 

3972 end = "" 

3973 if w >= start_w: 

3974 w -= start_w 

3975 else: 

3976 start = "" 

3977 middle_times = w // middle_w 

3978 w -= middle_times * middle_w 

3979 middle *= middle_times 

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

3981 

3982 

3983def _make_right( 

3984 w: int, 

3985 start: str, 

3986 middle: str, 

3987 end: str, 

3988): 

3989 start_w = line_width(start) 

3990 middle_w = line_width(middle) 

3991 end_w = line_width(end) 

3992 

3993 if w >= start_w: 

3994 w -= start_w 

3995 else: 

3996 start = "" 

3997 if w >= end_w: 

3998 w -= end_w 

3999 else: 

4000 end = "" 

4001 middle_times = w // middle_w 

4002 w -= middle_times * middle_w 

4003 middle *= middle_times 

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