Coverage for yuio / theme.py: 98%
352 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"""
9Controlling visual aspects of Yuio with themes.
11Theme base class
12----------------
14The overall look and feel of a Yuio application is declared
15in a :class:`Theme` object:
17.. autoclass:: Theme
19 .. autoattribute:: progress_bar_width
21 .. autoattribute:: spinner_update_rate_ms
23 .. autoattribute:: msg_decorations
25 .. automethod:: set_msg_decoration
27 .. automethod:: _set_msg_decoration_if_not_overridden
29 .. autoattribute:: colors
31 .. automethod:: set_color
33 .. automethod:: _set_color_if_not_overridden
35 .. automethod:: get_color
37 .. automethod:: to_color
39 .. automethod:: check
42Default theme
43-------------
45Use the following loader to create an instance of the default theme:
47.. autofunction:: load
49.. autoclass:: DefaultTheme
52.. _all-color-paths:
54Color paths
55-----------
57common tags
58 :class:`DefaultTheme` sets up commonly used colors that you can use
59 in formatted messages:
61 - ``code``: inline code,
62 - ``note``: inline highlighting,
63 - ``path``: file paths,
64 - ``flag``: CLI flags,
65 - ``bold``, ``b``: font style,
66 - ``dim``, ``d``: font style,
67 - ``italic``, ``i``: font style,
68 - ``underline``, ``u``: font style,
69 - ``inverse``: swap foreground and background colors,
70 - ``normal``: normal foreground,
71 - ``normal_dim``: muted foreground (see :attr:`~yuio.color.Color.FORE_NORMAL_DIM`),
72 - ``red``: foreground,
73 - ``green``: foreground,
74 - ``yellow``: foreground,
75 - ``blue``: foreground,
76 - ``magenta``: foreground,
77 - ``cyan``: foreground,
79 .. note::
81 We don't define ``black`` and ``white`` because they can be invisible
82 with some terminal themes. Prefer ``normal_dim`` when you need a muted color.
84 We also don't define tags for backgrounds because there's no way to tell
85 which foreground/background combination will be readable and which will not.
86 Prefer ``inverse`` when you need to add a background.
88:samp:`msg/decoration:{tag}`
89 Color for decorations in front of messages:
91 - ``msg/decoration:info``: messages from :mod:`yuio.io.info`,
92 - ``msg/decoration:warning``: messages from :mod:`yuio.io.warning`,
93 - ``msg/decoration:error``: messages from :mod:`yuio.io.error`,
94 - ``msg/decoration:success``: messages from :mod:`yuio.io.success`,
95 - ``msg/decoration:failure``: messages from :mod:`yuio.io.failure`,
96 - :samp:`msg/decoration:heading/{level}`: messages from :mod:`yuio.io.heading`
97 and headings in markdown,
98 - ``msg/decoration:heading/section``: first-level headings in CLI help,
99 - ``msg/decoration:question``: messages from :func:`yuio.io.ask`,
100 - ``msg/decoration:list``: bullets in markdown,
101 - ``msg/decoration:quote``: quote decorations in markdown,
102 - ``msg/decoration:code``: code decorations in markdown,
103 - ``msg/decoration:thematic_break``: thematic breaks
104 (i.e. horizontal rulers) in markdown,
105 - :samp:`msg/decoration:hr/{weight}`: horizontal rulers (see :func:`yuio.io.hr`
106 and :func:`yuio.string.Hr`).
108:samp:`msg/text:{tag}`
109 Color for the text part of messages:
111 - ``msg/text:info`` and all other tags from ``msg/decoration``,
112 - ``msg/text:paragraph``: plain text in markdown,
113 - :samp:`msg/text:code/{syntax}`: plain text in highlighted code blocks.
115:samp:`task/...:{status}`
116 Running and finished tasks:
118 - :samp:`task/decoration`: decoration before the task,
119 - ``task/progressbar/done``: filled portion of the progress bar,
120 - ``task/progressbar/done/start``: gradient start for the filled
121 portion of the progress bar,
122 - ``task/progressbar/done/end``: gradient end for the filled
123 portion of the progress bar,
124 - ``task/progressbar/pending``: unfilled portion of the progress bar,
125 - ``task/progressbar/pending/start``: gradient start for the unfilled
126 portion of the progress bar,
127 - ``task/progressbar/pending/end``: gradient end for the unfilled
128 portion of the progress bar,
129 - ``task/heading``: task title,
130 - ``task/progress``: number that indicates task progress,
131 - ``task/comment``: task comment.
133 ``status`` can be ``running``, ``done``, or ``error``.
135:samp:`hl/{part}:{syntax}`
136 Color for highlighted part of code:
138 - ``hl/comment``: code comments,
139 - ``hl/kwd``: keyword,
140 - ``hl/lit``: non-string literals,
141 - ``hl/punct``: punctuation,
142 - ``hl/str``: string literals,
143 - ``hl/str/esc``: escape sequences in strings,
144 - ``hl/type``: type names,
145 - ``hl/meta``: diff meta info for diff highlighting,
146 - ``hl/added``: added lines in diff highlighting,
147 - ``hl/removed``: removed lines in diff highlighting,
148 - ``hl/prog``: program name in CLI usage and shell highlighting,
149 - ``hl/flag``: CLI flags,
150 - ``hl/metavar``: meta variables in CLI usage.
152``tb/heading``, ``tb/message``, :samp:`tb/frame/{location}/...`
153 For highlighted tracebacks:
155 - ``tb/heading``: traceback heading,
156 - ``tb/message``: error message,
157 - :samp:`tb/frame/{location}/file/module`: module name,
158 - :samp:`tb/frame/{location}/file/line`: line number,
159 - :samp:`tb/frame/{location}/file/path`: file path,
160 - :samp:`tb/frame/{location}/code`: code sample at the error line,
161 - :samp:`tb/frame/{location}/highlight`: highlighting under the code sample.
163 ``location`` is either ``lib`` or ``usr`` depending on whether the code
164 is located in site-packages or in user code.
166:samp:`log/{part}:{level}`
167 Colors for log records. ``part`` is name of a `log record attribute`__,
168 level is lowercase name of logging level.
170 __ https://docs.python.org/3/library/logging.html#logrecord-attributes
172 .. seealso::
174 :class:`yuio.io.Formatter`.
176input widget
177 Colors for :class:`yuio.widget.Input`:
179 - ``menu/decoration:input``: decoration before an input box,
180 - ``menu/text:input``: entered text in an input box,
181 - ``menu/text/esc:input``: highlights for invisible characters in an input box,
182 - ``menu/text/placeholder:input``: placeholder text in an input box,
184grid widgets
185 Colors for :class:`yuio.widget.Grid`, :class:`yuio.widget.Choice`, and other
186 similar widgets:
188 - :samp:`menu/decoration:choice/{status}/{color_tag}`:
189 decoration before a grid item,
190 - :samp:`menu/decoration/comment:choice/{status}/{color_tag}`:
191 decoration around comments for a grid item,
192 - :samp:`menu/text:choice/{status}/{color_tag}`:
193 text of a grid item,
194 - :samp:`menu/text/comment:choice/{status}/{color_tag}`:
195 comment for a grid item,
196 - :samp:`menu/text/prefix:choice/{status}/{color_tag}`:
197 prefix before the main text of a grid item
198 (see :attr:`yuio.widget.Option.display_text_prefix` and
199 :attr:`yuio.complete.Completion.dprefix`),
200 - :samp:`menu/text/suffix:choice/{status}/{color_tag}`:
201 suffix after the main text of a grid item
202 (see :attr:`yuio.widget.Option.display_text_suffix` and
203 :attr:`yuio.complete.Completion.dsuffix`),
204 - ``menu/text:choice/status_line``: status line (i.e. "Page x of y").
205 - ``menu/text:choice/status_line/number``: page numbers in a status line.
207 ``status`` is either ``normal`` or ``active``:
209 - ``normal`` for regular grid items,
210 - ``active`` for the currently selected item.
212 ``color_tag`` is whatever tag specified by :attr:`yuio.widget.Option.color_tag`
213 and :attr:`yuio.complete.Completion.group_color_tag`. Currently supported tags:
215 - ``none``: color tag is not given,
216 - ``selected``: items selected in :class:`yuio.widget.Multiselect`,
217 - ``dir``: directory (in file completion),
218 - ``exec``: executable file (in file completion),
219 - ``symlink``: symbolic link (in file completion),
220 - ``socket``: socket (in file completion),
221 - ``pipe``: FIFO pipe (in file completion),
222 - ``block_device``: block device (in file completion),
223 - ``char_device``: character device (in file completion),
224 - ``original``: original completion item before spelling correction,
225 - ``corrected``: completion item that was found after spelling correction.
227full screen help menu
228 Colors for help menu that appears when pressing :kbd:`F1`:
230 - ``menu/text/heading:help_menu``: section heading,
231 - ``menu/text/help_key:help_menu``: key names,
232 - ``menu/text/help_sep:help_menu``: separators between key names,
233 - ``menu/decoration:help_menu``: decorations.
235inline help menu
236 Colors for help items rendered under a widget:
238 - ``menu/text/help_info:help``: help items that aren't associated with any key,
239 - ``menu/text/help_msg:help``: regular help items,
240 - ``menu/text/help_key:help``: keybinding names,
241 - ``menu/text/help_sep:help``: separator between items.
244.. _all-decorations:
246Decorations
247-----------
249``info``
250 Messages from :mod:`yuio.io.info`.
252``warning``
253 Messages from :mod:`yuio.io.warning`.
255``error``
256 Messages from :mod:`yuio.io.error`.
258``success``
259 Messages from :mod:`yuio.io.success`.
261``failure``
262 Messages from :mod:`yuio.io.failure`.
264:samp:`heading/{level}`
265 Messages from :mod:`yuio.io.heading` and headings in markdown.
267``heading/section``
268 First-level headings in CLI help.
270``question``
271 Messages from :func:`yuio.io.ask`.
273``list``
274 Bullets in markdown.
276``quote``
277 Quote decorations in markdown.
279``code``
280 Code decorations in markdown.
282``thematic_break``
283 Thematic breaks (i.e. horizontal rulers) in markdown.
285``overflow``
286 Ellipsis symbol for lines that don't fit terminal width. Must be one character wide.
288:samp:`progress_bar/{position}`
289 Decorations for progress bars.
291 Available positions are:
293 :``start_symbol``:
294 Start of the progress bar.
295 :``done_symbol``:
296 Tiles finished portion of the progress bar, must be one character wide.
297 :``pending_symbol``:
298 Tiles unfinished portion of the progress bar, must be one character wide.
299 :``end_symbol``:
300 End of the progress bar.
301 :``transition_pattern``:
302 If this decoration is empty, there's no symbol between finished and unfinished
303 parts of the progress bar.
305 Otherwise, this decoration defines a left-to-right gradient of transition
306 characters, ordered from most filled to least filled. Each character
307 must be one character wide.
309 .. raw:: html
311 <div class="highlight-text notranslate">
312 <div class="highlight">
313 <pre class="ascii-graphics">
314 <span class="k">[------> ]</span>
315 │└┬───┘│└┬───────┘│
316 │ │ │ │ end_symbol
317 │ │ │ └ pending_symbol
318 │ │ └ transition_pattern
319 │ └ done_symbol
320 └ start_symbol
321 </pre>
322 </div>
323 </div>
325 **Example:**
327 To get the classic blocky look, you can do the following:
329 .. code-block:: python
331 class BlockProgressTheme(yuio.theme.DefaultTheme):
332 msg_decorations = {
333 "progress_bar/start_symbol": "|",
334 "progress_bar/end_symbol": "|",
335 "progress_bar/done_symbol": "█",
336 "progress_bar/pending_symbol": " ",
337 "progress_bar/transition_pattern": "█▉▊▋▌▍▎▏ ",
338 }
340``spinner/pattern``
341 Defines a sequence of symbols that will be used to show spinners for tasks
342 without known progress. Next element of the sequence will be shown
343 every :attr:`~Theme.spinner_update_rate_ms`.
345 You can find some pre-made patterns in py-spinners__ package.
347 __ https://github.com/ManrajGrover/py-spinners?tab=readme-ov-file
349``spinner/static_symbol``
350 Static spinner symbol, for sub-tasks that've finished running but'.
352:samp:`hr/{weight}/{position}`
353 Decorations for horizontal rulers (see :func:`yuio.io.hr`
354 and :func:`yuio.string.Hr`).
356 Default theme defines three weights:
358 - ``0`` prints no ruler (but still prints centered text),
359 - ``1`` prints normal ruler,
360 - ``2`` prints bold ruler.
362 Available positions are:
364 :``left_start``:
365 Start of the ruler to the left of the message.
366 :``left_middle``:
367 Filler of the ruler to the left of the message.
368 :``left_end``:
369 End of the ruler to the left of the message.
370 :``middle``:
371 Filler of the ruler that's used if ``msg`` is empty.
372 :``right_start``:
373 Start of the ruler to the right of the message.
374 :``right_middle``:
375 Filler of the ruler to the right of the message.
376 :``right_end``:
377 End of the ruler to the right of the message.
379 .. raw:: html
381 <div class="highlight-text notranslate">
382 <div class="highlight">
383 <pre class="ascii-graphics">
384 <span class="k"><------>message<------></span>
385 │└┬───┘│ │└┬───┘│
386 │ │ │ │ │ right_end
387 │ │ │ │ └ right_middle
388 │ │ │ └ right_start
389 │ │ └ left_end
390 │ └ left_middle
391 └ left_start
393 <span class="k"><---------------------></span>
394 │└┬──────────────────┘│
395 │ middle right_end
396 └ left_start
397 </pre>
398 </div>
399 </div>
401"""
403from __future__ import annotations
405import dataclasses
406import functools
407import os
408import pathlib
409import warnings
410from dataclasses import dataclass
411from enum import IntFlag
413import yuio.color
414import yuio.term
415from yuio import _typing as _t
417__all__ = [
418 "DefaultTheme",
419 "RecursiveThemeWarning",
420 "TableJunction",
421 "Theme",
422 "ThemeWarning",
423 "load",
424]
426K = _t.TypeVar("K")
427V = _t.TypeVar("V")
430class ThemeWarning(yuio.YuioWarning):
431 pass
434class RecursiveThemeWarning(ThemeWarning):
435 pass
438class _ImmutableDictProxy(_t.Mapping[K, V], _t.Generic[K, V]): # pragma: no cover
439 def __init__(self, data: dict[K, V], /, *, attr: str):
440 self.__data = data
441 self.__attr = attr
443 def items(self) -> _t.ItemsView[K, V]:
444 return self.__data.items()
446 def keys(self) -> _t.KeysView[K]:
447 return self.__data.keys()
449 def values(self) -> _t.ValuesView[V]:
450 return self.__data.values()
452 def __len__(self):
453 return len(self.__data)
455 def __getitem__(self, key):
456 return self.__data[key]
458 def __iter__(self):
459 return iter(self.__data)
461 def __contains__(self, key):
462 return key in self.__data
464 def __repr__(self):
465 return repr(self.__data)
467 def __setitem__(self, key, item):
468 raise RuntimeError(f"Theme.{self.__attr} is immutable")
470 def __delitem__(self, key):
471 raise RuntimeError(f"Theme.{self.__attr} is immutable")
474class Theme:
475 """
476 Base class for Yuio themes.
478 .. warning::
480 Do not change theme contents after it was passed to :func:`yuio.io.setup`.
481 Otherwise there's a risc of race conditions.
483 """
485 msg_decorations: _t.Mapping[str, str] = {}
486 """
487 Decorative symbols for certain text elements, such as headings,
488 list items, etc.
490 This mapping becomes immutable once a theme class is created. The only possible
491 way to modify it is by using :meth:`~Theme.set_msg_decoration`
492 or :meth:`~Theme._set_msg_decoration_if_not_overridden`.
494 """
496 __msg_decorations: dict[str, str]
497 """
498 An actual mutable version of :attr:`~Theme.msg_decorations`
499 is kept here, because ``__init_subclass__`` will replace
500 :attr:`~Theme.msg_decorations` with an immutable proxy.
502 """
504 __msg_decoration_sources: dict[str, type | None] = {}
505 """
506 Keeps track of where a message decoration was inherited from. This var is used
507 to avoid ``__init__``-ing message decorations that were overridden in a subclass.
509 """
511 table_drawing_symbols: _t.Mapping[int, str] = {}
512 """
513 TODO!
514 """
516 __table_drawing_symbols: dict[int, str] = {}
517 """
518 An actual mutable version of :attr:`~Theme.table_drawing_symbols`
519 is kept here, because ``__init_subclass__`` will replace
520 :attr:`~Theme.table_drawing_symbols` with an immutable proxy.
522 """
524 __table_drawing_symbol_sources: dict[int, type | None] = {}
525 """
526 Keeps track of where a table drawing symbol was inherited from. This var is used
527 to avoid ``__init__``-ing table drawing symbols that were overridden in a subclass.
529 """
531 progress_bar_width: int = 15
532 """
533 Width of a progress bar for :class:`yuio.io.Task`.
535 """
537 spinner_update_rate_ms: int = 200
538 """
539 How often the spinner pattern changes.
541 """
543 colors: _t.Mapping[str, str | yuio.color.Color] = {}
544 """
545 Mapping of color paths to actual colors.
547 Themes use color paths to describe styles and colors for different
548 parts of an application. Color paths are similar to file paths,
549 they use snake case identifiers separated by slashes, and consist of
550 two parts separated by a colon.
552 The first part represents an object, i.e. what we're coloring.
554 The second part represents a context, i.e. what is the state or location
555 of an object that we're coloring.
557 For example, a color for the filled part of the task's progress bar
558 has path ``"task/progressbar/done"``, a color for a text of an error
559 log record has path ``"log/message:error"``, and a color for a string escape
560 sequence in a highlighted python code has path ``"hl/str/esc:python"``.
562 A color at a certain path is propagated to all sub-paths. For example,
563 if ``"task/progressbar"`` is bold, and ``"task/progressbar/done"`` is green,
564 the final color will be bold green.
566 Each color path can be associated with an instance of :class:`~yuio.color.Color`
567 or with another path.
569 If path is mapped to a :class:`~yuio.color.Color`, then the path is associated
570 with that particular color.
572 If path is mapped to another path, then the path is associated with
573 the color value for that other path (please don't create recursions here).
575 You can combine multiple paths within the same mapping by separating them with
576 whitespaces. In this case colors for those paths are combined.
578 For example:
580 .. code-block:: python
582 colors = {
583 "heading_color": "bold",
584 "error_color": "red",
585 "tb/heading": "heading_color error_color",
586 }
588 Here, color of traceback's heading ``"tb/heading"`` will be bold and red.
590 When deriving from a theme, you can override this mapping. When looking up
591 colors via :meth:`~Theme.get_color`, base classes will be tried for color,
592 in order of method resolution.
594 This mapping becomes immutable once a theme class is created. The only possible
595 way to modify it is by using :meth:`~Theme.set_color`
596 or :meth:`~Theme._set_color_if_not_overridden`.
598 """
600 __colors: dict[str, str | yuio.color.Color]
601 """
602 An actual mutable version of :attr:`~Theme.colors`
603 is kept here, because ``__init_subclass__`` will replace
604 :attr:`~Theme.colors` with an immutable proxy.
606 """
608 __color_sources: dict[str, type | None] = {}
609 """
610 Keeps track of where a color was inherited from. This var is used
611 to avoid ``__init__``-ing colors that were overridden in a subclass.
613 """
615 __expected_source: type | None = None
616 """
617 When running an ``__init__`` function, this variable will be set to the class
618 that implemented it, regardless of type of ``self``.
620 That is, inside ``DefaultTheme.__init__``, ``__expected_source`` is set
621 to ``DefaultTheme``, in ``MyTheme.__init__`` it is ``MyTheme``, etc.
623 This is possible because ``__init_subclass__`` wraps any implementation
624 of ``__init__`` into a wrapper that sets this variable.
626 """
628 def __init__(self):
629 self.__color_cache: dict[str, yuio.color.Color | None] = {}
631 def __init_subclass__(cls, **kwargs):
632 super().__init_subclass__(**kwargs)
634 colors = {}
635 color_sources = {}
636 for base in reversed(cls.__mro__):
637 base_colors = getattr(base, "_Theme__colors", {})
638 colors.update(base_colors)
639 base_color_sources = getattr(base, "_Theme__color_sources", {})
640 color_sources.update(base_color_sources)
642 colors.update(cls.colors)
643 color_sources.update(dict.fromkeys(cls.colors.keys(), cls))
645 cls.__colors = colors
646 cls.__color_sources = color_sources
647 cls.colors = _ImmutableDictProxy(cls.__colors, attr="colors")
649 msg_decorations = {}
650 msg_decoration_sources = {}
651 for base in reversed(cls.__mro__):
652 base_msg_decorations = getattr(base, "_Theme__msg_decorations", {})
653 msg_decorations.update(base_msg_decorations)
654 base_msg_decoration_sources = getattr(
655 base, "_Theme__msg_decoration_sources", {}
656 )
657 msg_decoration_sources.update(base_msg_decoration_sources)
659 msg_decorations.update(cls.msg_decorations)
660 msg_decoration_sources.update(dict.fromkeys(cls.msg_decorations.keys(), cls))
662 cls.__msg_decorations = msg_decorations
663 cls.__msg_decoration_sources = msg_decoration_sources
664 cls.msg_decorations = _ImmutableDictProxy(
665 cls.__msg_decorations, attr="msg_decorations"
666 )
668 table_drawing_symbols = {}
669 table_drawing_symbol_sources = {}
670 for base in reversed(cls.__mro__):
671 base_table_drawing_symbols = getattr(
672 base, "_Theme__table_drawing_symbols", {}
673 )
674 table_drawing_symbols.update(base_table_drawing_symbols)
675 base_table_drawing_symbol_sources = getattr(
676 base, "_Theme__table_drawing_symbol_sources", {}
677 )
678 table_drawing_symbol_sources.update(base_table_drawing_symbol_sources)
680 table_drawing_symbols.update(cls.table_drawing_symbols)
681 table_drawing_symbol_sources.update(
682 dict.fromkeys(cls.table_drawing_symbols.keys(), cls)
683 )
685 cls.__table_drawing_symbols = table_drawing_symbols
686 cls.__table_drawing_symbol_sources = table_drawing_symbol_sources
687 cls.table_drawing_symbols = _ImmutableDictProxy(
688 cls.__table_drawing_symbols, attr="table_drawing_symbols"
689 )
691 if init := cls.__dict__.get("__init__", None):
693 @functools.wraps(init)
694 def _wrapped_init(_self, *args, **kwargs):
695 prev_expected_source = _self._Theme__expected_source
696 _self._Theme__expected_source = cls
697 try:
698 return init(_self, *args, **kwargs)
699 finally:
700 _self._Theme__expected_source = prev_expected_source
702 cls.__init__ = _wrapped_init # type: ignore
704 def _set_msg_decoration_if_not_overridden(
705 self,
706 name: str,
707 msg_decoration: str,
708 /,
709 ):
710 """
711 Set message decoration by name, but only if it wasn't overridden
712 in a subclass.
714 This method should be called from ``__init__`` implementations
715 to dynamically set message decorations. It will only set the decoration
716 if it was not overridden by any child class.
718 """
720 if self.__expected_source is None:
721 raise RuntimeError(
722 "_set_msg_decoration_if_not_overridden should only be called from __init__"
723 )
724 source = self.__msg_decoration_sources.get(name, Theme)
725 # The class that's `__init__` is currently running should be a parent
726 # of the msg_decoration's source. This means that the msg_decoration was assigned by a parent.
727 if source is not None and issubclass(self.__expected_source, source):
728 self.set_msg_decoration(name, msg_decoration)
730 def set_msg_decoration(
731 self,
732 name: str,
733 msg_decoration: str,
734 /,
735 ):
736 """
737 Set message decoration by name.
739 """
741 if "_Theme__msg_decorations" not in self.__dict__:
742 self.__msg_decorations = self.__class__.__msg_decorations.copy()
743 self.__msg_decoration_sources = (
744 self.__class__.__msg_decoration_sources.copy()
745 )
746 self.msg_decorations = _ImmutableDictProxy(
747 self.__msg_decorations, attr="msg_decorations"
748 )
749 self.__msg_decorations[name] = msg_decoration
750 self.__msg_decoration_sources[name] = self.__expected_source
752 def _set_table_drawing_symbol_if_not_overridden(
753 self,
754 code: int,
755 table_drawing_symbol: str,
756 /,
757 ):
758 """
759 Set table drawing symbol by code, but only if it wasn't overridden
760 in a subclass.
762 This method should be called from ``__init__`` implementations
763 to dynamically set table drawing symbols. It will only set the symbol
764 if it was not overridden by any child class.
766 """
768 if self.__expected_source is None:
769 raise RuntimeError(
770 "_set_table_drawing_symbol_if_not_overridden should only be called from __init__"
771 )
772 source = self.__table_drawing_symbol_sources.get(code, Theme)
773 # The class that's `__init__` is currently running should be a parent
774 # of the table_drawing_symbol's source. This means that the table_drawing_symbol was assigned by a parent.
775 if source is not None and issubclass(self.__expected_source, source):
776 self.set_table_drawing_symbol(code, table_drawing_symbol)
778 def set_table_drawing_symbol(
779 self,
780 code: int,
781 table_drawing_symbol: str,
782 /,
783 ):
784 """
785 Set table drawing symbol by code.
787 """
789 if "_Theme__table_drawing_symbols" not in self.__dict__:
790 self.__table_drawing_symbols = self.__class__.__table_drawing_symbols.copy()
791 self.__table_drawing_symbol_sources = (
792 self.__class__.__table_drawing_symbol_sources.copy()
793 )
794 self.table_drawing_symbols = _ImmutableDictProxy(
795 self.__table_drawing_symbols, attr="table_drawing_symbols"
796 )
797 self.__table_drawing_symbols[code] = table_drawing_symbol
798 self.__table_drawing_symbol_sources[code] = self.__expected_source
800 def _set_color_if_not_overridden(
801 self,
802 path: str,
803 color: str | yuio.color.Color,
804 /,
805 ):
806 """
807 Set color by path, but only if the color was not overridden in a subclass.
809 This method should be called from ``__init__`` implementations
810 to dynamically set colors. It will only set the color if it was not overridden
811 by any child class.
813 """
815 if self.__expected_source is None:
816 raise RuntimeError(
817 "_set_color_if_not_overridden should only be called from __init__"
818 )
819 source = self.__color_sources.get(path, Theme)
820 # The class who's `__init__` is currently running should be a parent
821 # of the color's source. This means that the color was assigned by a parent.
822 if source is not None and issubclass(self.__expected_source, source):
823 self.set_color(path, color)
825 def set_color(
826 self,
827 path: str,
828 color: str | yuio.color.Color,
829 /,
830 ):
831 """
832 Set color by path.
834 """
836 if "_Theme__colors" not in self.__dict__:
837 self.__colors = self.__class__.__colors.copy()
838 self.__color_sources = self.__class__.__color_sources.copy()
839 self.colors = _ImmutableDictProxy(self.__colors, attr="colors")
840 self.__colors[path] = color
841 self.__color_sources[path] = self.__expected_source
842 self.__color_cache.clear()
843 self.__dict__.pop("_Theme__color_tree", None)
845 @dataclass(kw_only=True, slots=True)
846 class __ColorTree:
847 """
848 Prefix-like tree that contains all of the theme's colors.
850 """
852 colors: str | yuio.color.Color = yuio.color.Color.NONE
853 """
854 Colors in this node.
856 """
858 loc: dict[str, Theme.__ColorTree] = dataclasses.field(default_factory=dict)
859 """
860 Location part of the tree.
862 """
864 ctx: dict[str, Theme.__ColorTree] = dataclasses.field(default_factory=dict)
865 """
866 Context part of the tree.
868 """
870 @functools.cached_property
871 def __color_tree(self) -> Theme.__ColorTree:
872 root = self.__ColorTree()
874 for path, colors in self.__colors.items():
875 loc, ctx = self.__parse_path(path)
877 node = root
879 for part in loc:
880 if part not in node.loc:
881 node.loc[part] = self.__ColorTree()
882 node = node.loc[part]
884 for part in ctx:
885 if part not in node.ctx:
886 node.ctx[part] = self.__ColorTree()
887 node = node.ctx[part]
889 node.colors = colors
891 return root
893 @staticmethod
894 def __parse_path(path: str, /) -> tuple[list[str], list[str]]:
895 path_parts = path.split(":", maxsplit=1)
896 if len(path_parts) == 1:
897 loc, ctx = path_parts[0], ""
898 else:
899 loc, ctx = path_parts
900 return loc.split("/") if loc else [], ctx.split("/") if ctx else []
902 @_t.final
903 def get_color(self, paths: str, /) -> yuio.color.Color:
904 """
905 Lookup a color by path.
907 """
909 color = yuio.color.Color.NONE
910 for path in paths.split():
911 color |= self.__get_color(path)
912 return color
914 def __get_color(self, path: str, /) -> yuio.color.Color:
915 res: yuio.color.Color | None | yuio.Missing = self.__color_cache.get(
916 path, yuio.MISSING
917 )
918 if res is None:
919 warnings.warn(f"recursive color path {path!r}", RecursiveThemeWarning)
920 return yuio.color.Color.NONE
921 elif res is not yuio.MISSING:
922 return res
924 self.__color_cache[path] = None
925 if path.startswith("#") and len(path) == 7:
926 try:
927 res = yuio.color.Color.fore_from_hex(path)
928 except ValueError as e:
929 warnings.warn(f"invalid color code {path!r}: {e}", ThemeWarning)
930 res = yuio.color.Color.NONE
931 elif path[:3].lower() == "bg#" and len(path) == 9:
932 try:
933 res = yuio.color.Color.back_from_hex(path[2:])
934 except ValueError as e:
935 warnings.warn(f"invalid color code {path!r}: {e}", ThemeWarning)
936 res = yuio.color.Color.NONE
937 else:
938 loc, ctx = self.__parse_path(path)
939 res = self.__get_color_in_loc(self.__color_tree, loc, ctx)
940 self.__color_cache[path] = res
941 return res
943 def __get_color_in_loc(
944 self, node: Theme.__ColorTree, loc: list[str], ctx: list[str]
945 ):
946 color = yuio.color.Color.NONE
948 for part in loc:
949 if part not in node.loc:
950 break
951 color |= self.__get_color_in_ctx(node, ctx)
952 node = node.loc[part]
954 return color | self.__get_color_in_ctx(node, ctx)
956 def __get_color_in_ctx(self, node: Theme.__ColorTree, ctx: list[str]):
957 color = yuio.color.Color.NONE
959 for part in ctx:
960 if part not in node.ctx:
961 break
962 color |= self.__get_color_in_node(node)
963 node = node.ctx[part]
965 return color | self.__get_color_in_node(node)
967 def __get_color_in_node(self, node: Theme.__ColorTree) -> yuio.color.Color:
968 color = yuio.color.Color.NONE
970 if isinstance(node.colors, str):
971 color |= self.get_color(node.colors)
972 else:
973 color |= node.colors
975 return color
977 def to_color(
978 self, color_or_path: yuio.color.Color | str | None, /
979 ) -> yuio.color.Color:
980 """
981 Convert color or color path to color.
983 """
985 if color_or_path is None:
986 return yuio.color.Color.NONE
987 elif isinstance(color_or_path, yuio.color.Color):
988 return color_or_path
989 else:
990 return self.get_color(color_or_path)
992 def check(self):
993 """
994 Check theme for recursion.
996 This method is slow, and should be called from unit tests of your application.
998 """
1000 if "" in self.colors:
1001 warnings.warn("colors map contains an empty key", ThemeWarning)
1003 for k, v in self.colors.items():
1004 if not v:
1005 warnings.warn(f"color value for path {k!r} is empty", ThemeWarning)
1007 err_path = None
1008 with warnings.catch_warnings():
1009 warnings.simplefilter("error", category=RecursiveThemeWarning)
1010 for k in self.colors:
1011 try:
1012 self.get_color(k)
1013 except RecursiveThemeWarning:
1014 err_path = k
1015 if err_path is None:
1016 return
1018 self.__color_cache.clear()
1019 recursive_path = []
1020 get_color_inner = self.__get_color
1022 def get_color(path: str):
1023 recursive_path.append(path)
1024 return get_color_inner(path)
1026 self.__get_color = get_color
1028 try:
1029 with warnings.catch_warnings():
1030 warnings.simplefilter("error", category=RecursiveThemeWarning)
1031 self.get_color(err_path)
1032 except RecursiveThemeWarning:
1033 self.__get_color = get_color_inner
1034 else:
1035 assert False, (
1036 "unreachable, please report hitting this assert "
1037 "to https://github.com/taminomara/yuio/issues"
1038 )
1040 raise RecursiveThemeWarning(
1041 f"infinite recursion in color path {err_path!r}:\n "
1042 + "\n ".join(
1043 f"{path!r} -> {self.colors.get(path)!r}" for path in recursive_path[:-1]
1044 )
1045 )
1048Theme.__init_subclass__()
1051class DefaultTheme(Theme):
1052 """
1053 Default Yuio theme. Adapts for terminal background color,
1054 if one can be detected.
1056 This theme defines *main colors*, which you can override by subclassing.
1058 - ``"heading_color"``: for headings,
1059 - ``"primary_color"``: for main text,
1060 - ``"accent_color"``, ``"accent_color_2"``: for visually highlighted elements,
1061 - ``"secondary_color"``: for visually dimmed elements,
1062 - ``"error_color"``: for everything that indicates an error,
1063 - ``"warning_color"``: for everything that indicates a warning,
1064 - ``"success_color"``: for everything that indicates a success,
1065 - ``"critical_color"``: for critical or internal errors,
1066 - ``"low_priority_color_a"``: for auxiliary elements such as help widget,
1067 - ``"low_priority_color_b"``: for auxiliary elements such as help widget,
1068 even lower priority.
1070 """
1072 colors = {
1073 #
1074 # Main settings
1075 # -------------
1076 # This section controls the overall theme look.
1077 # Most likely you'll want to change accent colors from here.
1078 "heading_color": "bold primary_color",
1079 "primary_color": "normal",
1080 "accent_color": "magenta",
1081 "accent_color_2": "cyan",
1082 "secondary_color": "normal_dim",
1083 "error_color": "red",
1084 "warning_color": "yellow",
1085 "success_color": "green",
1086 "critical_color": "inverse error_color",
1087 "low_priority_color_a": "normal_dim",
1088 "low_priority_color_b": "normal_dim",
1089 #
1090 # Common tags
1091 # -----------
1092 "code": "italic",
1093 "note": "accent_color_2",
1094 "path": "code",
1095 "flag": "note",
1096 #
1097 # Styles
1098 # ------
1099 "bold": yuio.color.Color.STYLE_BOLD,
1100 "b": "bold",
1101 "dim": yuio.color.Color.STYLE_DIM,
1102 "d": "dim",
1103 "italic": yuio.color.Color.STYLE_ITALIC,
1104 "i": "italic",
1105 "underline": yuio.color.Color.STYLE_UNDERLINE,
1106 "u": "underline",
1107 "inverse": yuio.color.Color.STYLE_INVERSE,
1108 #
1109 # Foreground
1110 # ----------
1111 # Note: we don't have tags for background because it's impossible to guarantee
1112 # that they'll work nicely with whatever foreground you choose. Prefer using
1113 # `inverse` instead.
1114 "normal": yuio.color.Color.FORE_NORMAL,
1115 "normal_dim": yuio.color.Color.FORE_NORMAL_DIM,
1116 "black": yuio.color.Color.FORE_BLACK,
1117 "red": yuio.color.Color.FORE_RED,
1118 "green": yuio.color.Color.FORE_GREEN,
1119 "yellow": yuio.color.Color.FORE_YELLOW,
1120 "blue": yuio.color.Color.FORE_BLUE,
1121 "magenta": yuio.color.Color.FORE_MAGENTA,
1122 "cyan": yuio.color.Color.FORE_CYAN,
1123 "white": yuio.color.Color.FORE_WHITE,
1124 #
1125 # IO messages and text
1126 # --------------------
1127 "msg/decoration": "secondary_color",
1128 "msg/decoration:heading": "heading_color accent_color",
1129 "msg/decoration:thematic_break": "secondary_color",
1130 "msg/text": "primary_color",
1131 "msg/text:heading": "heading_color",
1132 "msg/text:heading/1": "accent_color",
1133 "msg/text:heading/section": "accent_color",
1134 "msg/text:question": "heading_color",
1135 "msg/text:error": "error_color",
1136 "msg/text:warning": "warning_color",
1137 "msg/text:success": "heading_color success_color",
1138 "msg/text:failure": "heading_color error_color",
1139 "msg/text:info": "primary_color",
1140 "msg/text:thematic_break": "secondary_color",
1141 #
1142 # Log messages
1143 # ------------
1144 "log/name": "dim accent_color_2",
1145 "log/pathname": "dim",
1146 "log/filename": "dim",
1147 "log/module": "dim",
1148 "log/lineno": "dim",
1149 "log/funcName": "dim",
1150 "log/created": "dim",
1151 "log/asctime": "dim",
1152 "log/msecs": "dim",
1153 "log/relativeCreated": "dim",
1154 "log/thread": "dim",
1155 "log/threadName": "dim",
1156 "log/taskName": "dim",
1157 "log/process": "dim",
1158 "log/processName": "dim",
1159 "log/levelno": "log/levelname",
1160 "log/levelno:critical": "log/levelname:critical",
1161 "log/levelno:error": "log/levelname:error",
1162 "log/levelno:warning": "log/levelname:warning",
1163 "log/levelno:info": "log/levelname:info",
1164 "log/levelno:debug": "log/levelname:debug",
1165 "log/levelname": "heading_color",
1166 "log/levelname:critical": "critical_color",
1167 "log/levelname:error": "error_color",
1168 "log/levelname:warning": "warning_color",
1169 "log/levelname:info": "success_color",
1170 "log/levelname:debug": "dim",
1171 "log/message": "primary_color",
1172 "log/message:critical": "bold error_color",
1173 "log/message:debug": "dim",
1174 "log/colMessage": "log/message",
1175 "log/colMessage:critical": "log/message:critical",
1176 "log/colMessage:error": "log/message:error",
1177 "log/colMessage:warning": "log/message:warning",
1178 "log/colMessage:info": "log/message:info",
1179 "log/colMessage:debug": "log/message:debug",
1180 #
1181 # Tasks and progress bars
1182 # -----------------------
1183 "task": "secondary_color",
1184 "task/decoration": "msg/decoration:heading",
1185 "task/decoration:running": "accent_color",
1186 "task/decoration:done": "success_color",
1187 "task/decoration:error": "error_color",
1188 "task/progressbar/done": "accent_color",
1189 "task/progressbar/done/start": "blue",
1190 "task/progressbar/done/end": "accent_color",
1191 "task/progressbar/pending": "secondary_color",
1192 "task/heading": "heading_color",
1193 "task/progress": "secondary_color",
1194 "task/comment": "primary_color",
1195 #
1196 # Syntax highlighting
1197 # -------------------
1198 "hl/kwd": "bold",
1199 "hl/str": "yellow",
1200 "hl/str/esc": "accent_color",
1201 "hl/punct": "secondary_color",
1202 "hl/comment": "green",
1203 "hl/lit": "blue",
1204 "hl/type": "cyan",
1205 "hl/prog": "bold underline",
1206 "hl/flag": "flag",
1207 "hl/metavar": "bold",
1208 "hl/meta": "accent_color",
1209 "hl/added": "green",
1210 "hl/removed": "red",
1211 "tb/heading": "bold red",
1212 "tb/message": "tb/heading",
1213 "tb/frame/usr/file/module": "accent_color",
1214 "tb/frame/usr/file/line": "accent_color",
1215 "tb/frame/usr/file/path": "accent_color",
1216 "tb/frame/usr/code": yuio.color.Color.NONE,
1217 "tb/frame/usr/highlight": "low_priority_color_a",
1218 "tb/frame/lib": "dim",
1219 "tb/frame/lib/file/module": "tb/frame/usr/file/module",
1220 "tb/frame/lib/file/line": "tb/frame/usr/file/line",
1221 "tb/frame/lib/file/path": "tb/frame/usr/file/path",
1222 "tb/frame/lib/code": "tb/frame/usr/code",
1223 "tb/frame/lib/highlight": "tb/frame/usr/highlight",
1224 #
1225 # Menu and widgets
1226 # ----------------
1227 "menu/text": "primary_color",
1228 "menu/text/heading": "menu/text heading_color",
1229 "menu/text/help_info:help": "low_priority_color_a",
1230 "menu/text/help_msg:help": "low_priority_color_b",
1231 "menu/text/help_key:help": "low_priority_color_a",
1232 "menu/text/help_sep:help": "low_priority_color_b",
1233 "menu/text/help_key:help_menu": "accent_color_2",
1234 "menu/text/help_sep:help_menu": "secondary_color",
1235 "menu/text/esc": "white on_magenta",
1236 "menu/text/comment": "accent_color_2",
1237 "menu/text:choice/active": "accent_color",
1238 "menu/text:choice/active/selected": "bold",
1239 "menu/text:choice/normal/selected": "accent_color_2 bold",
1240 "menu/text:choice/normal/dir": "blue",
1241 "menu/text:choice/normal/exec": "red",
1242 "menu/text:choice/normal/symlink": "magenta",
1243 "menu/text:choice/normal/socket": "green",
1244 "menu/text:choice/normal/pipe": "yellow",
1245 "menu/text:choice/normal/block_device": "cyan bold",
1246 "menu/text:choice/normal/char_device": "yellow bold",
1247 "menu/text/comment:choice/normal/original": "success_color",
1248 "menu/text/comment:choice/active/original": "success_color",
1249 "menu/text/comment:choice/normal/corrected": "error_color",
1250 "menu/text/comment:choice/active/corrected": "error_color",
1251 "menu/text/prefix:choice/normal": "primary_color",
1252 "menu/text/prefix:choice/normal/selected": "accent_color_2 bold",
1253 "menu/text/prefix:choice/active": "accent_color",
1254 "menu/text/prefix:choice/active/selected": "bold",
1255 "menu/text/suffix:choice/normal": "primary_color",
1256 "menu/text/suffix:choice/normal/selected": "accent_color_2 bold",
1257 "menu/text/suffix:choice/active": "accent_color",
1258 "menu/text/suffix:choice/active/selected": "bold",
1259 "menu/text:choice/status_line": "low_priority_color_b",
1260 "menu/text:choice/status_line/number": "low_priority_color_a",
1261 "menu/text/placeholder": "secondary_color",
1262 "menu/decoration": "accent_color",
1263 "menu/decoration/quick-select": "secondary_color",
1264 "menu/decoration/comment": "secondary_color",
1265 "menu/decoration:choice/normal": "menu/text",
1266 }
1267 """
1268 Colors for default theme are separated into several sections.
1270 The main section (the first one) has common settings which are referenced
1271 from all other sections. You'll probably want to override
1273 """
1275 def __init__(self, term: yuio.term.Term):
1276 super().__init__()
1278 if term.is_unicode:
1279 decorations = _MSG_DECORATIONS_UNICODE
1280 table_symbols = _TABLE_SYMBOLS_UNICODE
1281 else:
1282 decorations = _MSG_DECORATIONS_ASCII
1283 table_symbols = _TABLE_SYMBOLS_ASCII
1284 for k, v in decorations.items():
1285 self._set_msg_decoration_if_not_overridden(k, v)
1286 for k, v in table_symbols.items():
1287 self._set_table_drawing_symbol_if_not_overridden(k, v)
1289 if (colors := term.terminal_theme) is None:
1290 return
1292 # Gradients look bad in other modes.
1293 if term.supports_colors_true:
1294 self._set_color_if_not_overridden(
1295 "normal", yuio.color.Color(fore=colors.foreground)
1296 )
1297 self._set_color_if_not_overridden(
1298 "black", yuio.color.Color(fore=colors.black)
1299 )
1300 self._set_color_if_not_overridden(
1301 "red",
1302 yuio.color.Color(fore=colors.red),
1303 )
1304 self._set_color_if_not_overridden(
1305 "green", yuio.color.Color(fore=colors.green)
1306 )
1307 self._set_color_if_not_overridden(
1308 "yellow", yuio.color.Color(fore=colors.yellow)
1309 )
1310 self._set_color_if_not_overridden(
1311 "blue", yuio.color.Color(fore=colors.blue)
1312 )
1313 self._set_color_if_not_overridden(
1314 "magenta", yuio.color.Color(fore=colors.magenta)
1315 )
1316 self._set_color_if_not_overridden(
1317 "cyan", yuio.color.Color(fore=colors.cyan)
1318 )
1319 self._set_color_if_not_overridden(
1320 "white", yuio.color.Color(fore=colors.white)
1321 )
1323 if colors.lightness == yuio.term.Lightness.UNKNOWN:
1324 return
1326 background = colors.background
1327 foreground = colors.foreground
1329 if colors.lightness is colors.lightness.DARK:
1330 self._set_color_if_not_overridden(
1331 "low_priority_color_a",
1332 yuio.color.Color(
1333 fore=foreground.match_luminosity(background.lighten(0.30))
1334 ),
1335 )
1336 self._set_color_if_not_overridden(
1337 "low_priority_color_b",
1338 yuio.color.Color(
1339 fore=foreground.match_luminosity(background.lighten(0.25))
1340 ),
1341 )
1342 else:
1343 self._set_color_if_not_overridden(
1344 "low_priority_color_a",
1345 yuio.color.Color(
1346 fore=foreground.match_luminosity(background.darken(0.30))
1347 ),
1348 )
1349 self._set_color_if_not_overridden(
1350 "low_priority_color_b",
1351 yuio.color.Color(
1352 fore=foreground.match_luminosity(background.darken(0.25))
1353 ),
1354 )
1357def load(
1358 term: yuio.term.Term,
1359 theme_ctor: _t.Callable[[yuio.term.Term], Theme] | None = None,
1360 /,
1361) -> Theme:
1362 """
1363 Loads a default theme.
1365 """
1367 # NOTE: loading themes from json is beta, don't use it yet.
1369 if theme_ctor is None:
1370 theme_ctor = DefaultTheme
1372 if not (path := os.environ.get("YUIO_THEME_PATH")):
1373 return theme_ctor(term)
1375 import yuio.config
1376 import yuio.parse
1378 class ThemeData(yuio.config.Config):
1379 include: list[str] | str | None = None
1380 progress_bar_width: _t.Annotated[int, yuio.parse.Ge(0)] | None = None
1381 spinner_update_rate_ms: _t.Annotated[int, yuio.parse.Ge(0)] | None = None
1382 msg_decorations: dict[str, str] = yuio.config.field(
1383 default={},
1384 merge=lambda l, r: {**l, **r},
1385 )
1386 colors: dict[str, str] = yuio.config.field(
1387 default={},
1388 merge=lambda l, r: {**l, **r},
1389 )
1391 seen = set()
1392 stack = [pathlib.Path(path)]
1393 loaded_partials = []
1394 while stack:
1395 path = stack.pop()
1396 if path in seen:
1397 continue
1398 if not path.exists():
1399 warnings.warn(f"theme file {path} does not exist", ThemeWarning)
1400 continue
1401 if not path.is_file():
1402 warnings.warn(f"theme file {path} is not a file", ThemeWarning)
1403 continue
1404 try:
1405 loaded = ThemeData.load_from_json_file(path, ignore_unknown_fields=True)
1406 except yuio.parse.ParsingError as e:
1407 warnings.warn(str(e), ThemeWarning)
1408 continue
1409 loaded_partials.append(loaded)
1410 include = loaded.include
1411 if isinstance(include, str):
1412 include = [include]
1413 if include:
1414 stack.extend([path.parent / new_path for new_path in include])
1416 theme_data = ThemeData()
1417 for partial in reversed(loaded_partials):
1418 theme_data.update(partial)
1420 theme = theme_ctor(term)
1422 if theme_data.progress_bar_width is not None:
1423 theme.progress_bar_width = theme_data.progress_bar_width
1424 if theme_data.spinner_update_rate_ms is not None:
1425 theme.spinner_update_rate_ms = theme_data.spinner_update_rate_ms
1427 for k, v in theme_data.msg_decorations.items():
1428 theme.set_msg_decoration(k, v)
1430 for k, v in theme_data.colors.items():
1431 theme.set_color(k, v)
1433 return theme
1436class TableJunction(IntFlag):
1437 WEST = 1 << 0
1438 WEST_ALT = 1 << 1
1439 SOUTH = 1 << 2
1440 SOUTH_ALT = 1 << 3
1441 EAST = 1 << 4
1442 EAST_ALT = 1 << 5
1443 NORTH = 1 << 6
1444 NORTH_ALT = 1 << 7
1445 ALT_STYLE = 1 << 8
1447 def __repr__(self) -> str:
1448 res = "".join(
1449 [
1450 ["", "n", "", "N"][
1451 bool(self & self.NORTH) + 2 * bool(self & self.NORTH_ALT)
1452 ],
1453 ["", "e", "", "E"][
1454 bool(self & self.EAST) + 2 * bool(self & self.EAST_ALT)
1455 ],
1456 ["", "s", "", "S"][
1457 bool(self & self.SOUTH) + 2 * bool(self & self.SOUTH_ALT)
1458 ],
1459 ["", "w", "", "W"][
1460 bool(self & self.WEST) + 2 * bool(self & self.WEST_ALT)
1461 ],
1462 ["-", "="][bool(self & self.ALT_STYLE)],
1463 ]
1464 )
1465 return f"<{self.__class__.__name__} {res}>"
1468_MSG_DECORATIONS_UNICODE: dict[str, str] = {
1469 "heading/section": "",
1470 "heading/1": "⣿ ",
1471 "heading/2": "",
1472 "heading/3": "",
1473 "heading/4": "",
1474 "heading/5": "",
1475 "heading/6": "",
1476 "question": "> ",
1477 "task": "> ",
1478 "thematic_break": "╌╌╌╌╌╌╌╌",
1479 "list": "• ",
1480 "quote": "> ",
1481 "code": " " * 8,
1482 "overflow": "…",
1483 "hr/1/left_start": "─",
1484 "hr/1/left_middle": "─",
1485 "hr/1/left_end": "╴",
1486 "hr/1/middle": "─",
1487 "hr/1/right_start": "╶",
1488 "hr/1/right_middle": "─",
1489 "hr/1/right_end": "─",
1490 "hr/2/left_start": "━",
1491 "hr/2/left_middle": "━",
1492 "hr/2/left_end": "╸",
1493 "hr/2/middle": "━",
1494 "hr/2/right_start": "╺",
1495 "hr/2/right_middle": "━",
1496 "hr/2/right_end": "━",
1497 "progress_bar/start_symbol": "",
1498 "progress_bar/end_symbol": "",
1499 "progress_bar/done_symbol": "■", # "█",
1500 "progress_bar/pending_symbol": "□", # " ",
1501 "progress_bar/transition_pattern": "", # "█▉▊▋▌▍▎▏ ",
1502 "spinner/pattern": "⣤⣤⣤⠶⠛⠛⠛⠶",
1503 "spinner/static_symbol": "⣿",
1504 # TODO: support these in widgets
1505 # 'menu/current_item': '▶︎',
1506 # 'menu/selected_item': '★',
1507 # 'menu/default_item': '★',
1508 # 'menu/select': '#',
1509 # 'menu/search': '/',
1510}
1512# fmt: off
1513_TABLE_SYMBOLS_UNICODE: dict[int, str] = {
1514 0x000: " ", 0x040: "╵", 0x0C0: "╹", 0x010: "╶", 0x050: "└", 0x0D0: "┖", 0x030: "╺",
1515 0x070: "┕", 0x0F0: "┗", 0x001: "╴", 0x041: "┘", 0x0C1: "┚", 0x011: "─", 0x051: "┴",
1516 0x0D1: "┸", 0x031: "╼", 0x071: "┶", 0x0F1: "┺", 0x003: "╸", 0x043: "┙", 0x0C3: "┛",
1517 0x013: "╾", 0x053: "┵", 0x0D3: "┹", 0x033: "━", 0x073: "┷", 0x0F3: "┻", 0x004: "╷",
1518 0x044: "│", 0x0C4: "╿", 0x014: "┌", 0x054: "├", 0x0D4: "┞", 0x034: "┍", 0x074: "┝",
1519 0x0F4: "┡", 0x005: "┐", 0x045: "┤", 0x0C5: "┦", 0x015: "┬", 0x055: "┼", 0x0D5: "╀",
1520 0x035: "┮", 0x075: "┾", 0x0F5: "╄", 0x007: "┑", 0x047: "┥", 0x0C7: "┩", 0x017: "┭",
1521 0x057: "┽", 0x0D7: "╃", 0x037: "┯", 0x077: "┿", 0x0F7: "╇", 0x00C: "╻", 0x04C: "╽",
1522 0x0CC: "┃", 0x01C: "┎", 0x05C: "┟", 0x0DC: "┠", 0x03C: "┎", 0x07C: "┢", 0x0FC: "┣",
1523 0x00D: "┒", 0x04D: "┧", 0x0CD: "┨", 0x01D: "┰", 0x05D: "╁", 0x0DD: "╂", 0x03D: "┲",
1524 0x07D: "╆", 0x0FD: "╊", 0x00F: "┓", 0x04F: "┪", 0x0CF: "┫", 0x01F: "┱", 0x05F: "╅",
1525 0x0DF: "╉", 0x03F: "┳", 0x07F: "╈", 0x0FF: "╋", 0x100: " ", 0x140: "╵", 0x1C0: "║",
1526 0x110: "╶", 0x150: "└", 0x1D0: "╙", 0x130: "═", 0x170: "╘", 0x1F0: "╚", 0x101: "╴",
1527 0x141: "┘", 0x1C1: "╜", 0x111: "─", 0x151: "┴", 0x1D1: "╨", 0x131: "═", 0x171: "╧",
1528 0x1F1: "╩", 0x103: "╸", 0x143: "╛", 0x1C3: "╝", 0x113: "═", 0x153: "╧", 0x1D3: "╩",
1529 0x133: "═", 0x173: "╧", 0x1F3: "╩", 0x104: "╷", 0x144: "│", 0x1C4: "║", 0x114: "┌",
1530 0x154: "├", 0x1D4: "╟", 0x134: "╒", 0x174: "╞", 0x1F4: "╠", 0x105: "┐", 0x145: "┤",
1531 0x1C5: "╢", 0x115: "┬", 0x155: "┼", 0x1D5: "╫", 0x135: "╤", 0x175: "╪", 0x1F5: "╬",
1532 0x107: "╕", 0x147: "╡", 0x1C7: "╣", 0x117: "╤", 0x157: "╪", 0x1D7: "╬", 0x137: "╤",
1533 0x177: "╪", 0x1F7: "╬", 0x10C: "║", 0x14C: "║", 0x1CC: "║", 0x11C: "╓", 0x15C: "╟",
1534 0x1DC: "╟", 0x13C: "╔", 0x17C: "╠", 0x1FC: "╠", 0x10D: "╖", 0x14D: "╢", 0x1CD: "╢",
1535 0x11D: "╥", 0x15D: "╫", 0x1DD: "╫", 0x13D: "╦", 0x17D: "╬", 0x1FD: "╬", 0x10F: "╗",
1536 0x14F: "╣", 0x1CF: "╣", 0x11F: "╦", 0x15F: "╬", 0x1DF: "╬", 0x13F: "╦", 0x17F: "╬",
1537 0x1FF: "╬",
1538}
1539# fmt: on
1541_MSG_DECORATIONS_ASCII: dict[str, str] = {
1542 "heading/section": "",
1543 "heading/1": "# ",
1544 "heading/2": "",
1545 "heading/3": "",
1546 "heading/4": "",
1547 "heading/5": "",
1548 "heading/6": "",
1549 "question": "> ",
1550 "task": "> ",
1551 "thematic_break": "-" * 8,
1552 "list": "* ",
1553 "quote": "> ",
1554 "code": " " * 8,
1555 "overflow": "~",
1556 "progress_bar/start_symbol": "[",
1557 "progress_bar/end_symbol": "]",
1558 "progress_bar/done_symbol": "-",
1559 "progress_bar/pending_symbol": " ",
1560 "progress_bar/transition_pattern": ">",
1561 "spinner/pattern": "|||/-\\",
1562 "spinner/static_symbol": ">",
1563 "hr/1/left_start": "-",
1564 "hr/1/left_middle": "-",
1565 "hr/1/left_end": " ",
1566 "hr/1/middle": "-",
1567 "hr/1/right_start": " ",
1568 "hr/1/right_middle": "-",
1569 "hr/1/right_end": "-",
1570 "hr/2/left_start": "=",
1571 "hr/2/left_middle": "=",
1572 "hr/2/left_end": " ",
1573 "hr/2/middle": "=",
1574 "hr/2/right_start": " ",
1575 "hr/2/right_middle": "=",
1576 "hr/2/right_end": "=",
1577 # TODO: support these in widgets
1578 # 'menu/current_item': '>',
1579 # 'menu/selected_item': '*',
1580 # 'menu/default_item': '*',
1581 # 'menu/select': '#',
1582 # 'menu/search': '/',
1583}
1585# fmt: off
1586_TABLE_SYMBOLS_ASCII: dict[int, str] = {
1587 0x000: " ", 0x040: "+", 0x0C0: "+", 0x010: "+", 0x050: "+", 0x0D0: "+", 0x030: "+",
1588 0x070: "+", 0x0F0: "+", 0x001: "+", 0x041: "+", 0x0C1: "+", 0x011: "-", 0x051: "+",
1589 0x0D1: "+", 0x031: "+", 0x071: "+", 0x0F1: "+", 0x003: "+", 0x043: "+", 0x0C3: "+",
1590 0x013: "+", 0x053: "+", 0x0D3: "+", 0x033: "=", 0x073: "+", 0x0F3: "+", 0x004: "+",
1591 0x044: "|", 0x0C4: "+", 0x014: "+", 0x054: "+", 0x0D4: "+", 0x034: "+", 0x074: "+",
1592 0x0F4: "+", 0x005: "+", 0x045: "+", 0x0C5: "+", 0x015: "+", 0x055: "+", 0x0D5: "+",
1593 0x035: "+", 0x075: "+", 0x0F5: "+", 0x007: "+", 0x047: "+", 0x0C7: "+", 0x017: "+",
1594 0x057: "+", 0x0D7: "+", 0x037: "+", 0x077: "+", 0x0F7: "+", 0x00C: "+", 0x04C: "+",
1595 0x0CC: "|", 0x01C: "+", 0x05C: "+", 0x0DC: "+", 0x03C: "+", 0x07C: "+", 0x0FC: "+",
1596 0x00D: "+", 0x04D: "+", 0x0CD: "+", 0x01D: "+", 0x05D: "+", 0x0DD: "+", 0x03D: "+",
1597 0x07D: "+", 0x0FD: "+", 0x00F: "+", 0x04F: "+", 0x0CF: "+", 0x01F: "+", 0x05F: "+",
1598 0x0DF: "+", 0x03F: "+", 0x07F: "+", 0x0FF: "+", 0x100: " ", 0x140: "+", 0x1C0: "#",
1599 0x110: "+", 0x150: "+", 0x1D0: "#", 0x130: "#", 0x170: "#", 0x1F0: "#", 0x101: "+",
1600 0x141: "+", 0x1C1: "#", 0x111: "-", 0x151: "+", 0x1D1: "#", 0x131: "#", 0x171: "#",
1601 0x1F1: "#", 0x103: "#", 0x143: "#", 0x1C3: "#", 0x113: "#", 0x153: "#", 0x1D3: "#",
1602 0x133: "*", 0x173: "#", 0x1F3: "#", 0x104: "+", 0x144: "|", 0x1C4: "#", 0x114: "+",
1603 0x154: "+", 0x1D4: "#", 0x134: "#", 0x174: "#", 0x1F4: "#", 0x105: "+", 0x145: "+",
1604 0x1C5: "#", 0x115: "+", 0x155: "+", 0x1D5: "#", 0x135: "#", 0x175: "#", 0x1F5: "#",
1605 0x107: "#", 0x147: "#", 0x1C7: "#", 0x117: "#", 0x157: "#", 0x1D7: "#", 0x137: "#",
1606 0x177: "#", 0x1F7: "#", 0x10C: "#", 0x14C: "#", 0x1CC: "*", 0x11C: "#", 0x15C: "#",
1607 0x1DC: "#", 0x13C: "#", 0x17C: "#", 0x1FC: "#", 0x10D: "#", 0x14D: "#", 0x1CD: "#",
1608 0x11D: "#", 0x15D: "#", 0x1DD: "#", 0x13D: "#", 0x17D: "#", 0x1FD: "#", 0x10F: "#",
1609 0x14F: "#", 0x1CF: "#", 0x11F: "#", 0x15F: "#", 0x1DF: "#", 0x13F: "#", 0x17F: "#",
1610 0x1FF: "#",
1611}
1612# fmt: on