Coverage for yuio / string.py: 98%
1328 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-04 10:05 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-04 10:05 +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
8"""
9The higher-level :mod:`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:
13This is a low-level module upon which :mod:`yuio.io` builds
14its higher-level abstraction.
16.. autoclass:: ColorizedString
17 :members:
19.. autoclass:: Link
20 :members:
23Parsing color tags
24------------------
26.. autofunction:: colorize
28.. autofunction:: strip_color_tags
31Pretty ``str`` and ``repr``
32---------------------------
34.. autofunction:: colorized_str
36.. autofunction:: colorized_repr
39.. _pretty-protocol:
41Pretty printing protocol
42------------------------
44Yuio searches for special methods on your objects when rendering them.
46``__colorized_str__``, ``__colorized_repr__``
47 This should be a method that accepts a single positional argument,
48 :class:`ReprContext`, and returns a :class:`ColorizedString`.
50 .. warning::
52 Don't call :func:`colorized_repr` or :func:`colorized_str` from these
53 implementations, as this may result in infinite recursion. Instead,
54 use :meth:`ReprContext.repr` and :meth:`ReprContext.str`.
56 .. tip::
58 Prefer ``__rich_repr__`` for simpler use cases, and only use
59 ``__colorized_repr__`` when you need something advanced.
61 **Example:**
63 .. code-block:: python
65 class MyObject:
66 def __init__(self, value):
67 self.value = value
69 def __colorized_str__(self, ctx: yuio.string.ReprContext):
70 result = yuio.string.ColorizedString()
71 result += ctx.theme.get_color("magenta")
72 result += "MyObject"
73 result += ctx.theme.get_color("normal")
74 result += "MyObject"
75 result += ctx.repr(self.value)
76 result += ctx.theme.get_color("normal")
77 result += ")"
78 return result
80``__rich_repr__``
81 This method doesn't have any arguments. It should return an iterable of tuples
82 describing object's arguments:
84 - ``yield name, value`` will generate a keyword argument,
85 - ``yield name, value, default`` will generate a keyword argument if value
86 is not equal to default,
87 - if ``name`` is :data:`None`, it will generate positional argument instead.
89 See the `Rich library documentation`__ for more info.
91 __ https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol
93 **Example:**
95 .. code-block:: python
97 class MyObject:
98 def __init__(self, value1, value2):
99 self.value1 = value1
100 self.value2 = value2
102 def __rich_repr__(self) -> yuio.string.RichReprResult:
103 yield "value1", self.value1
104 yield "value2", self.value2
106.. autoclass:: ReprContext
107 :members:
109.. type:: RichReprResult
110 :canonical: typing.Iterable[tuple[typing.Any] | tuple[str | None, typing.Any] | tuple[str | None, typing.Any, typing.Any]]
112 This is an alias similar to ``rich.repr.Result``, but stricter: it only
113 allows tuples, not arbitrary values.
115 This is done to avoid bugs where you yield a single value which happens to contain
116 a tuple, and Yuio (or Rich) renders it as a named argument.
119.. type:: ColorizedStrProtocol
121 Protocol for objects that define ``__colorized_str__`` method.
123.. type:: ColorizedReprProtocol
125 Protocol for objects that define ``__colorized_repr__`` method.
127.. type:: RichReprProtocol
129 Protocol for objects that define ``__rich_repr__`` method.
131.. type:: Printable
133 Any object that supports printing.
135 Technically, any object supports colorized printing because we'll fall back
136 to ``__repr__`` or ``__str__`` if there are no special methods on it.
138 However, we don't use :class:`typing.Any` to avoid potential errors.
140.. type:: Colorable
141 :canonical: Printable | ColorizedStrProtocol | ColorizedReprProtocol | RichReprProtocol | str | BaseException
143 An object that supports colorized printing.
145.. autofunction:: repr_from_rich
148.. _formatting-utilities:
150Formatting utilities
151--------------------
153.. autoclass:: Format
154 :members:
156.. autoclass:: Repr
157 :members:
159.. autoclass:: TypeRepr
160 :members:
162.. autoclass:: JoinStr
163 :members:
164 :inherited-members:
166.. autoclass:: JoinRepr
167 :members:
168 :inherited-members:
170.. autofunction:: And
172.. autofunction:: Or
174.. autoclass:: Stack
175 :members:
177.. autoclass:: Indent
178 :members:
180.. autoclass:: Md
181 :members:
183.. autoclass:: Hl
184 :members:
186.. autoclass:: Wrap
187 :members:
189.. autoclass:: WithBaseColor
190 :members:
192.. autoclass:: Hr
193 :members:
196Helper types
197------------
199.. type:: AnyString
200 :canonical: str | ~yuio.color.Color | ColorizedString | NoWrapMarker | typing.Iterable[AnyString]
202 Any string (i.e. a :class:`str`, a raw colorized string,
203 or a normal colorized string).
205.. autodata:: NO_WRAP_START
207.. autodata:: NO_WRAP_END
209.. type:: NoWrapMarker
210 NoWrapStart
211 NoWrapEnd
213 Type of a no-wrap marker.
215.. autofunction:: line_width
217"""
219from __future__ import annotations
221import abc
222import collections
223import dataclasses
224import functools
225import os
226import re
227import reprlib
228import shutil
229import string
230import types
231import unicodedata
232from enum import Enum
234import yuio
235import yuio.color
236import yuio.theme
237from yuio import _typing as _t
238from yuio.color import Color as _Color
239from yuio.util import UserString as _UserString
240from yuio.util import dedent as _dedent
242if _t.TYPE_CHECKING:
243 import yuio.md
245__all__ = [
246 "NO_WRAP_END",
247 "NO_WRAP_START",
248 "And",
249 "AnyString",
250 "Colorable",
251 "ColorizedReprProtocol",
252 "ColorizedStrProtocol",
253 "ColorizedString",
254 "Esc",
255 "Format",
256 "Hl",
257 "Hr",
258 "Indent",
259 "JoinRepr",
260 "JoinStr",
261 "Link",
262 "Md",
263 "NoWrapEnd",
264 "NoWrapMarker",
265 "NoWrapStart",
266 "Or",
267 "Printable",
268 "Repr",
269 "ReprContext",
270 "RichReprProtocol",
271 "RichReprResult",
272 "Stack",
273 "TypeRepr",
274 "WithBaseColor",
275 "Wrap",
276 "colorize",
277 "colorized_repr",
278 "colorized_str",
279 "line_width",
280 "repr_from_rich",
281 "strip_color_tags",
282]
285def line_width(s: str, /) -> int:
286 """
287 Calculates string width when the string is displayed
288 in a terminal.
290 This function makes effort to detect wide characters
291 such as emojis. If does not, however, work correctly
292 with extended grapheme clusters, and so it may fail
293 for emojis with modifiers, or other complex characters.
295 Example where it fails is ``👩🏽💻``. It consists
296 of four code points:
298 - Unicode Character `WOMAN` (``U+1F469``, ``👩``),
299 - Unicode Character `EMOJI MODIFIER FITZPATRICK TYPE-4` (``U+1F3FD``),
300 - Unicode Character `ZERO WIDTH JOINER` (``U+200D``),
301 - Unicode Character `PERSONAL COMPUTER` (``U+1F4BB``, ``💻``).
303 Since :func:`line_width` can't understand that these code points
304 are combined into a single emoji, it treats them separately,
305 resulting in answer `6` (`2` for every code point except `ZERO WIDTH JOINER`)::
307 >>> line_width("\U0001f469\U0001f3fd\U0000200d\U0001f4bb")
308 6
310 In all fairness, detecting how much space such an emoji will take
311 is not so straight forward, as that will depend on unicode capabilities
312 of a specific terminal. Since a lot of terminals will not handle such emojis
313 correctly, I've decided to go with this simplistic implementation.
315 """
317 # Note: it may be better to bundle `wcwidth` and use it instead of the code below.
318 # However, there is an issue that `wcwidth`'s results are not additive.
319 # In the above example, `wcswidth('👩🏽💻')` will see that it is two-spaces wide,
320 # while `sum(wcwidth(c) for c in '👩🏽💻')` will report that it is four-spaces wide.
321 # To render it properly, the widget will have to be aware of extended grapheme
322 # clusters, and generally this will be a lot of headache. Since most terminals
323 # won't handle these edge cases correctly, I don't want to bother.
325 if s.isascii():
326 # Fast path. Note that our renderer replaces unprintable characters
327 # with spaces, so ascii strings always have width equal to their length.
328 return len(s)
329 else:
330 # Long path. It kinda works, but not always, but most of the times...
331 return sum(
332 (unicodedata.east_asian_width(c) in "WF") + 1
333 for c in s
334 if unicodedata.category(c)[0] not in "MC"
335 )
338RichReprResult: _t.TypeAlias = _t.Iterable[
339 tuple[_t.Any] | tuple[str | None, _t.Any] | tuple[str | None, _t.Any, _t.Any]
340]
341"""
342Similar to ``rich.repr.Result``, but only allows tuples, not arbitrary values.
344"""
347@_t.runtime_checkable
348class ColorizedStrProtocol(_t.Protocol):
349 """
350 Protocol for objects that define ``__colorized_str__`` method.
352 """
354 @abc.abstractmethod
355 def __colorized_str__(self, ctx: ReprContext, /) -> ColorizedString: ...
358@_t.runtime_checkable
359class ColorizedReprProtocol(_t.Protocol):
360 """
361 Protocol for objects that define ``__colorized_repr__`` method.
363 """
365 @abc.abstractmethod
366 def __colorized_repr__(self, ctx: ReprContext, /) -> ColorizedString: ...
369@_t.runtime_checkable
370class RichReprProtocol(_t.Protocol):
371 """
372 Protocol for objects that define ``__rich_repr__`` method.
374 """
376 @abc.abstractmethod
377 def __rich_repr__(self) -> _t.Iterable[_t.Any]: ...
380Printable = _t.NewType("Printable", object)
381"""
382Any object that supports printing.
384Technically, any object supports colorized printing because we'll fall back
385to ``__repr__`` or ``__str__`` if there are no special methods on it.
387However, we don't use :class:`typing.Any` to avoid potential errors.
389"""
392Colorable: _t.TypeAlias = (
393 Printable
394 | ColorizedStrProtocol
395 | ColorizedReprProtocol
396 | RichReprProtocol
397 | str
398 | BaseException
399)
400"""
401Any object that supports colorized printing.
403This can be a string, and exception, or any object that follows
404:class:`ColorableProtocol`. Additionally, you can pass any object that has
405``__repr__``, but you'll have to wrap it into :type:`Printable` to confirm
406your intent to print it.
408"""
411RichReprProtocolT = _t.TypeVar("RichReprProtocolT", bound=RichReprProtocol)
414def repr_from_rich(cls: type[RichReprProtocolT], /) -> type[RichReprProtocolT]:
415 """repr_from_rich(cls: RichReprProtocol) -> RichReprProtocol
417 A decorator that generates ``__repr__`` from ``__rich_repr__``.
419 :param cls:
420 class that needs ``__repr__``.
421 :returns:
422 always returns ``cls``.
423 :example:
424 .. code-block:: python
426 @yuio.string.repr_from_rich
427 class MyClass:
428 def __init__(self, value):
429 self.value = value
431 def __rich_repr__(self) -> yuio.string.RichReprResult:
432 yield "value", self.value
434 ::
436 >>> print(repr(MyClass("plush!")))
437 MyClass(value='plush!')
440 """
442 setattr(cls, "__repr__", _repr_from_rich_impl)
443 return cls
446def _repr_from_rich_impl(self: RichReprProtocol):
447 if rich_repr := getattr(self, "__rich_repr__", None):
448 args = rich_repr()
449 angular = getattr(rich_repr, "angular", False)
450 else:
451 args = []
452 angular = False
454 if args is None:
455 args = [] # `rich_repr` didn't yield?
457 res = []
459 if angular:
460 res.append("<")
461 res.append(self.__class__.__name__)
462 if angular:
463 res.append(" ")
464 else:
465 res.append("(")
467 sep = False
468 for arg in args:
469 if isinstance(arg, tuple):
470 if len(arg) == 3:
471 key, child, default = arg
472 if default == child:
473 continue
474 elif len(arg) == 2:
475 key, child = arg
476 elif len(arg) == 1:
477 key, child = None, arg[0]
478 else:
479 key, child = None, arg
480 else:
481 key, child = None, arg
483 if sep:
484 res.append(" " if angular else ", ")
485 if key:
486 res.append(str(key))
487 res.append("=")
488 res.append(repr(child))
489 sep = True
491 res.append(">" if angular else ")")
493 return "".join(res)
496class _NoWrapMarker(Enum):
497 """
498 Type for a no-wrap marker.
500 """
502 NO_WRAP_START = "<no_wrap_start>"
503 NO_WRAP_END = "<no_wrap_end>"
505 def __repr__(self):
506 return f"yuio.string.{self.name}" # pragma: no cover
508 def __str__(self) -> str:
509 return self.value # pragma: no cover
512NoWrapStart: _t.TypeAlias = _t.Literal[_NoWrapMarker.NO_WRAP_START]
513"""
514Type of the :data:`NO_WRAP_START` placeholder.
516"""
518NO_WRAP_START: NoWrapStart = _NoWrapMarker.NO_WRAP_START
519"""
520Indicates start of a no-wrap region in a :class:`ColorizedString`.
522"""
525NoWrapEnd: _t.TypeAlias = _t.Literal[_NoWrapMarker.NO_WRAP_END]
526"""
527Type of the :data:`NO_WRAP_END` placeholder.
529"""
531NO_WRAP_END: NoWrapEnd = _NoWrapMarker.NO_WRAP_END
532"""
533Indicates end of a no-wrap region in a :class:`ColorizedString`.
535"""
537NoWrapMarker: _t.TypeAlias = NoWrapStart | NoWrapEnd
538"""
539Type of a no-wrap marker.
541"""
544@_t.final
545@repr_from_rich
546class ColorizedString:
547 """ColorizedString(content: AnyString = '', /)
549 A string with colors.
551 This class is a wrapper over a list of strings, colors, and no-wrap markers.
552 Each color applies to strings after it, right until the next color.
554 :class:`ColorizedString` supports some basic string operations.
555 Most notably, it supports wide-character-aware wrapping
556 (see :meth:`~ColorizedString.wrap`),
557 and ``%``-like formatting (see :meth:`~ColorizedString.percent_format`).
559 Unlike :class:`str`, :class:`ColorizedString` is mutable through
560 the ``+=`` operator and ``append``/``extend`` methods.
562 :param content:
563 initial content of the string. Can be :class:`str`, color, no-wrap marker,
564 or another colorized string.
567 **String combination semantics**
569 When you append a :class:`str`, it will take on color and no-wrap semantics
570 according to the last appended color and no-wrap marker.
572 When you append another :class:`ColorizedString`, it will not change its colors
573 based on the last appended color, nor will it affect colors of the consequent
574 strings. If appended :class:`ColorizedString` had an unterminated no-wrap region,
575 this region will be terminated after appending.
577 Thus, appending a colorized string does not change current color
578 or no-wrap setting::
580 >>> s1 = yuio.string.ColorizedString()
581 >>> s1 += yuio.color.Color.FORE_RED
582 >>> s1 += yuio.string.NO_WRAP_START
583 >>> s1 += "red nowrap text"
584 >>> s1 # doctest: +NORMALIZE_WHITESPACE
585 ColorizedString([yuio.string.NO_WRAP_START,
586 <Color fore=<RED>>,
587 'red nowrap text'])
589 >>> s2 = yuio.string.ColorizedString()
590 >>> s2 += yuio.color.Color.FORE_GREEN
591 >>> s2 += "green text "
592 >>> s2 += s1
593 >>> s2 += " green text continues"
594 >>> s2 # doctest: +NORMALIZE_WHITESPACE
595 ColorizedString([<Color fore=<GREEN>>,
596 'green text ',
597 yuio.string.NO_WRAP_START,
598 <Color fore=<RED>>,
599 'red nowrap text',
600 yuio.string.NO_WRAP_END,
601 <Color fore=<GREEN>>,
602 ' green text continues'])
604 """
606 # Invariants:
607 #
608 # - there is always a color before the first string in `_parts`.
609 # - there are no empty strings in `_parts`.
610 # - for every pair of colors in `_parts`, there is a string between them
611 # (i.e. there are no colors that don't highlight anything).
612 # - every color in `_parts` is different from the previous one
613 # (i.e. there are no redundant color markers).
614 # - `start-no-wrap` and `end-no-wrap` markers form a balanced bracket sequence,
615 # except for the last `start-no-wrap`, which may have no corresponding
616 # `end-no-wrap` yet.
617 # - no-wrap regions can't be nested.
618 # - fo every pair of (start-no-wrap, end-no-wrap) markers, there is a string
619 # between them (i.e. no empty no-wrap regions).
621 def __init__(
622 self,
623 content: AnyString = "",
624 /,
625 *,
626 _isolate_colors: bool = True,
627 ):
628 if isinstance(content, ColorizedString):
629 self._parts = content._parts.copy()
630 self._last_color = content._last_color
631 self._active_color = content._active_color
632 self._explicit_newline = content._explicit_newline
633 self._len = content._len
634 self._has_no_wrap = content._has_no_wrap
635 if (width := content.__dict__.get("width", None)) is not None:
636 self.__dict__["width"] = width
637 else:
638 self._parts: list[_Color | NoWrapMarker | str] = []
639 self._active_color = _Color.NONE
640 self._last_color: _Color | None = None
641 self._explicit_newline: str = ""
642 self._len = 0
643 self._has_no_wrap = False
645 if not _isolate_colors:
646 # Prevent adding `_Color.NONE` to the front of the string.
647 self._last_color = self._active_color
649 if content:
650 self += content
652 @property
653 def explicit_newline(self) -> str:
654 """
655 Explicit newline indicates that a line of a wrapped text
656 was broken because the original text contained a new line character.
658 See :meth:`~ColorizedString.wrap` for details.
660 """
662 return self._explicit_newline
664 @property
665 def active_color(self) -> _Color:
666 """
667 Last color appended to this string.
669 """
671 return self._active_color
673 @functools.cached_property
674 def width(self) -> int:
675 """
676 String width when the string is displayed in a terminal.
678 See :func:`line_width` for more information.
680 """
682 return sum(line_width(s) for s in self._parts if isinstance(s, str))
684 @property
685 def len(self) -> int:
686 """
687 Line length in bytes, ignoring all colors.
689 """
691 return self._len
693 def append_color(self, color: _Color, /):
694 """
695 Append new color to this string.
697 This operation is lazy, the color will be appended if a non-empty string
698 is appended after it.
700 :param color:
701 color to append.
703 """
705 self._active_color = color
707 def append_str(self, s: str, /):
708 """
709 Append new plain string to this string.
711 :param s:
712 plain string to append.
714 """
716 if not s:
717 return
718 if self._last_color != self._active_color:
719 self._parts.append(self._active_color)
720 self._last_color = self._active_color
721 self._parts.append(s)
722 self._len += len(s)
723 self.__dict__.pop("width", None)
725 def append_colorized_str(self, s: ColorizedString, /):
726 """
727 Append new colorized string to this string.
729 :param s:
730 colorized string to append.
732 """
733 if not s:
734 # Nothing to append.
735 return
737 parts = s._parts
739 # Cleanup color at the beginning of the string.
740 for i, part in enumerate(parts):
741 if part in (NO_WRAP_START, NO_WRAP_END):
742 continue
743 elif isinstance(part, str): # pragma: no cover
744 # We never hit this branch in normal conditions because colorized
745 # strings always start with a color. The only way to trigger this
746 # branch is to tamper with `_parts` and break colorized string
747 # invariants.
748 break
750 # First color in the appended string is the same as our last color.
751 # We can remove it without changing the outcome.
752 if part == self._last_color:
753 if i == 0:
754 parts = parts[i + 1 :]
755 else:
756 parts = parts[:i] + parts[i + 1 :]
758 break
760 if self._has_no_wrap:
761 # We're in a no-wrap sequence, we don't need any more markers.
762 self._parts.extend(
763 part for part in parts if part not in (NO_WRAP_START, NO_WRAP_END)
764 )
765 else:
766 # We're not in a no-wrap sequence. We preserve no-wrap regions from the
767 # appended string, but we make sure that they don't affect anything
768 # appended after.
769 self._parts.extend(parts)
770 if s._has_no_wrap:
771 self._has_no_wrap = True
772 self.end_no_wrap()
774 self._last_color = s._last_color
775 self._len += s._len
776 if (lw := self.__dict__.get("width")) and (rw := s.__dict__.get("width")):
777 self.__dict__["width"] = lw + rw
778 else:
779 self.__dict__.pop("width", None)
781 def append_no_wrap(self, m: NoWrapMarker, /):
782 """
783 Append a no-wrap marker.
785 :param m:
786 no-wrap marker, will be dispatched
787 to :meth:`~ColorizedString.start_no_wrap`
788 or :meth:`~ColorizedString.end_no_wrap`.
790 """
792 if m is NO_WRAP_START:
793 self.start_no_wrap()
794 else:
795 self.end_no_wrap()
797 def start_no_wrap(self):
798 """
799 Start a no-wrap region.
801 String parts within no-wrap regions are not wrapped on spaces; they can be
802 hard-wrapped if ``break_long_nowrap_words`` is :data:`True`. Whitespaces and
803 newlines in no-wrap regions are preserved regardless of ``preserve_spaces``
804 and ``preserve_newlines`` settings.
806 """
808 if self._has_no_wrap:
809 return
811 self._has_no_wrap = True
812 self._parts.append(NO_WRAP_START)
814 def end_no_wrap(self):
815 """
816 End a no-wrap region.
818 """
820 if not self._has_no_wrap:
821 return
823 if self._parts and self._parts[-1] is NO_WRAP_START:
824 # Empty no-wrap sequence, just remove it.
825 self._parts.pop()
826 else:
827 self._parts.append(NO_WRAP_END)
829 self._has_no_wrap = False
831 def extend(
832 self,
833 parts: _t.Iterable[str | ColorizedString | _Color | NoWrapMarker],
834 /,
835 ):
836 """
837 Extend string from iterable of raw parts.
839 :param parts:
840 raw parts that will be appended to the string.
842 """
844 for part in parts:
845 self += part
847 def copy(self) -> ColorizedString:
848 """
849 Copy this string.
851 :returns:
852 copy of the string.
854 """
856 return ColorizedString(self)
858 def _split_at(self, i: int, /) -> tuple[ColorizedString, ColorizedString]:
859 l, r = ColorizedString(), ColorizedString()
860 l.extend(self._parts[:i])
861 r._active_color = l._active_color
862 r._has_no_wrap = l._has_no_wrap
863 r.extend(self._parts[i:])
864 r._active_color = self._active_color
865 return l, r
867 def with_base_color(self, base_color: _Color) -> ColorizedString:
868 """
869 Apply the given color "under" all parts of this string. That is, all colors
870 in this string will be combined with this color on the left:
871 ``base_color | color``.
873 :param base_color:
874 color that will be added under the string.
875 :returns:
876 new string with changed colors, or current string if base color
877 is :attr:`~yuio.color.Color.NONE`.
878 :example:
879 ::
881 >>> s1 = yuio.string.ColorizedString([
882 ... "part 1",
883 ... yuio.color.Color.FORE_GREEN,
884 ... "part 2",
885 ... ])
886 >>> s2 = s1.with_base_color(
887 ... yuio.color.Color.FORE_RED
888 ... | yuio.color.Color.STYLE_BOLD
889 ... )
890 >>> s2 # doctest: +NORMALIZE_WHITESPACE
891 ColorizedString([<Color fore=<RED> bold=True>,
892 'part 1',
893 <Color fore=<GREEN> bold=True>,
894 'part 2'])
896 """
898 if base_color == _Color.NONE:
899 return self
901 res = ColorizedString()
903 for part in self._parts:
904 if isinstance(part, _Color):
905 res.append_color(base_color | part)
906 else:
907 res += part
908 res._active_color = base_color | self._active_color
909 if self._last_color is not None:
910 res._last_color = base_color | self._last_color
912 return res
914 def process_colors(self, color_support: yuio.color.ColorSupport, /) -> list[str]:
915 """
916 Convert colors in this string to ANSI escape sequences.
918 :param term:
919 terminal that will be used to print the resulting string.
920 :returns:
921 raw parts of colorized string with all colors converted to ANSI
922 escape sequences.
924 """
926 if color_support == yuio.color.ColorSupport.NONE:
927 return [part for part in self._parts if isinstance(part, str)]
928 else:
929 parts: list[str] = []
930 cur_link: Link | None = None
931 for part in self:
932 if isinstance(part, Link):
933 if not cur_link:
934 cur_link = part
935 elif cur_link.href == part.href:
936 cur_link += part
937 else:
938 parts.append(cur_link.as_code(color_support))
939 cur_link = part
940 continue
941 elif cur_link:
942 parts.append(cur_link.as_code(color_support))
943 cur_link = None
944 if isinstance(part, _Color):
945 parts.append(part.as_code(color_support))
946 elif isinstance(part, str):
947 parts.append(part)
948 if cur_link:
949 parts.append(cur_link)
950 if self._last_color != _Color.NONE:
951 parts.append(_Color.NONE.as_code(color_support))
952 return parts
954 def wrap(
955 self,
956 width: int,
957 /,
958 *,
959 preserve_spaces: bool = False,
960 preserve_newlines: bool = True,
961 break_long_words: bool = True,
962 break_long_nowrap_words: bool = False,
963 overflow: _t.Literal[False] | str = False,
964 indent: AnyString | int = "",
965 continuation_indent: AnyString | int | None = None,
966 ) -> list[ColorizedString]:
967 """
968 Wrap a long line of text into multiple lines.
970 :param preserve_spaces:
971 if set to :data:`True`, all spaces are preserved.
972 Otherwise, consecutive spaces are collapsed into a single space.
974 Note that tabs always treated as a single whitespace.
975 :param preserve_newlines:
976 if set to :data:`True` (default), text is additionally wrapped
977 on newline sequences. When this happens, the newline sequence that wrapped
978 the line will be placed into :attr:`~ColorizedString.explicit_newline`.
980 If set to :data:`False`, newline sequences are treated as whitespaces.
982 .. list-table:: Whitespace sequences
983 :header-rows: 1
984 :stub-columns: 1
986 * - Sequence
987 - ``preserve_newlines``
988 - Result
989 * - ``\\n``, ``\\r\\n``, ``\\r``
990 - ``False``
991 - Treated as a single whitespace.
992 * - ``\\n``, ``\\r\\n``, ``\\r``
993 - ``True``
994 - Creates a new line.
995 * - ``\\v``, ``\\v\\n``, ``\\v\\r\\n``, ``\\v\\r``
996 - Any
997 - Always creates a new line.
999 :param break_long_words:
1000 if set to :data:`True` (default), words that don't fit into a single line
1001 will be split into multiple lines.
1002 :param break_long_nowrap_words:
1003 if set to :data:`True`, words in no-wrap regions that don't fit
1004 into a single line will be split into multiple lines.
1005 :param overflow:
1006 a symbol that will be added to a line if it doesn't fit the given width.
1007 Pass :data:`False` to keep the overflowing lines without modification.
1008 :param indent:
1009 a string that will be prepended before the first line.
1010 :param continuation_indent:
1011 a string that will be prepended before all subsequent lines.
1012 :returns:
1013 a list of individual lines without newline characters at the end.
1015 """
1017 return _TextWrapper(
1018 width,
1019 preserve_spaces=preserve_spaces,
1020 preserve_newlines=preserve_newlines,
1021 break_long_words=break_long_words,
1022 break_long_nowrap_words=break_long_nowrap_words,
1023 overflow=overflow,
1024 indent=indent,
1025 continuation_indent=continuation_indent,
1026 ).wrap(self)
1028 def indent(
1029 self,
1030 indent: AnyString | int = " ",
1031 continuation_indent: AnyString | int | None = None,
1032 ) -> ColorizedString:
1033 """
1034 Indent this string.
1036 :param indent:
1037 this will be prepended to the first line in the string.
1038 Defaults to two spaces.
1039 :param continuation_indent:
1040 this will be prepended to subsequent lines in the string.
1041 Defaults to ``indent``.
1042 :returns:
1043 indented string.
1045 """
1047 if isinstance(indent, int):
1048 indent = ColorizedString(" " * indent)
1049 else:
1050 indent = ColorizedString(indent)
1051 if continuation_indent is None:
1052 continuation_indent = indent
1053 elif isinstance(continuation_indent, int):
1054 continuation_indent = ColorizedString(" " * continuation_indent)
1055 else:
1056 continuation_indent = ColorizedString(continuation_indent)
1058 if not indent and not continuation_indent:
1059 return self
1061 res = ColorizedString()
1063 needs_indent = True
1064 for part in self._parts:
1065 if not isinstance(part, str) or isinstance(part, Esc):
1066 res += part
1067 continue
1069 for line in _split_keep_link(part, _WORDSEP_NL_RE):
1070 if not line:
1071 continue
1072 if needs_indent:
1073 res.append_colorized_str(indent)
1074 indent = continuation_indent
1075 res.append_str(line)
1076 needs_indent = line.endswith(("\n", "\r", "\v"))
1078 return res
1080 def percent_format(
1081 self, args: _t.Any, ctx: yuio.theme.Theme | ReprContext | None = None
1082 ) -> ColorizedString:
1083 """
1084 Format colorized string as if with ``%``-formatting
1085 (i.e. `printf-style formatting`__).
1087 __ https://docs.python.org/3/library/stdtypes.html#printf-style-string-formatting
1089 :param args:
1090 arguments for formatting. Can be either a tuple of a mapping. Any other
1091 value will be converted to a tuple of one element.
1092 :param ctx:
1093 :class:`ReprContext` or theme that will be passed to ``__colorized_str__``
1094 and ``__colorized_repr__``. If not given, uses theme
1095 from :func:`yuio.io.get_theme`.
1096 :returns:
1097 formatted string.
1098 :raises:
1099 :class:`TypeError`, :class:`ValueError`, :class:`KeyError` if formatting
1100 fails.
1102 """
1104 return _percent_format(self, args, ctx)
1106 def __len__(self) -> int:
1107 return self.len
1109 def __bool__(self) -> bool:
1110 return self.len > 0
1112 def __iter__(self) -> _t.Iterator[_Color | NoWrapMarker | str]:
1113 return self._parts.__iter__()
1115 def __add__(self, rhs: AnyString) -> ColorizedString:
1116 copy = self.copy()
1117 copy += rhs
1118 return copy
1120 def __radd__(self, lhs: AnyString) -> ColorizedString:
1121 copy = ColorizedString(lhs)
1122 copy += self
1123 return copy
1125 def __iadd__(self, rhs: AnyString) -> ColorizedString:
1126 if isinstance(rhs, str):
1127 self.append_str(rhs)
1128 elif isinstance(rhs, ColorizedString):
1129 self.append_colorized_str(rhs)
1130 elif isinstance(rhs, _Color):
1131 self.append_color(rhs)
1132 elif rhs in (NO_WRAP_START, NO_WRAP_END):
1133 self.append_no_wrap(rhs)
1134 else:
1135 self.extend(rhs)
1137 return self
1139 def __eq__(self, value: object) -> bool:
1140 if isinstance(value, ColorizedString):
1141 return self._parts == value._parts
1142 else:
1143 return NotImplemented
1145 def __ne__(self, value: object) -> bool:
1146 return not (self == value)
1148 def __rich_repr__(self) -> RichReprResult:
1149 yield None, self._parts
1150 yield "explicit_newline", self._explicit_newline, ""
1152 def __str__(self) -> str:
1153 return "".join(c for c in self._parts if isinstance(c, str))
1155 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
1156 return self
1159AnyString: _t.TypeAlias = (
1160 str
1161 | ColorizedString
1162 | _Color
1163 | NoWrapMarker
1164 | _t.Iterable[str | ColorizedString | _Color | NoWrapMarker]
1165)
1166"""
1167Any string (i.e. a :class:`str`, a raw colorized string, or a normal colorized string).
1169"""
1172_S_SYNTAX = re.compile(
1173 r"""
1174 % # Percent
1175 (?:\((?P<mapping>[^)]*)\))? # Mapping key
1176 (?P<flag>[#0\-+ ]*) # Conversion Flag
1177 (?P<width>\*|\d+)? # Field width
1178 (?:\.(?P<precision>\*|\d*))? # Precision
1179 [hlL]? # Unused length modifier
1180 (?P<format>.) # Conversion type
1181 """,
1182 re.VERBOSE,
1183)
1186def _percent_format(
1187 s: ColorizedString, args: object, ctx: yuio.theme.Theme | ReprContext | None
1188) -> ColorizedString:
1189 if ctx is None:
1190 import yuio.io
1192 ctx = yuio.io.get_theme()
1193 if not isinstance(ctx, ReprContext):
1194 ctx = ReprContext(theme=ctx)
1195 seen_mapping = False
1196 arg_index = 0
1197 res = ColorizedString()
1198 for part in s:
1199 if isinstance(part, str):
1200 pos = 0
1201 for match in _S_SYNTAX.finditer(part):
1202 if pos < match.start():
1203 res.append_str(part[pos : match.start()])
1204 seen_mapping = seen_mapping or bool(match.group("mapping"))
1205 last_color = res.active_color
1206 arg_index, replaced = _percent_format_repl(
1207 match, args, arg_index, last_color, ctx
1208 )
1209 res += replaced
1210 res.append_color(last_color)
1211 pos = match.end()
1212 if pos < len(part):
1213 res.append_str(part[pos:])
1214 else:
1215 res += part
1217 if (isinstance(args, tuple) and arg_index < len(args)) or (
1218 not isinstance(args, tuple)
1219 and (
1220 not hasattr(args, "__getitem__")
1221 or isinstance(args, (str, bytes, bytearray))
1222 )
1223 and not seen_mapping
1224 and not arg_index
1225 ):
1226 raise TypeError("not all arguments converted during string formatting")
1228 return res
1231def _percent_format_repl(
1232 match: _t.StrReMatch,
1233 args: object,
1234 arg_index: int,
1235 base_color: _Color,
1236 ctx: ReprContext,
1237) -> tuple[int, str | ColorizedString]:
1238 if match.group("format") == "%":
1239 if match.group(0) != "%%":
1240 raise ValueError("unsupported format character '%'")
1241 return arg_index, "%"
1243 if match.group("format") in "rs":
1244 return _percent_format_repl_str(match, args, arg_index, base_color, ctx)
1246 if mapping := match.group("mapping"):
1247 try:
1248 fmt_arg = args[mapping] # type: ignore
1249 except TypeError:
1250 raise TypeError("format requires a mapping") from None
1251 fmt_arg, added_color = _unwrap_base_color(fmt_arg, ctx.theme)
1252 if added_color:
1253 fmt_args = {mapping: fmt_arg}
1254 else:
1255 fmt_args = args
1256 elif isinstance(args, tuple):
1257 try:
1258 fmt_arg = args[arg_index]
1259 except IndexError:
1260 raise TypeError("not enough arguments for format string")
1261 fmt_arg, added_color = _unwrap_base_color(fmt_arg, ctx.theme)
1262 begin = arg_index + 1
1263 end = arg_index = (
1264 arg_index
1265 + 1
1266 + (match.group("width") == "*")
1267 + (match.group("precision") == "*")
1268 )
1269 fmt_args = (fmt_arg,) + args[begin:end]
1270 elif arg_index == 0:
1271 fmt_args, added_color = _unwrap_base_color(args, ctx.theme)
1272 arg_index += 1
1273 else:
1274 raise TypeError("not enough arguments for format string")
1276 fmt = match.group(0) % fmt_args
1277 if added_color:
1278 added_color = ctx.theme.to_color(added_color)
1279 fmt = ColorizedString([base_color | added_color, fmt])
1280 return arg_index, fmt
1283def _unwrap_base_color(x, theme: yuio.theme.Theme):
1284 color = None
1285 while isinstance(x, WithBaseColor):
1286 x, base_color = x._msg, x._base_color
1287 base_color = theme.to_color(base_color)
1288 if color:
1289 color = color | base_color
1290 else:
1291 color = base_color
1292 else:
1293 return x, color
1296def _percent_format_repl_str(
1297 match: _t.StrReMatch,
1298 args: object,
1299 arg_index: int,
1300 base_color: _Color,
1301 ctx: ReprContext,
1302) -> tuple[int, str | ColorizedString]:
1303 if width_s := match.group("width"):
1304 if width_s == "*":
1305 if not isinstance(args, tuple):
1306 raise TypeError("* wants int")
1307 try:
1308 width = args[arg_index]
1309 arg_index += 1
1310 except (KeyError, IndexError):
1311 raise TypeError("not enough arguments for format string")
1312 if not isinstance(width, int):
1313 raise TypeError("* wants int")
1314 else:
1315 width = int(width_s)
1316 else:
1317 width = None
1319 if precision_s := match.group("precision"):
1320 if precision_s == "*":
1321 if not isinstance(args, tuple):
1322 raise TypeError("* wants int")
1323 try:
1324 precision = args[arg_index]
1325 arg_index += 1
1326 except (KeyError, IndexError):
1327 raise TypeError("not enough arguments for format string")
1328 if not isinstance(precision, int):
1329 raise TypeError("* wants int")
1330 else:
1331 precision = int(precision_s)
1332 else:
1333 precision = None
1335 if mapping := match.group("mapping"):
1336 try:
1337 fmt_arg = args[mapping] # type: ignore
1338 except TypeError:
1339 raise TypeError("format requires a mapping") from None
1340 elif isinstance(args, tuple):
1341 try:
1342 fmt_arg = args[arg_index]
1343 arg_index += 1
1344 except IndexError:
1345 raise TypeError("not enough arguments for format string") from None
1346 elif arg_index == 0:
1347 fmt_arg = args
1348 arg_index += 1
1349 else:
1350 raise TypeError("not enough arguments for format string")
1352 flag = match.group("flag")
1353 multiline = "+" in flag
1354 highlighted = "#" in flag
1355 if match.group("format") == "r":
1356 res = ctx.repr(fmt_arg, multiline=multiline, highlighted=highlighted)
1357 else:
1358 res = ctx.str(fmt_arg, multiline=multiline, highlighted=highlighted)
1360 if precision is not None and res.width > precision:
1361 cut = ColorizedString()
1362 for part in res:
1363 if precision <= 0:
1364 break
1365 if isinstance(part, str):
1366 part_width = line_width(part)
1367 if part_width <= precision:
1368 cut.append_str(part)
1369 precision -= part_width
1370 elif part.isascii():
1371 cut.append_str(part[:precision])
1372 break
1373 else:
1374 for j, ch in enumerate(part):
1375 precision -= line_width(ch)
1376 if precision == 0:
1377 cut.append_str(part[: j + 1])
1378 break
1379 elif precision < 0:
1380 cut.append_str(part[:j])
1381 cut.append_str(" ")
1382 break
1383 break
1384 else:
1385 cut += part
1386 res = cut
1388 if width is not None:
1389 spacing = " " * (abs(width) - res.width)
1390 if spacing:
1391 if match.group("flag") == "-" or width < 0:
1392 res = res + spacing
1393 else:
1394 res = spacing + res
1396 return arg_index, res.with_base_color(base_color)
1399__TAG_RE = re.compile(
1400 r"""
1401 <c (?P<tag_open>[a-z0-9 _/@:-]+)> # _Color tag open.
1402 | </c> # _Color tag close.
1403 | \\(?P<punct>[%(punct)s]) # Escape character.
1404 | (?<!`)(`+)(?!`)(?P<code>.*?)(?<!`)\3(?!`) # Inline code block (backticks).
1405 """
1406 % {"punct": re.escape(string.punctuation)},
1407 re.VERBOSE | re.MULTILINE,
1408)
1409__NEG_NUM_RE = re.compile(r"^-(0x[0-9a-fA-F]+|0b[01]+|\d+(e[+-]?\d+)?)$")
1410__FLAG_RE = re.compile(r"^-[-a-zA-Z0-9_]*$")
1413def colorize(
1414 line: str,
1415 /,
1416 *args: _t.Any,
1417 ctx: yuio.theme.Theme | ReprContext | None = None,
1418 default_color: _Color | str = _Color.NONE,
1419 parse_cli_flags_in_backticks: bool = False,
1420) -> ColorizedString:
1421 """
1422 Parse color tags and produce a colorized string.
1424 Apply ``default_color`` to the entire paragraph, and process color tags
1425 and backticks within it.
1427 :param line:
1428 text to colorize.
1429 :param args:
1430 if given, string will be ``%``-formatted after parsing.
1431 :param ctx:
1432 :class:`ReprContext` or theme that will be used to look up color tags.
1433 If not given, uses theme from :func:`yuio.io.get_theme`.
1434 :param default_color:
1435 color or color tag to apply to the entire text.
1436 :returns:
1437 a colorized string.
1439 """
1441 if ctx is None:
1442 import yuio.io
1444 ctx = yuio.io.get_theme()
1445 if not isinstance(ctx, ReprContext):
1446 ctx = ReprContext(theme=ctx)
1448 default_color = ctx.theme.to_color(default_color)
1450 res = ColorizedString(default_color)
1452 stack = [default_color]
1454 last_pos = 0
1455 for tag in __TAG_RE.finditer(line):
1456 res.append_str(line[last_pos : tag.start()])
1457 last_pos = tag.end()
1459 if name := tag.group("tag_open"):
1460 color = stack[-1] | ctx.theme.get_color(name)
1461 res.append_color(color)
1462 stack.append(color)
1463 elif code := tag.group("code"):
1464 code = code.replace("\n", " ")
1465 if code.startswith(" ") and code.endswith(" ") and not code.isspace():
1466 code = code[1:-1]
1467 if (
1468 parse_cli_flags_in_backticks
1469 and __FLAG_RE.match(code)
1470 and not __NEG_NUM_RE.match(code)
1471 ):
1472 res.append_color(stack[-1] | ctx.theme.get_color("flag"))
1473 else:
1474 res.append_color(stack[-1] | ctx.theme.get_color("code"))
1475 res.start_no_wrap()
1476 res.append_str(code)
1477 res.end_no_wrap()
1478 res.append_color(stack[-1])
1479 elif punct := tag.group("punct"):
1480 res.append_str(punct)
1481 elif len(stack) > 1:
1482 stack.pop()
1483 res.append_color(stack[-1])
1485 res.append_str(line[last_pos:])
1487 if args:
1488 return res.percent_format(args, ctx)
1489 else:
1490 return res
1493def strip_color_tags(s: str) -> str:
1494 """
1495 Remove all color tags from a string.
1497 """
1499 raw: list[str] = []
1501 last_pos = 0
1502 for tag in __TAG_RE.finditer(s):
1503 raw.append(s[last_pos : tag.start()])
1504 last_pos = tag.end()
1506 if code := tag.group("code"):
1507 code = code.replace("\n", " ")
1508 if code.startswith(" ") and code.endswith(" ") and not code.isspace():
1509 code = code[1:-1]
1510 raw.append(code)
1511 elif punct := tag.group("punct"):
1512 raw.append(punct)
1514 raw.append(s[last_pos:])
1516 return "".join(raw)
1519class Esc(_UserString):
1520 """
1521 A string that can't be broken during word wrapping even
1522 if ``break_long_nowrap_words`` is :data:`True`.
1524 """
1526 __slots__ = ()
1529class Link(_UserString):
1530 """
1531 A :class:`str` wrapper with an attached hyperlink.
1533 """
1535 __slots__ = ("__href",)
1537 def __new__(cls, *args, href: str, **kwargs):
1538 res = super().__new__(cls, *args, **kwargs)
1539 res.__href = href
1540 return res
1542 @property
1543 def href(self):
1544 """
1545 Target link.
1547 """
1549 return self.__href
1551 def as_code(self, color_support: yuio.color.ColorSupport):
1552 """
1553 Convert this link into an ANSI escape code with respect to the given
1554 terminal capabilities.
1556 :param color_support:
1557 level of color support of a terminal.
1558 :returns:
1559 either ANSI escape code for this color or an empty string.
1561 """
1563 if color_support < yuio.color.ColorSupport.ANSI_TRUE:
1564 return ""
1565 else:
1566 return f"\x1b]8;;{self.__href}\x1b\\{self}\x1b]8;;\x1b\\"
1568 def _wrap(self, data: str):
1569 return self.__class__(data, href=self.__href)
1571 def __repr__(self) -> str:
1572 return f"Link({super().__repr__()}, href={self.href!r})"
1574 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
1575 return ColorizedString(self)
1578def _split_keep_link(s: str, r: _t.StrRePattern):
1579 if isinstance(s, Link):
1580 href = s.href
1581 ctor = lambda x: Link(x, href=href)
1582 else:
1583 ctor = s.__class__
1584 return [ctor(part) for part in r.split(s)]
1587_SPACE_TRANS = str.maketrans("\r\n\t\v\b\f", " ")
1589_WORD_PUNCT = r'[\w!"\'&.,?]'
1590_LETTER = r"[^\d\W]"
1591_NOWHITESPACE = r"[^ \r\n\t\v\b\f]"
1593# Copied from textwrap with some modifications in newline handling
1594_WORDSEP_RE = re.compile(
1595 r"""
1596 ( # newlines and line feeds are matched one-by-one
1597 (?:\r\n|\r|\n|\v\r\n|\v\r|\v\n|\v)
1598 | # any whitespace
1599 [ \t\b\f]+
1600 | # em-dash between words
1601 (?<=%(wp)s) -{2,} (?=\w)
1602 | # word, possibly hyphenated
1603 %(nws)s+? (?:
1604 # hyphenated word
1605 -(?: (?<=%(lt)s{2}-) | (?<=%(lt)s-%(lt)s-))
1606 (?= %(lt)s -? %(lt)s)
1607 | # end of word
1608 (?=[ \r\n\t\v\b\f]|\Z)
1609 | # em-dash
1610 (?<=%(wp)s) (?=-{2,}\w)
1611 )
1612 )"""
1613 % {"wp": _WORD_PUNCT, "lt": _LETTER, "nws": _NOWHITESPACE},
1614 re.VERBOSE,
1615)
1616_WORDSEP_NL_RE = re.compile(r"(\r\n|\r|\n|\v\r\n|\v\r|\v\n|\v)")
1619class _TextWrapper:
1620 def __init__(
1621 self,
1622 width: float,
1623 /,
1624 *,
1625 preserve_spaces: bool,
1626 preserve_newlines: bool,
1627 break_long_words: bool,
1628 break_long_nowrap_words: bool,
1629 overflow: _t.Literal[False] | str,
1630 indent: AnyString | int,
1631 continuation_indent: AnyString | int | None,
1632 ):
1633 self.width: float = width # Actual type is `int | +inf`.
1634 self.preserve_spaces: bool = preserve_spaces
1635 self.preserve_newlines: bool = preserve_newlines
1636 self.break_long_words: bool = break_long_words
1637 self.break_long_nowrap_words: bool = break_long_nowrap_words
1638 self.overflow: _t.Literal[False] | str = overflow
1640 if isinstance(indent, int):
1641 self.indent = ColorizedString(" " * indent)
1642 else:
1643 self.indent = ColorizedString(indent)
1644 if continuation_indent is None:
1645 self.continuation_indent = self.indent
1646 elif isinstance(continuation_indent, int):
1647 self.continuation_indent = ColorizedString(" " * continuation_indent)
1648 else:
1649 self.continuation_indent = ColorizedString(continuation_indent)
1651 self.lines: list[ColorizedString] = []
1653 self.current_line = ColorizedString()
1654 if self.indent:
1655 self.current_line += self.indent
1656 self.current_line_width: int = self.indent.width
1657 self.at_line_start: bool = True
1658 self.has_ellipsis: bool = False
1659 self.needs_space_before_word = False
1660 self.space_before_word_href = None
1662 self.nowrap_start_index = None
1663 self.nowrap_start_width = 0
1664 self.nowrap_start_added_space = False
1666 def _flush_line(self, explicit_newline=""):
1667 self.current_line._explicit_newline = explicit_newline
1668 self.lines.append(self.current_line)
1670 self.current_line = ColorizedString(self.current_line.active_color)
1672 if self.continuation_indent:
1673 self.current_line += self.continuation_indent
1675 self.current_line_width: int = self.continuation_indent.width
1676 self.at_line_start = True
1677 self.has_ellipsis = False
1678 self.nowrap_start_index = None
1679 self.nowrap_start_width = 0
1680 self.nowrap_start_added_space = False
1681 self.needs_space_before_word = False
1682 self.space_before_word_href = None
1684 def _flush_line_part(self):
1685 assert self.nowrap_start_index is not None
1686 self.current_line, tail = self.current_line._split_at(self.nowrap_start_index)
1687 tail_width = self.current_line_width - self.nowrap_start_width
1688 if (
1689 self.nowrap_start_added_space
1690 and self.current_line._parts
1691 and self.current_line._parts[-1] == " "
1692 ):
1693 # Remove space that was added before no-wrap sequence.
1694 self.current_line._parts.pop()
1695 self._flush_line()
1696 self.current_line += tail
1697 self.current_line.append_color(tail.active_color)
1698 self.current_line_width += tail_width
1700 def _append_word(self, word: str, word_width: int):
1701 if (
1702 self.overflow is not False
1703 and self.current_line_width + word_width > self.width
1704 ):
1705 if isinstance(word, Esc):
1706 if self.overflow:
1707 self._add_ellipsis()
1708 return
1710 word_head_len = word_head_width = 0
1712 for c in word:
1713 c_width = line_width(c)
1714 if self.current_line_width + word_head_width + c_width > self.width:
1715 break
1716 word_head_len += 1
1717 word_head_width += c_width
1719 if word_head_len:
1720 self.current_line.append_str(word[:word_head_len])
1721 self.at_line_start = False
1722 self.has_ellipsis = False
1723 self.current_line_width += word_head_width
1725 if self.overflow:
1726 self._add_ellipsis()
1727 else:
1728 self.current_line.append_str(word)
1729 self.current_line_width += word_width
1730 self.has_ellipsis = False
1731 self.at_line_start = False
1733 def _append_space(self):
1734 if self.needs_space_before_word:
1735 word = " "
1736 if self.space_before_word_href:
1737 word = Link(word, href=self.space_before_word_href)
1738 self._append_word(word, 1)
1739 self.needs_space_before_word = False
1740 self.space_before_word_href = None
1742 def _add_ellipsis(self):
1743 if self.has_ellipsis:
1744 # Already has an ellipsis.
1745 return
1747 if self.current_line_width + 1 <= self.width:
1748 # There's enough space on this line to add new ellipsis.
1749 self.current_line.append_str(str(self.overflow))
1750 self.current_line_width += 1
1751 self.at_line_start = False
1752 self.has_ellipsis = True
1753 elif not self.at_line_start:
1754 # Modify last word on this line, if there is any.
1755 parts = self.current_line._parts
1756 for i in range(len(parts) - 1, -1, -1):
1757 part = parts[i]
1758 if isinstance(part, str):
1759 if not isinstance(part, (Esc, Link)):
1760 parts[i] = f"{part[:-1]}{self.overflow}"
1761 self.has_ellipsis = True
1762 return
1764 def _append_word_with_breaks(self, word: str, word_width: int):
1765 while self.current_line_width + word_width > self.width:
1766 word_head_len = word_head_width = 0
1768 for c in word:
1769 c_width = line_width(c)
1770 if self.current_line_width + word_head_width + c_width > self.width:
1771 break
1772 word_head_len += 1
1773 word_head_width += c_width
1775 if self.at_line_start and not word_head_len:
1776 if self.overflow:
1777 return
1778 else:
1779 word_head_len = 1
1780 word_head_width += line_width(word[:1])
1782 self._append_word(word[:word_head_len], word_head_width)
1784 word = word[word_head_len:]
1785 word_width -= word_head_width
1787 self._flush_line()
1789 if word:
1790 self._append_word(word, word_width)
1792 def wrap(self, text: ColorizedString) -> list[ColorizedString]:
1793 nowrap = False
1795 for part in text:
1796 if isinstance(part, _Color):
1797 if (
1798 self.needs_space_before_word
1799 and self.current_line_width + self.needs_space_before_word
1800 < self.width
1801 ):
1802 # Make sure any whitespace that was added before color
1803 # is flushed. If it doesn't fit, we just forget it: the line
1804 # will be wrapped soon anyways.
1805 self._append_space()
1806 self.needs_space_before_word = False
1807 self.space_before_word_href = None
1808 self.current_line.append_color(part)
1809 continue
1810 elif part is NO_WRAP_START:
1811 if nowrap: # pragma: no cover
1812 continue
1813 if (
1814 self.needs_space_before_word
1815 and self.current_line_width + self.needs_space_before_word
1816 < self.width
1817 ):
1818 # Make sure any whitespace that was added before no-wrap
1819 # is flushed. If it doesn't fit, we just forget it: the line
1820 # will be wrapped soon anyways.
1821 self._append_space()
1822 self.nowrap_start_added_space = True
1823 else:
1824 self.nowrap_start_added_space = False
1825 self.needs_space_before_word = False
1826 self.space_before_word_href = None
1827 if self.at_line_start:
1828 self.nowrap_start_index = None
1829 self.nowrap_start_width = 0
1830 else:
1831 self.nowrap_start_index = len(self.current_line._parts)
1832 self.nowrap_start_width = self.current_line_width
1833 nowrap = True
1834 continue
1835 elif part is NO_WRAP_END:
1836 nowrap = False
1837 self.nowrap_start_index = None
1838 self.nowrap_start_width = 0
1839 self.nowrap_start_added_space = False
1840 continue
1842 esc = False
1843 if isinstance(part, Esc):
1844 words = [Esc(part.translate(_SPACE_TRANS))]
1845 esc = True
1846 elif nowrap:
1847 words = _split_keep_link(part, _WORDSEP_NL_RE)
1848 else:
1849 words = _split_keep_link(part, _WORDSEP_RE)
1851 for word in words:
1852 if not word:
1853 # `_WORDSEP_RE` produces empty strings, skip them.
1854 continue
1856 if word.startswith(("\v", "\r", "\n")):
1857 # `_WORDSEP_RE` yields one newline sequence at a time, we don't
1858 # need to split the word further.
1859 if nowrap or self.preserve_newlines or word.startswith("\v"):
1860 self._flush_line(explicit_newline=word)
1861 continue
1862 else:
1863 # Treat any newline sequence as a single space.
1864 word = " "
1866 isspace = not esc and word.isspace()
1867 if isspace:
1868 if (
1869 # Spaces are preserved in no-wrap sequences.
1870 nowrap
1871 # Spaces are explicitly preserved.
1872 or self.preserve_spaces
1873 # We preserve indentation even if `preserve_spaces` is `False`.
1874 # We need to check that the previous line ended with an
1875 # explicit newline, otherwise this is not an indent.
1876 or (
1877 self.at_line_start
1878 and (not self.lines or self.lines[-1].explicit_newline)
1879 )
1880 ):
1881 word = word.translate(_SPACE_TRANS)
1882 else:
1883 self.needs_space_before_word = True
1884 self.space_before_word_href = (
1885 word.href if isinstance(word, Link) else None
1886 )
1887 continue
1889 word_width = line_width(word)
1891 if self._try_fit_word(word, word_width):
1892 # Word fits onto the current line.
1893 continue
1895 if self.nowrap_start_index is not None:
1896 # Move the entire no-wrap sequence onto the new line.
1897 self._flush_line_part()
1899 if self._try_fit_word(word, word_width):
1900 # Word fits onto the current line after we've moved
1901 # no-wrap sequence. Nothing more to do.
1902 continue
1904 if (
1905 not self.at_line_start
1906 and (
1907 # Spaces can be broken anywhere, so we don't break line
1908 # for them: `_append_word_with_breaks` will do it for us.
1909 # Note: `esc` implies `not isspace`, so all `esc` words
1910 # outside of no-wrap sequences are handled by this check.
1911 (not nowrap and not isspace)
1912 # No-wrap sequences are broken in the middle of any word,
1913 # so we don't need any special handling for them
1914 # (again, `_append_word_with_breaks` will do breaking for us).
1915 # An exception is `esc` words which can't be broken in the middle;
1916 # if the break is possible at all, it must happen here.
1917 or (nowrap and esc and self.break_long_nowrap_words)
1918 )
1919 and not (
1920 # This is an esc word which wouldn't fit onto this line, nor onto
1921 # the next line, and there's enough space for an ellipsis
1922 # on this line (or it already has one). We don't need to break
1923 # the line here: this word will be passed to `_append_word`,
1924 # which will handle ellipsis for us.
1925 self.overflow is not False
1926 and esc
1927 and self.continuation_indent.width + word_width > self.width
1928 and (
1929 self.has_ellipsis
1930 or self.current_line_width
1931 + self.needs_space_before_word
1932 + 1
1933 <= self.width
1934 )
1935 )
1936 ):
1937 # Flush a non-empty line.
1938 self._flush_line()
1940 # Note: `need_space_before_word` is always `False` at this point.
1941 # `need_space_before_word` becomes `True` only when current line
1942 # is non-empty, we're not in no-wrap sequence, and `preserve_spaces`
1943 # is `False` (meaning `isspace` is also `False`). In such situation,
1944 # we flush the line in the condition above.
1945 if not esc and (
1946 (nowrap and self.break_long_nowrap_words)
1947 or (not nowrap and (self.break_long_words or isspace))
1948 ):
1949 # We will break the word in the middle if it doesn't fit.
1950 self._append_word_with_breaks(word, word_width)
1951 else:
1952 self._append_word(word, word_width)
1954 if self.current_line or not self.lines or self.lines[-1].explicit_newline:
1955 self._flush_line()
1957 return self.lines
1959 def _try_fit_word(self, word: str, word_width: int):
1960 if (
1961 self.current_line_width + word_width + self.needs_space_before_word
1962 <= self.width
1963 ):
1964 self._append_space()
1965 self._append_word(word, word_width)
1966 return True
1967 else:
1968 return False
1971class _ReprContextState(Enum):
1972 START = 0
1973 """
1974 Initial state.
1976 """
1978 CONTAINER_START = 1
1979 """
1980 Right after a token starting a container was pushed.
1982 """
1984 ITEM_START = 2
1985 """
1986 Right after a token separating container items was pushed.
1988 """
1990 NORMAL = 3
1991 """
1992 In the middle of a container element.
1994 """
1997@_t.final
1998class ReprContext:
1999 """
2000 Context object that tracks repr settings and ensures that recursive objects
2001 are handled properly.
2003 :param theme:
2004 theme will be passed to ``__colorized_repr__``.
2005 :param multiline:
2006 indicates that values rendered via `rich repr protocol`_
2007 should be split into multiple lines.
2008 :param highlighted:
2009 indicates that values rendered via `rich repr protocol`_
2010 or via built-in :func:`repr` should be highlighted according to python syntax.
2011 :param max_depth:
2012 maximum depth of nested containers, after which container's contents
2013 are not rendered.
2014 :param max_width:
2015 maximum width of the content, used when wrapping text or rendering markdown.
2017 .. _rich repr protocol: https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol
2019 """
2021 def __init__(
2022 self,
2023 *,
2024 theme: yuio.theme.Theme | None = None,
2025 multiline: bool = False,
2026 highlighted: bool = False,
2027 max_depth: int = 5,
2028 max_width: int | None = None,
2029 ):
2030 if theme is None:
2031 import yuio.io
2033 theme = yuio.io.get_theme()
2034 self._theme = theme
2035 self._multiline = multiline
2036 self._highlighted = highlighted
2037 self._max_depth = max_depth
2038 self._max_width = max(max_width or shutil.get_terminal_size().columns, 1)
2040 self._seen: set[int] = set()
2041 self._line = ColorizedString()
2042 self._indent = 0
2043 self._state = _ReprContextState.START
2044 self._pending_sep = None
2046 import yuio.md
2048 self._hl = yuio.md.SyntaxHighlighter.get_highlighter("repr")
2049 self._base_color = theme.get_color("msg/text:code/repr")
2051 @property
2052 def theme(self) -> yuio.theme.Theme:
2053 """
2054 Current theme.
2056 """
2058 return self._theme # pragma: no cover
2060 @property
2061 def multiline(self) -> bool:
2062 """
2063 Whether values rendered with ``repr`` are split into multiple lines.
2065 """
2067 return self._multiline # pragma: no cover
2069 @property
2070 def highlighted(self) -> bool:
2071 """
2072 Whether values rendered with ``repr`` are highlighted.
2074 """
2076 return self._highlighted # pragma: no cover
2078 @property
2079 def max_depth(self) -> int:
2080 """
2081 Maximum depth of nested containers, after which container's contents
2082 are not rendered.
2084 """
2086 return self._max_depth # pragma: no cover
2088 @property
2089 def max_width(self) -> int:
2090 """
2091 Maximum width of the content, used when wrapping text or rendering markdown.
2093 """
2095 return self._max_width # pragma: no cover
2097 def _flush_sep(self):
2098 if self._pending_sep is not None:
2099 self._push_color("punct")
2100 self._line.append_str(self._pending_sep)
2101 self._pending_sep = None
2103 def _flush_line(self):
2104 if self._multiline:
2105 self._line.append_color(_Color.NONE)
2106 self._line.append_str("\n")
2107 if self._indent:
2108 self._line.append_str(" " * self._indent)
2110 def _push_color(self, tag: str):
2111 if self._highlighted:
2112 self._line.append_color(
2113 self._base_color | self._theme.to_color(f"hl/{tag}:repr")
2114 )
2116 def _push_token(self, content: str, tag: str):
2117 self._flush_sep()
2119 if self._state in [
2120 _ReprContextState.CONTAINER_START,
2121 _ReprContextState.ITEM_START,
2122 ]:
2123 self._flush_line()
2125 self._push_color(tag)
2126 self._line.append_str(content)
2128 self._state = _ReprContextState.NORMAL
2130 def _terminate_item(self, sep: str = ", "):
2131 self._flush_sep()
2132 self._pending_sep = sep
2133 self._state = _ReprContextState.ITEM_START
2135 def _start_container(self):
2136 self._state = _ReprContextState.CONTAINER_START
2137 self._indent += 1
2139 def _end_container(self):
2140 self._indent -= 1
2142 if self._state in [_ReprContextState.NORMAL, _ReprContextState.ITEM_START]:
2143 self._flush_line()
2145 self._state = _ReprContextState.NORMAL
2146 self._pending_sep = None
2148 def repr(
2149 self,
2150 value: _t.Any,
2151 /,
2152 *,
2153 multiline: bool | None = None,
2154 highlighted: bool | None = None,
2155 max_width: int | None = None,
2156 ) -> ColorizedString:
2157 """
2158 Convert value to colorized string using repr methods.
2160 :param value:
2161 value to be rendered.
2162 :param multiline:
2163 if given, overrides settings passed to :func:`colorized_str` for this call.
2164 :param highlighted:
2165 if given, overrides settings passed to :func:`colorized_str` for this call.
2166 :param max_width:
2167 if given, overrides settings passed to :func:`colorized_str` for this call.
2168 :returns:
2169 a colorized string containing representation of the ``value``.
2170 :raises:
2171 this method does not raise any errors. If any inner object raises an
2172 exception, this function returns a colorized string with
2173 an error description.
2175 """
2177 return self._print(
2178 value,
2179 multiline=multiline,
2180 highlighted=highlighted,
2181 use_str=False,
2182 max_width=max_width,
2183 )
2185 def str(
2186 self,
2187 value: _t.Any,
2188 /,
2189 *,
2190 multiline: bool | None = None,
2191 highlighted: bool | None = None,
2192 max_width: int | None = None,
2193 ) -> ColorizedString:
2194 """
2195 Convert value to colorized string.
2197 :param value:
2198 value to be rendered.
2199 :param multiline:
2200 if given, overrides settings passed to :func:`colorized_str` for this call.
2201 :param highlighted:
2202 if given, overrides settings passed to :func:`colorized_str` for this call.
2203 :param max_width:
2204 if given, overrides settings passed to :func:`colorized_str` for this call.
2205 :returns:
2206 a colorized string containing string representation of the ``value``.
2207 :raises:
2208 this method does not raise any errors. If any inner object raises an
2209 exception, this function returns a colorized string with
2210 an error description.
2212 """
2214 return self._print(
2215 value,
2216 multiline=multiline,
2217 highlighted=highlighted,
2218 use_str=True,
2219 max_width=max_width,
2220 )
2222 def hl(
2223 self,
2224 value: str,
2225 /,
2226 *,
2227 highlighted: bool | None = None,
2228 ) -> ColorizedString:
2229 """
2230 Highlight result of :func:`repr`.
2232 :meth:`ReprContext.repr` does this automatically, but sometimes you need
2233 to highlight a string without :func:`repr`-ing it one more time.
2235 :param value:
2236 result of :func:`repr` that needs highlighting.
2237 :returns:
2238 highlighted string.
2240 """
2242 highlighted = highlighted if highlighted is not None else self._highlighted
2244 if highlighted:
2245 return self._hl.highlight(
2246 self._theme, value, default_color=self._base_color
2247 )
2248 else:
2249 return ColorizedString(value)
2251 def _print(
2252 self,
2253 value: _t.Any,
2254 multiline: bool | None,
2255 highlighted: bool | None,
2256 max_width: int | None,
2257 use_str: bool,
2258 ) -> ColorizedString:
2259 old_line, self._line = self._line, ColorizedString()
2260 old_state, self._state = self._state, _ReprContextState.START
2261 old_pending_sep, self._pending_sep = self._pending_sep, None
2262 old_multiline, self._multiline = (
2263 self._multiline,
2264 (self._multiline if multiline is None else multiline),
2265 )
2266 old_highlighted, self._highlighted = (
2267 self._highlighted,
2268 (self._highlighted if highlighted is None else highlighted),
2269 )
2270 old_max_width, self._max_width = (
2271 self._max_width,
2272 (self._max_width if max_width is None else max_width),
2273 )
2275 try:
2276 self._print_nested(value, use_str)
2277 return self._line
2278 except Exception as e:
2279 yuio._logger.exception("error in repr context")
2280 res = ColorizedString()
2281 res.append_color(_Color.STYLE_INVERSE | _Color.FORE_RED)
2282 res.append_str(f"{_t.type_repr(type(e))}: {e}")
2283 return res
2284 finally:
2285 self._line = old_line
2286 self._state = old_state
2287 self._pending_sep = old_pending_sep
2288 self._multiline = old_multiline
2289 self._highlighted = old_highlighted
2290 self._max_width = old_max_width
2292 def _print_nested(self, value: _t.Any, use_str: bool = False):
2293 if id(value) in self._seen or self._indent > self._max_depth:
2294 self._push_token("...", "more")
2295 return
2296 self._seen.add(id(value))
2297 old_indent = self._indent
2298 try:
2299 if use_str:
2300 self._print_nested_as_str(value)
2301 else:
2302 self._print_nested_as_repr(value)
2303 finally:
2304 self._indent = old_indent
2305 self._seen.remove(id(value))
2307 def _print_nested_as_str(self, value):
2308 if isinstance(value, type):
2309 # This is a type.
2310 self._print_plain(value, convert=_t.type_repr)
2311 elif hasattr(value, "__colorized_str__"):
2312 # Has `__colorized_str__`.
2313 self._print_colorized_str(value)
2314 elif getattr(type(value), "__str__", None) is not object.__str__:
2315 # Has custom `__str__`.
2316 self._print_plain(value, convert=str, hl=False)
2317 else:
2318 # Has default `__str__` which falls back to `__repr__`.
2319 self._print_nested_as_repr(value)
2321 def _print_nested_as_repr(self, value):
2322 if isinstance(value, type):
2323 # This is a type.
2324 self._print_plain(value, convert=_t.type_repr)
2325 elif hasattr(value, "__colorized_repr__"):
2326 # Has `__colorized_repr__`.
2327 self._print_colorized_repr(value)
2328 elif hasattr(value, "__rich_repr__"):
2329 # Has `__rich_repr__`.
2330 self._print_rich_repr(value)
2331 elif isinstance(value, _CONTAINER_TYPES):
2332 # Is a known container.
2333 for ty, repr_fn in _CONTAINERS.items():
2334 if isinstance(value, ty):
2335 if getattr(type(value), "__repr__", None) is ty.__repr__:
2336 repr_fn(self, value) # type: ignore
2337 else:
2338 self._print_plain(value)
2339 break
2340 elif dataclasses.is_dataclass(value):
2341 # Is a dataclass.
2342 self._print_dataclass(value)
2343 else:
2344 # Fall back to regular `__repr__`.
2345 self._print_plain(value)
2347 def _print_plain(self, value, convert=None, hl=True):
2348 convert = convert or repr
2350 self._flush_sep()
2352 if self._state in [
2353 _ReprContextState.CONTAINER_START,
2354 _ReprContextState.ITEM_START,
2355 ]:
2356 self._flush_line()
2358 if hl and self._highlighted:
2359 self._line += self._hl.highlight(
2360 self._theme, convert(value), default_color=self._base_color
2361 )
2362 else:
2363 self._line.append_str(convert(value))
2365 self._state = _ReprContextState.NORMAL
2367 def _print_list(self, name: str, obrace: str, cbrace: str, items):
2368 if name:
2369 self._push_token(name, "type")
2370 self._push_token(obrace, "punct")
2371 if self._indent >= self._max_depth:
2372 self._push_token("...", "more")
2373 else:
2374 self._start_container()
2375 for item in items:
2376 self._print_nested(item)
2377 self._terminate_item()
2378 self._end_container()
2379 self._push_token(cbrace, "punct")
2381 def _print_dict(self, name: str, obrace: str, cbrace: str, items):
2382 if name:
2383 self._push_token(name, "type")
2384 self._push_token(obrace, "punct")
2385 if self._indent >= self._max_depth:
2386 self._push_token("...", "more")
2387 else:
2388 self._start_container()
2389 for key, value in items:
2390 self._print_nested(key)
2391 self._push_token(": ", "punct")
2392 self._print_nested(value)
2393 self._terminate_item()
2394 self._end_container()
2395 self._push_token(cbrace, "punct")
2397 def _print_defaultdict(self, value: collections.defaultdict[_t.Any, _t.Any]):
2398 self._push_token("defaultdict", "type")
2399 self._push_token("(", "punct")
2400 if self._indent >= self._max_depth:
2401 self._push_token("...", "more")
2402 else:
2403 self._start_container()
2404 self._print_nested(value.default_factory)
2405 self._terminate_item()
2406 self._print_dict("", "{", "}", value.items())
2407 self._terminate_item()
2408 self._end_container()
2409 self._push_token(")", "punct")
2411 def _print_dequeue(self, value: collections.deque[_t.Any]):
2412 self._push_token("deque", "type")
2413 self._push_token("(", "punct")
2414 if self._indent >= self._max_depth:
2415 self._push_token("...", "more")
2416 else:
2417 self._start_container()
2418 self._print_list("", "[", "]", value)
2419 self._terminate_item()
2420 if value.maxlen is not None:
2421 self._push_token("maxlen", "param")
2422 self._push_token("=", "punct")
2423 self._print_nested(value.maxlen)
2424 self._terminate_item()
2425 self._end_container()
2426 self._push_token(")", "punct")
2428 def _print_dataclass(self, value):
2429 try:
2430 # If dataclass has a custom repr, fall back to it.
2431 # This code is copied from Rich, MIT License.
2432 has_custom_repr = value.__repr__.__code__.co_filename not in (
2433 dataclasses.__file__,
2434 reprlib.__file__,
2435 )
2436 except Exception: # pragma: no cover
2437 has_custom_repr = True
2439 if has_custom_repr:
2440 self._print_plain(value)
2441 return
2443 self._push_token(value.__class__.__name__, "type")
2444 self._push_token("(", "punct")
2446 if self._indent >= self._max_depth:
2447 self._push_token("...", "more")
2448 else:
2449 self._start_container()
2450 for field in dataclasses.fields(value):
2451 if not field.repr:
2452 continue
2453 self._push_token(field.name, "param")
2454 self._push_token("=", "punct")
2455 self._print_nested(getattr(value, field.name))
2456 self._terminate_item()
2457 self._end_container()
2459 self._push_token(")", "punct")
2461 def _print_colorized_repr(self, value):
2462 self._flush_sep()
2464 if self._state in [
2465 _ReprContextState.CONTAINER_START,
2466 _ReprContextState.ITEM_START,
2467 ]:
2468 self._flush_line()
2470 res = value.__colorized_repr__(self)
2471 if not isinstance(res, ColorizedString):
2472 raise TypeError(
2473 f"__colorized_repr__ returned non-colorized-string (type {_t.type_repr(type(res))})"
2474 )
2475 self._line += res
2477 self._state = _ReprContextState.NORMAL
2479 def _print_colorized_str(self, value):
2480 self._flush_sep()
2482 if self._state in [
2483 _ReprContextState.CONTAINER_START,
2484 _ReprContextState.ITEM_START,
2485 ]: # pragma: no cover
2486 self._flush_line()
2487 # This never happens because `_state` is always `START`
2488 # when rendering as `str`.
2490 res = value.__colorized_str__(self)
2491 if not isinstance(res, ColorizedString):
2492 raise TypeError(
2493 f"__colorized_str__ returned non-colorized-string (type {_t.type_repr(type(res))})"
2494 )
2495 self._line += res
2496 self._state = _ReprContextState.NORMAL
2498 def _print_rich_repr(self, value):
2499 rich_repr = getattr(value, "__rich_repr__")
2500 angular = getattr(rich_repr, "angular", False)
2502 if angular:
2503 self._push_token("<", "punct")
2504 self._push_token(value.__class__.__name__, "type")
2505 if angular:
2506 self._push_token(" ", "space")
2507 else:
2508 self._push_token("(", "punct")
2510 if self._indent >= self._max_depth:
2511 self._push_token("...", "more")
2512 else:
2513 self._start_container()
2514 args = rich_repr()
2515 if args is None:
2516 args = [] # `rich_repr` didn't yield?
2517 for arg in args:
2518 if isinstance(arg, tuple):
2519 if len(arg) == 3:
2520 key, child, default = arg
2521 if default == child:
2522 continue
2523 elif len(arg) == 2:
2524 key, child = arg
2525 elif len(arg) == 1:
2526 key, child = None, arg[0]
2527 else:
2528 key, child = None, arg
2529 else:
2530 key, child = None, arg
2532 if key:
2533 self._push_token(str(key), "param")
2534 self._push_token("=", "punct")
2535 self._print_nested(child)
2536 self._terminate_item("" if angular else ", ")
2537 self._end_container()
2539 self._push_token(">" if angular else ")", "punct")
2542_CONTAINERS = {
2543 os._Environ: lambda c, o: c._print_dict("environ", "({", "})", o.items()),
2544 collections.defaultdict: ReprContext._print_defaultdict,
2545 collections.deque: ReprContext._print_dequeue,
2546 collections.Counter: lambda c, o: c._print_dict("Counter", "({", "})", o.items()),
2547 collections.UserList: lambda c, o: c._print_list("", "[", "]", o),
2548 collections.UserDict: lambda c, o: c._print_dict("", "{", "}", o.items()),
2549 list: lambda c, o: c._print_list("", "[", "]", o),
2550 set: lambda c, o: c._print_list("", "{", "}", o),
2551 frozenset: lambda c, o: c._print_list("frozenset", "({", "})", o),
2552 tuple: lambda c, o: c._print_list("", "(", ")", o),
2553 dict: lambda c, o: c._print_dict("", "{", "}", o.items()),
2554 types.MappingProxyType: lambda _: lambda c, o: c._print_dict(
2555 "mappingproxy", "({", "})", o.items()
2556 ),
2557}
2558_CONTAINER_TYPES = tuple(_CONTAINERS)
2561def colorized_str(
2562 value: _t.Any,
2563 /,
2564 theme: yuio.theme.Theme | None = None,
2565 **kwargs,
2566) -> ColorizedString:
2567 """
2568 Like :class:`str() <str>`, but uses ``__colorized_str__`` and returns
2569 a colorized string.
2571 This function is used when formatting values
2572 via :meth:`ColorizedString.percent_format`, or printing them via :mod:`yuio.io`
2573 functions.
2575 :param value:
2576 value to colorize.
2577 :param theme:
2578 theme will be passed to ``__colorized_str__``.
2579 :param kwargs:
2580 all other keyword arguments will be forwarded to :class:`ReprContext`.
2581 :returns:
2582 a colorized string containing representation of ``value``.
2583 :raises:
2584 this method does not raise any errors. If any inner object raises an
2585 exception, this function returns a colorized string with an error description.
2587 .. _rich repr protocol: https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol
2589 """
2591 ctx = ReprContext(theme=theme, **kwargs)
2592 return ctx.str(value)
2595def colorized_repr(
2596 value: _t.Any,
2597 /,
2598 theme: yuio.theme.Theme | None = None,
2599 **kwargs,
2600) -> ColorizedString:
2601 """
2602 Like :func:`repr`, but uses ``__colorized_repr__`` and returns
2603 a colorized string.
2605 This function is used when formatting values
2606 via :meth:`ColorizedString.percent_format`, or printing them via :mod:`yuio.io`
2607 functions.
2609 :param value:
2610 value to colorize.
2611 :param theme:
2612 theme will be passed to ``__colorized_repr__``.
2613 :param kwargs:
2614 all other keyword arguments will be forwarded to :class:`ReprContext`.
2615 :returns:
2616 a colorized string containing representation of ``value``.
2617 :raises:
2618 this method does not raise any errors. If any inner object raises an
2619 exception, this function returns a colorized string with an error description.
2621 .. _rich repr protocol: https://rich.readthedocs.io/en/stable/pretty.html#rich-repr-protocol
2623 """
2625 ctx = ReprContext(theme=theme, **kwargs)
2626 return ctx.repr(value)
2629def _to_colorable(msg: _t.Any, args: tuple[_t.Any, ...] | None = None) -> Colorable:
2630 """
2631 Convert generic ``msg, args`` tuple to a colorable.
2633 If msg is a string, returns :class:`Format`. Otherwise, check that no arguments
2634 were given, and returns ``msg`` unchanged.
2636 """
2638 if isinstance(msg, str):
2639 return Format(_t.cast(_t.LiteralString, msg), *(args or ()))
2640 else:
2641 if args:
2642 raise TypeError(
2643 f"non-string type {_t.type_repr(type(msg))} can't have format arguments"
2644 )
2645 return msg
2648class _StrBase(abc.ABC):
2649 def __str__(self) -> str:
2650 return str(ReprContext().str(self))
2652 @abc.abstractmethod
2653 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
2654 raise NotImplementedError()
2657@repr_from_rich
2658class Format(_StrBase):
2659 """Format(msg: typing.LiteralString, /, *args: typing.Any)
2660 Format(msg: str, /)
2662 Lazy wrapper that ``%``-formats the given message.
2664 This utility allows saving ``%``-formatted messages and performing actual
2665 formatting lazily when requested. Color tags and backticks are handled as usual.
2667 :param msg:
2668 message to format.
2669 :param args:
2670 arguments for ``%``-formatting the message.
2671 :example:
2672 ::
2674 >>> message = Format("Hello, `%s`!", "world")
2675 >>> print(message)
2676 Hello, world!
2678 """
2680 @_t.overload
2681 def __init__(self, msg: _t.LiteralString, /, *args: _t.Any): ...
2682 @_t.overload
2683 def __init__(self, msg: str, /): ...
2684 def __init__(self, msg: str, /, *args: _t.Any):
2685 self._msg: str = msg
2686 self._args: tuple[_t.Any, ...] = args
2688 def __rich_repr__(self) -> RichReprResult:
2689 yield None, self._msg
2690 yield from ((None, arg) for arg in self._args)
2692 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
2693 return colorize(self._msg, *self._args, ctx=ctx)
2696@_t.final
2697@repr_from_rich
2698class Repr:
2699 """
2700 Lazy wrapper that calls :func:`colorized_repr` on the given value.
2702 :param value:
2703 value to repr.
2704 :param multiline:
2705 if given, overrides settings passed to :func:`colorized_repr` for this call.
2706 :param highlighted:
2707 if given, overrides settings passed to :func:`colorized_repr` for this call.
2708 :example:
2709 .. code-block:: python
2711 config = ...
2712 yuio.io.info(
2713 "Loaded config:\\n`%#+s`", yuio.string.Indent(yuio.string.Repr(config))
2714 )
2716 """
2718 def __init__(
2719 self,
2720 value: _t.Any,
2721 /,
2722 *,
2723 multiline: bool | None = None,
2724 highlighted: bool | None = None,
2725 ):
2726 self.value = value
2727 self.multiline = multiline
2728 self.highlighted = highlighted
2730 def __rich_repr__(self) -> RichReprResult:
2731 yield None, self.value
2732 yield "multiline", self.multiline, None
2733 yield "highlighted", self.highlighted, None
2735 def __str__(self):
2736 return repr(self.value)
2738 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
2739 return ctx.repr(
2740 self.value, multiline=self.multiline, highlighted=self.highlighted
2741 )
2744@_t.final
2745@repr_from_rich
2746class TypeRepr(_StrBase):
2747 """
2748 Lazy wrapper that calls :func:`typing.type_repr` on the given value
2749 and highlights the result.
2751 :param ty:
2752 type to format.
2754 If ``ty`` is a string, :func:`typing.type_repr` is not called on it, allowing
2755 you to mix types and arbitrary descriptions.
2756 :param highlighted:
2757 if given, overrides settings passed to :func:`colorized_repr` for this call.
2758 :example:
2759 .. invisible-code-block: python
2761 value = ...
2763 .. code-block:: python
2765 yuio.io.error("Expected `str`, got `%s`", yuio.string.TypeRepr(type(value)))
2767 """
2769 def __init__(self, ty: _t.Any, /, *, highlighted: bool | None = None):
2770 self._ty = ty
2771 self._highlighted = highlighted
2773 def __rich_repr__(self) -> RichReprResult:
2774 yield None, self._ty
2775 yield "highlighted", self._highlighted, None
2777 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
2778 if not isinstance(self._ty, type) and isinstance(
2779 self._ty, (str, ColorizedString)
2780 ):
2781 return ColorizedString(self._ty)
2782 else:
2783 return ctx.hl(_t.type_repr(self._ty), highlighted=self._highlighted)
2786@repr_from_rich
2787class _JoinBase(_StrBase):
2788 def __init__(
2789 self,
2790 collection: _t.Iterable[_t.Any],
2791 /,
2792 *,
2793 sep: str = ", ",
2794 sep_two: str | None = None,
2795 sep_last: str | None = None,
2796 fallback: AnyString = "",
2797 color: str | _Color | None = "code",
2798 ):
2799 self.__collection = collection
2800 self._sep = sep
2801 self._sep_two = sep_two
2802 self._sep_last = sep_last
2803 self._fallback: AnyString = fallback
2804 self._color = color
2806 @functools.cached_property
2807 def _collection(self):
2808 return list(self.__collection)
2810 @classmethod
2811 def or_(
2812 cls,
2813 collection: _t.Iterable[_t.Any],
2814 /,
2815 *,
2816 fallback: AnyString = "",
2817 color: str | _Color | None = "code",
2818 ) -> _t.Self:
2819 """
2820 Shortcut for joining arguments using word "or" as the last separator.
2822 :example:
2823 ::
2825 >>> print(yuio.string.JoinStr.or_([1, 2, 3]))
2826 1, 2, or 3
2828 """
2830 return cls(
2831 collection, sep_last=", or ", sep_two=" or ", fallback=fallback, color=color
2832 )
2834 @classmethod
2835 def and_(
2836 cls,
2837 collection: _t.Iterable[_t.Any],
2838 /,
2839 *,
2840 fallback: AnyString = "",
2841 color: str | _Color | None = "code",
2842 ) -> _t.Self:
2843 """
2844 Shortcut for joining arguments using word "and" as the last separator.
2846 :example:
2847 ::
2849 >>> print(yuio.string.JoinStr.and_([1, 2, 3]))
2850 1, 2, and 3
2852 """
2854 return cls(
2855 collection,
2856 sep_last=", and ",
2857 sep_two=" and ",
2858 fallback=fallback,
2859 color=color,
2860 )
2862 def __rich_repr__(self) -> RichReprResult:
2863 yield None, self._collection
2864 yield "sep", self._sep, ", "
2865 yield "sep_two", self._sep_two, None
2866 yield "sep_last", self._sep_last, None
2867 yield "color", self._color, "code"
2869 def _render(
2870 self,
2871 theme: yuio.theme.Theme,
2872 to_str: _t.Callable[[_t.Any], ColorizedString],
2873 ) -> ColorizedString:
2874 res = ColorizedString()
2875 color = theme.to_color(self._color)
2877 size = len(self._collection)
2878 if not size:
2879 res += self._fallback
2880 return res
2881 elif size == 1:
2882 return to_str(self._collection[0]).with_base_color(color)
2883 elif size == 2:
2884 res.append_colorized_str(to_str(self._collection[0]).with_base_color(color))
2885 res.append_str(self._sep if self._sep_two is None else self._sep_two)
2886 res.append_colorized_str(to_str(self._collection[1]).with_base_color(color))
2887 return res
2889 last_i = size - 1
2891 sep = self._sep
2892 sep_last = self._sep if self._sep_last is None else self._sep_last
2894 do_sep = False
2895 for i, value in enumerate(self._collection):
2896 if do_sep:
2897 if i == last_i:
2898 res.append_str(sep_last)
2899 else:
2900 res.append_str(sep)
2901 res.append_colorized_str(to_str(value).with_base_color(color))
2902 do_sep = True
2903 return res
2906@_t.final
2907class JoinStr(_JoinBase):
2908 """
2909 Lazy wrapper that calls :class:`colorized_str` on elements of the given collection,
2910 then joins the results using the given separator.
2912 :param collection:
2913 collection that will be printed.
2914 :param sep:
2915 separator that's printed between elements of the collection.
2916 :param sep_two:
2917 separator that's used when there are only two elements in the collection.
2918 Defaults to ``sep``.
2919 :param sep_last:
2920 separator that's used between the last and prior-to-last element
2921 of the collection. Defaults to ``sep``.
2922 :param fallback:
2923 printed if collection is empty.
2924 :param color:
2925 color applied to elements of the collection.
2926 :example:
2927 .. code-block:: python
2929 values = ["foo", "bar"]
2930 yuio.io.info("Available values: %s", yuio.string.JoinStr(values))
2932 """
2934 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
2935 return self._render(ctx._theme, ctx.str)
2938@_t.final
2939class JoinRepr(_JoinBase):
2940 """
2941 Lazy wrapper that calls :class:`colorized_repr` on elements of the given collection,
2942 then joins the results using the given separator.
2944 :param collection:
2945 collection that will be printed.
2946 :param sep:
2947 separator that's printed between elements of the collection.
2948 :param sep_two:
2949 separator that's used when there are only two elements in the collection.
2950 Defaults to ``sep``.
2951 :param sep_last:
2952 separator that's used between the last and prior-to-last element
2953 of the collection. Defaults to ``sep``.
2954 :param fallback:
2955 printed if collection is empty.
2956 :param color:
2957 color applied to elements of the collection.
2958 :example:
2959 .. code-block:: python
2961 values = ["foo", "bar"]
2962 yuio.io.info("Available values: %s", yuio.string.JoinRepr(values))
2964 """
2966 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
2967 return self._render(ctx._theme, ctx.repr)
2970And = JoinStr.and_
2971"""
2972Shortcut for :meth:`JoinStr.and_`.
2974"""
2977Or = JoinStr.or_
2978"""
2979Shortcut for :meth:`JoinStr.or_`.
2981"""
2984@_t.final
2985@repr_from_rich
2986class Stack(_StrBase):
2987 """
2988 Lazy wrapper that joins multiple :obj:`Colorable` objects with newlines,
2989 effectively stacking them one on top of another.
2991 :param args:
2992 colorables to stack.
2993 :example:
2994 ::
2996 >>> print(
2997 ... yuio.string.Stack(
2998 ... yuio.string.Format("<c bold magenta>Example:</c>"),
2999 ... yuio.string.Indent(
3000 ... yuio.string.Hl(
3001 ... \"""
3002 ... {
3003 ... "foo": "bar"
3004 ... }
3005 ... \""",
3006 ... syntax="json",
3007 ... ),
3008 ... indent="-> ",
3009 ... ),
3010 ... )
3011 ... )
3012 Example:
3013 -> {
3014 -> "foo": "bar"
3015 -> }
3017 """
3019 def __init__(self, *args: Colorable):
3020 self._args = args
3022 def __rich_repr__(self) -> RichReprResult:
3023 yield from ((None, arg) for arg in self._args)
3025 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3026 res = ColorizedString()
3027 sep = False
3028 for arg in self._args:
3029 if sep:
3030 res.append_color(_Color.NONE)
3031 res.append_str("\n")
3032 res += ctx.str(arg)
3033 sep = True
3034 return res
3037@_t.final
3038@repr_from_rich
3039class Indent(_StrBase):
3040 """
3041 Lazy wrapper that indents the message during formatting.
3043 .. seealso::
3045 :meth:`ColorizedString.indent`.
3047 :param msg:
3048 message to indent.
3049 :param indent:
3050 this will be prepended to the first line in the string.
3051 Defaults to two spaces.
3052 :param continuation_indent:
3053 this will be prepended to subsequent lines in the string.
3054 Defaults to ``indent``.
3055 :example:
3056 .. code-block:: python
3058 config = ...
3059 yuio.io.info(
3060 "Loaded config:\\n`%#+s`", yuio.string.Indent(yuio.string.Repr(config))
3061 )
3063 """
3065 def __init__(
3066 self,
3067 msg: Colorable,
3068 /,
3069 *,
3070 indent: AnyString | int = " ",
3071 continuation_indent: AnyString | int | None = None,
3072 ):
3073 self._msg = msg
3074 self._indent: AnyString | int = indent
3075 self._continuation_indent: AnyString | int | None = continuation_indent
3077 def __rich_repr__(self) -> RichReprResult:
3078 yield None, self._msg
3079 yield "indent", self._indent, " "
3080 yield "continuation_indent", self._continuation_indent, None
3082 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3083 if isinstance(self._indent, int):
3084 indent = ColorizedString(" " * self._indent)
3085 else:
3086 indent = ColorizedString(self._indent)
3087 if self._continuation_indent is None:
3088 continuation_indent = indent
3089 elif isinstance(self._continuation_indent, int):
3090 continuation_indent = ColorizedString(" " * self._continuation_indent)
3091 else:
3092 continuation_indent = ColorizedString(self._continuation_indent)
3094 indent_width = max(indent.width, continuation_indent.width)
3095 max_width = max(1, ctx.max_width - indent_width)
3097 return ctx.str(self._msg, max_width=max_width).indent(
3098 indent, continuation_indent
3099 )
3102@_t.final
3103@repr_from_rich
3104class Md(_StrBase):
3105 """Md(msg: typing.LiteralString, /, *args, max_width: int | None | yuio.Missing = yuio.MISSING, dedent: bool = True, allow_headings: bool = True)
3106 Md(msg: str, /, *, max_width: int | None | yuio.Missing = yuio.MISSING, dedent: bool = True, allow_headings: bool = True)
3108 Lazy wrapper that renders markdown during formatting.
3110 :param md:
3111 markdown to format.
3112 :param args:
3113 arguments for ``%``-formatting the rendered markdown.
3114 :param max_width:
3115 if given, overrides settings passed to :func:`colorized_repr` for this call.
3116 :param dedent:
3117 whether to remove leading indent from markdown.
3118 :param allow_headings:
3119 whether to render headings as actual headings or as paragraphs.
3121 """
3123 @_t.overload
3124 def __init__(
3125 self,
3126 md: _t.LiteralString,
3127 /,
3128 *args: _t.Any,
3129 max_width: int | None = None,
3130 dedent: bool = True,
3131 allow_headings: bool = True,
3132 ): ...
3133 @_t.overload
3134 def __init__(
3135 self,
3136 md: str,
3137 /,
3138 *,
3139 max_width: int | None = None,
3140 dedent: bool = True,
3141 allow_headings: bool = True,
3142 ): ...
3143 def __init__(
3144 self,
3145 md: str,
3146 /,
3147 *args: _t.Any,
3148 max_width: int | None = None,
3149 dedent: bool = True,
3150 allow_headings: bool = True,
3151 ):
3152 self._md: str = md
3153 self._args: tuple[_t.Any, ...] = args
3154 self._max_width: int | None = max_width
3155 self._dedent: bool = dedent
3156 self._allow_headings: bool = allow_headings
3158 def __rich_repr__(self) -> RichReprResult:
3159 yield None, self._md
3160 yield from ((None, arg) for arg in self._args)
3161 yield "max_width", self._max_width, yuio.MISSING
3162 yield "dedent", self._dedent, True
3163 yield "allow_headings", self._allow_headings, True
3165 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3166 import yuio.md
3168 max_width = self._max_width or ctx.max_width
3170 formatter = yuio.md.MdFormatter(
3171 ctx.theme,
3172 width=max_width,
3173 allow_headings=self._allow_headings,
3174 )
3176 res = ColorizedString()
3177 res.start_no_wrap()
3178 sep = False
3179 for line in formatter.format(self._md, dedent=self._dedent):
3180 if sep:
3181 res += "\n"
3182 res += line
3183 sep = True
3184 res.end_no_wrap()
3185 if self._args:
3186 res = res.percent_format(self._args, ctx)
3188 return res
3191@_t.final
3192@repr_from_rich
3193class Hl(_StrBase):
3194 """Hl(code: typing.LiteralString, /, *args, syntax: str | yuio.md.SyntaxHighlighter, dedent: bool = True)
3195 Hl(code: str, /, *, syntax: str | yuio.md.SyntaxHighlighter, dedent: bool = True)
3197 Lazy wrapper that highlights code during formatting.
3199 :param md:
3200 code to highlight.
3201 :param args:
3202 arguments for ``%``-formatting the highlighted code.
3203 :param syntax:
3204 name of syntax or a :class:`~yuio.md.SyntaxHighlighter` instance.
3205 :param dedent:
3206 whether to remove leading indent from code.
3208 """
3210 @_t.overload
3211 def __init__(
3212 self,
3213 code: _t.LiteralString,
3214 /,
3215 *args: _t.Any,
3216 syntax: str | yuio.md.SyntaxHighlighter,
3217 dedent: bool = True,
3218 ): ...
3219 @_t.overload
3220 def __init__(
3221 self,
3222 code: str,
3223 /,
3224 *,
3225 syntax: str | yuio.md.SyntaxHighlighter,
3226 dedent: bool = True,
3227 ): ...
3228 def __init__(
3229 self,
3230 code: str,
3231 /,
3232 *args: _t.Any,
3233 syntax: str | yuio.md.SyntaxHighlighter,
3234 dedent: bool = True,
3235 ):
3236 self._code: str = code
3237 self._args: tuple[_t.Any, ...] = args
3238 self._syntax: str | yuio.md.SyntaxHighlighter = syntax
3239 self._dedent: bool = dedent
3241 def __rich_repr__(self) -> RichReprResult:
3242 yield None, self._code
3243 yield from ((None, arg) for arg in self._args)
3244 yield "syntax", self._syntax
3245 yield "dedent", self._dedent, True
3247 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3248 import yuio.md
3250 syntax = (
3251 self._syntax
3252 if isinstance(self._syntax, yuio.md.SyntaxHighlighter)
3253 else yuio.md.SyntaxHighlighter.get_highlighter(self._syntax)
3254 )
3255 code = self._code
3256 if self._dedent:
3257 code = _dedent(code)
3258 code = code.rstrip()
3260 res = ColorizedString()
3261 res.start_no_wrap()
3262 res += syntax.highlight(ctx.theme, code)
3263 res.end_no_wrap()
3264 if self._args:
3265 res = res.percent_format(self._args, ctx)
3267 return res
3270@_t.final
3271@repr_from_rich
3272class Wrap(_StrBase):
3273 """
3274 Lazy wrapper that wraps the message during formatting.
3276 .. seealso::
3278 :meth:`ColorizedString.wrap`.
3280 :param msg:
3281 message to wrap.
3282 :param max_width:
3283 if given, overrides settings passed to :func:`colorized_repr` for this call.
3284 :param preserve_spaces:
3285 if set to :data:`True`, all spaces are preserved.
3286 Otherwise, consecutive spaces are collapsed into a single space.
3288 Note that tabs always treated as a single whitespace.
3289 :param preserve_newlines:
3290 if set to :data:`True` (default), text is additionally wrapped
3291 on newline sequences. When this happens, the newline sequence that wrapped
3292 the line will be placed into :attr:`~ColorizedString.explicit_newline`.
3294 If set to :data:`False`, newline sequences are treated as whitespaces.
3295 :param break_long_words:
3296 if set to :data:`True` (default), words that don't fit into a single line
3297 will be split into multiple lines.
3298 :param overflow:
3299 Pass :data:`True` to trim overflowing lines and replace them with ellipsis.
3300 :param break_long_nowrap_words:
3301 if set to :data:`True`, words in no-wrap regions that don't fit
3302 into a single line will be split into multiple lines.
3303 :param indent:
3304 this will be prepended to the first line in the string.
3305 Defaults to two spaces.
3306 :param continuation_indent:
3307 this will be prepended to subsequent lines in the string.
3308 Defaults to ``indent``.
3310 """
3312 def __init__(
3313 self,
3314 msg: Colorable,
3315 /,
3316 *,
3317 max_width: int | None = None,
3318 preserve_spaces: bool = False,
3319 preserve_newlines: bool = True,
3320 break_long_words: bool = True,
3321 break_long_nowrap_words: bool = False,
3322 overflow: bool | str = False,
3323 indent: AnyString | int = "",
3324 continuation_indent: AnyString | int | None = None,
3325 ):
3326 self._msg = msg
3327 self._max_width: int | None = max_width
3328 self._preserve_spaces = preserve_spaces
3329 self._preserve_newlines = preserve_newlines
3330 self._break_long_words = break_long_words
3331 self._break_long_nowrap_words = break_long_nowrap_words
3332 self._overflow = overflow
3333 self._indent: AnyString | int = indent
3334 self._continuation_indent: AnyString | int | None = continuation_indent
3336 def __rich_repr__(self) -> RichReprResult:
3337 yield None, self._msg
3338 yield "max_width", self._max_width, None
3339 yield "indent", self._indent, ""
3340 yield "continuation_indent", self._continuation_indent, None
3341 yield "preserve_spaces", self._preserve_spaces, None
3342 yield "preserve_newlines", self._preserve_newlines, True
3343 yield "break_long_words", self._break_long_words, True
3344 yield "break_long_nowrap_words", self._break_long_nowrap_words, False
3346 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3347 if isinstance(self._indent, int):
3348 indent = ColorizedString(" " * self._indent)
3349 else:
3350 indent = ColorizedString(self._indent)
3351 if self._continuation_indent is None:
3352 continuation_indent = indent
3353 elif isinstance(self._continuation_indent, int):
3354 continuation_indent = ColorizedString(" " * self._continuation_indent)
3355 else:
3356 continuation_indent = ColorizedString(self._continuation_indent)
3358 max_width = self._max_width or ctx.max_width
3359 indent_width = max(indent.width, continuation_indent.width)
3360 inner_max_width = max(1, max_width - indent_width)
3362 overflow = self._overflow
3363 if overflow is True:
3364 overflow = ctx.theme.msg_decorations.get("overflow", "")
3366 res = ColorizedString()
3367 res.start_no_wrap()
3368 sep = False
3369 for line in ctx.str(self._msg, max_width=inner_max_width).wrap(
3370 max_width,
3371 preserve_spaces=self._preserve_spaces,
3372 preserve_newlines=self._preserve_newlines,
3373 break_long_words=self._break_long_words,
3374 break_long_nowrap_words=self._break_long_nowrap_words,
3375 overflow=overflow,
3376 indent=indent,
3377 continuation_indent=continuation_indent,
3378 ):
3379 if sep:
3380 res.append_str("\n")
3381 res.append_colorized_str(line)
3382 sep = True
3383 res.end_no_wrap()
3385 return res
3388@_t.final
3389@repr_from_rich
3390class WithBaseColor(_StrBase):
3391 """
3392 Lazy wrapper that applies the given color "under" the given colorable.
3393 That is, all colors in the rendered colorable will be combined with this color
3394 on the left: ``base_color | color``.
3396 .. seealso::
3398 :meth:`ColorizedString.with_base_color`.
3400 :param msg:
3401 message to highlight.
3402 :param base_color:
3403 color that will be added under the message.
3405 """
3407 def __init__(
3408 self,
3409 msg: Colorable,
3410 /,
3411 *,
3412 base_color: str | _Color,
3413 ):
3414 self._msg = msg
3415 self._base_color = base_color
3417 def __rich_repr__(self) -> RichReprResult:
3418 yield None, self._msg
3419 yield "base_color", self._base_color
3421 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3422 return ctx.str(self._msg).with_base_color(ctx.theme.to_color(self._base_color))
3425@repr_from_rich
3426class Hr(_StrBase):
3427 """Hr(msg: Colorable = "", /, *, weight: int | str = 1, overflow: bool | str = True, **kwargs)
3429 Produces horizontal ruler when converted to string.
3431 :param msg:
3432 any colorable that will be placed in the middle of the ruler.
3433 :param weight:
3434 weight or style of the ruler:
3436 - ``0`` prints no ruler (but still prints centered text),
3437 - ``1`` prints normal ruler,
3438 - ``2`` prints bold ruler.
3440 Additional styles can be added through
3441 :attr:`Theme.msg_decorations <yuio.theme.Theme.msg_decorations>`.
3442 :param max_width:
3443 if given, overrides settings passed to :func:`colorized_repr` for this call.
3444 :param overflow:
3445 pass :data:`False` to disable trimming ``msg`` to terminal width.
3446 :param kwargs:
3447 Other keyword arguments override corresponding decorations from the theme:
3449 :``left_start``:
3450 start of the ruler to the left of the message.
3451 :``left_middle``:
3452 filler of the ruler to the left of the message.
3453 :``left_end``:
3454 end of the ruler to the left of the message.
3455 :``middle``:
3456 filler of the ruler that's used if ``msg`` is empty.
3457 :``right_start``:
3458 start of the ruler to the right of the message.
3459 :``right_middle``:
3460 filler of the ruler to the right of the message.
3461 :``right_end``:
3462 end of the ruler to the right of the message.
3464 """
3466 def __init__(
3467 self,
3468 msg: Colorable = "",
3469 /,
3470 *,
3471 max_width: int | None = None,
3472 overflow: bool | str = True,
3473 weight: int | str = 1,
3474 left_start: str | None = None,
3475 left_middle: str | None = None,
3476 left_end: str | None = None,
3477 middle: str | None = None,
3478 right_start: str | None = None,
3479 right_middle: str | None = None,
3480 right_end: str | None = None,
3481 ):
3482 self._msg = msg
3483 self._max_width = max_width
3484 self._overflow = overflow
3485 self._weight = weight
3486 self._left_start = left_start
3487 self._left_middle = left_middle
3488 self._left_end = left_end
3489 self._middle = middle
3490 self._right_start = right_start
3491 self._right_middle = right_middle
3492 self._right_end = right_end
3494 def __rich_repr__(self) -> RichReprResult:
3495 yield None, self._msg, None
3496 yield "weight", self._weight, None
3497 yield "max_width", self._max_width, None
3498 yield "overflow", self._overflow, None
3499 yield "left_start", self._left_start, None
3500 yield "left_middle", self._left_middle, None
3501 yield "left_end", self._left_end, None
3502 yield "middle", self._middle, None
3503 yield "right_start", self._right_start, None
3504 yield "right_middle", self._right_middle, None
3505 yield "right_end", self._right_end, None
3507 def __colorized_str__(self, ctx: ReprContext) -> ColorizedString:
3508 max_width = self._max_width or ctx.max_width
3510 color = ctx.theme.get_color(f"msg/decoration:hr/{self._weight}")
3512 res = ColorizedString(color)
3513 res.start_no_wrap()
3515 msg = ctx.str(self._msg)
3516 if not msg:
3517 res.append_str(self._make_whole(max_width, ctx.theme.msg_decorations))
3518 return res
3520 overflow = self._overflow
3521 if overflow is True:
3522 overflow = ctx.theme.msg_decorations.get("overflow", "")
3524 sep = False
3525 for line in msg.wrap(
3526 max_width, preserve_spaces=True, break_long_words=False, overflow=overflow
3527 ):
3528 if sep:
3529 res.append_color(yuio.color.Color.NONE)
3530 res.append_str("\n")
3531 res.append_color(color)
3533 line_w = line.width
3534 line_w_fill = max(0, max_width - line_w)
3535 line_w_fill_l = line_w_fill // 2
3536 line_w_fill_r = line_w_fill - line_w_fill_l
3537 if not line_w_fill_l and not line_w_fill_r:
3538 res.append_colorized_str(line)
3539 return res
3541 res.append_str(self._make_left(line_w_fill_l, ctx.theme.msg_decorations))
3542 res.append_colorized_str(line)
3543 res.append_str(self._make_right(line_w_fill_r, ctx.theme.msg_decorations))
3545 sep = True
3547 return res
3549 def _make_left(self, w: int, msg_decorations: _t.Mapping[str, str]):
3550 weight = self._weight
3551 start = (
3552 self._left_start
3553 if self._left_start is not None
3554 else msg_decorations.get(f"hr/{weight}/left_start", "")
3555 )
3556 middle = (
3557 self._left_middle
3558 if self._left_middle is not None
3559 else msg_decorations.get(f"hr/{weight}/left_middle")
3560 ) or " "
3561 end = (
3562 self._left_end
3563 if self._left_end is not None
3564 else msg_decorations.get(f"hr/{weight}/left_end", "")
3565 )
3567 return _make_left(w, start, middle, end)
3569 def _make_right(self, w: int, msg_decorations: _t.Mapping[str, str]):
3570 weight = self._weight
3571 start = (
3572 self._right_start
3573 if self._right_start is not None
3574 else msg_decorations.get(f"hr/{weight}/right_start", "")
3575 )
3576 middle = (
3577 self._right_middle
3578 if self._right_middle is not None
3579 else msg_decorations.get(f"hr/{weight}/right_middle")
3580 ) or " "
3581 end = (
3582 self._right_end
3583 if self._right_end is not None
3584 else msg_decorations.get(f"hr/{weight}/right_end", "")
3585 )
3587 return _make_right(w, start, middle, end)
3589 def _make_whole(self, w: int, msg_decorations: _t.Mapping[str, str]):
3590 weight = self._weight
3591 start = (
3592 self._left_start
3593 if self._left_start is not None
3594 else msg_decorations.get(f"hr/{weight}/left_start", " ")
3595 )
3596 middle = (
3597 self._middle
3598 if self._middle is not None
3599 else msg_decorations.get(f"hr/{weight}/middle")
3600 ) or " "
3601 end = (
3602 self._right_end
3603 if self._right_end is not None
3604 else msg_decorations.get(f"hr/{weight}/right_end", " ")
3605 )
3607 start_w = line_width(start)
3608 middle_w = line_width(middle)
3609 end_w = line_width(end)
3611 if w >= start_w:
3612 w -= start_w
3613 else:
3614 start = ""
3615 if w >= end_w:
3616 w -= end_w
3617 else:
3618 end = ""
3619 middle_times = w // middle_w
3620 w -= middle_times * middle_w
3621 middle *= middle_times
3622 return start + middle + end + " " * w
3625def _make_left(
3626 w: int,
3627 start: str,
3628 middle: str,
3629 end: str,
3630):
3631 start_w = line_width(start)
3632 middle_w = line_width(middle)
3633 end_w = line_width(end)
3635 if w >= end_w:
3636 w -= end_w
3637 else:
3638 end = ""
3639 if w >= start_w:
3640 w -= start_w
3641 else:
3642 start = ""
3643 middle_times = w // middle_w
3644 w -= middle_times * middle_w
3645 middle *= middle_times
3646 return start + middle + end + " " * w
3649def _make_right(
3650 w: int,
3651 start: str,
3652 middle: str,
3653 end: str,
3654):
3655 start_w = line_width(start)
3656 middle_w = line_width(middle)
3657 end_w = line_width(end)
3659 if w >= start_w:
3660 w -= start_w
3661 else:
3662 start = ""
3663 if w >= end_w:
3664 w -= end_w
3665 else:
3666 end = ""
3667 middle_times = w // middle_w
3668 w -= middle_times * middle_w
3669 middle *= middle_times
3670 return " " * w + start + middle + end