Coverage for yuio / theme.py: 98%
357 statements
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-03 15:42 +0000
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-03 15:42 +0000
1# Yuio project, MIT license.
2#
3# https://github.com/taminomara/yuio/
4#
5# You're free to copy this file to your project and edit it for your needs,
6# just keep this copyright line please :3
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:: separate_headings
25 .. autoattribute:: fallback_width
27 .. autoattribute:: msg_decorations_unicode
29 .. automethod:: set_msg_decoration_unicode
31 .. automethod:: _set_msg_decoration_unicode_if_not_overridden
33 .. autoattribute:: msg_decorations_ascii
35 .. automethod:: set_msg_decoration_ascii
37 .. automethod:: _set_msg_decoration_ascii_if_not_overridden
39 .. autoattribute:: colors
41 .. automethod:: set_color
43 .. automethod:: _set_color_if_not_overridden
45 .. automethod:: get_color
47 .. automethod:: to_color
49 .. automethod:: check
52Default theme
53-------------
55Use the following loader to create an instance of the default theme:
57.. autofunction:: load
59.. autoclass:: BaseTheme
61.. autoclass:: DefaultTheme
64.. _all-color-paths:
66Color paths
67-----------
69.. _common-tags:
71.. color-path:: common tags
73 :class:`BaseTheme` sets up commonly used colors that you can use
74 in formatted messages:
76 - ``code``: inline code,
77 - ``note``: inline highlighting,
78 - ``path``: file paths,
79 - ``flag``: CLI flags,
80 - ``bold``, ``b``: font style,
81 - ``dim``, ``d``: font style,
82 - ``italic``, ``i``: font style,
83 - ``underline``, ``u``: font style,
84 - ``inverse``: swap foreground and background colors,
85 - ``normal``: normal foreground,
86 - ``muted``: muted foreground (see :attr:`~yuio.color.Color.FORE_NORMAL_DIM`),
87 - ``red``: foreground,
88 - ``green``: foreground,
89 - ``yellow``: foreground,
90 - ``blue``: foreground,
91 - ``magenta``: foreground,
92 - ``cyan``: foreground,
94 .. note::
96 We don't define ``black`` and ``white`` because they can be invisible
97 with some terminal themes. Prefer ``muted`` when you need a muted color.
99 We also don't define tags for backgrounds because there's no way to tell
100 which foreground/background combination will be readable and which will not.
101 Prefer ``inverse`` when you need to add a background.
103.. _main-colors:
105.. color-path:: main colors
107 :class:`DefaultTheme` defines *main colors*, which you can override by subclassing.
109 - ``heading_color``: for headings,
110 - ``primary_color``: for main text,
111 - ``accent_color``, ``accent_color_2``: for visually highlighted elements,
112 - ``secondary_color``: for visually dimmed elements,
113 - ``error_color``: for everything that indicates an error,
114 - ``warning_color``: for everything that indicates a warning,
115 - ``success_color``: for everything that indicates a success,
116 - ``critical_color``: for critical or internal errors,
117 - ``low_priority_color_a``: for auxiliary elements such as help widget,
118 - ``low_priority_color_b``: for auxiliary elements such as help widget,
119 even lower priority.
121.. _term-colors:
123.. color-path:: `term/{color}`
125 :class:`DefaultTheme` will export default colors for the attached terminal
126 as :samp:`term/{color}`. This is useful when defining gradients for progress bars,
127 as they require exact color values for interpolation.
129 ``color`` can be one ``background``, ``foreground``, ``black``, ``bright_black``,
130 ``red``, ``bright_red``, ``green``, ``bright_green``, ``yellow``, ``bright_yellow``,
131 ``blue``, ``bright_blue``, ``magenta``, ``bright_magenta``,
132 ``cyan``, ``bright_cyan``, ``white``, or ``bright_white``.
134.. color-path:: `msg/decoration:{tag}`
136 Color for decorations in front of messages:
138 - ``msg/decoration:info``: messages from :mod:`yuio.io.info`,
139 - ``msg/decoration:warning``: messages from :mod:`yuio.io.warning`,
140 - ``msg/decoration:error``: messages from :mod:`yuio.io.error`,
141 - ``msg/decoration:success``: messages from :mod:`yuio.io.success`,
142 - ``msg/decoration:failure``: messages from :mod:`yuio.io.failure`,
143 - :samp:`msg/decoration:heading/{level}`: messages from :mod:`yuio.io.heading`
144 and headings in markdown,
145 - ``msg/decoration:heading/section``: first-level headings in CLI help,
146 - ``msg/decoration:question``: messages from :func:`yuio.io.ask`,
147 - ``msg/decoration:list``: bullets in markdown,
148 - ``msg/decoration:quote``: quote decorations in markdown,
149 - ``msg/decoration:code``: code decorations in markdown,
150 - ``msg/decoration:thematic_break``: thematic breaks
151 (i.e. horizontal rulers) in markdown,
152 - :samp:`msg/decoration:hr/{weight}`: horizontal rulers (see :func:`yuio.io.hr`
153 and :func:`yuio.string.Hr`).
155.. color-path:: `msg/text:{tag}`
157 Color for the text part of messages:
159 - ``msg/text:info`` and all other tags from ``msg/decoration``,
160 - ``msg/text:paragraph``: plain text in markdown,
161 - :samp:`msg/text:code/{syntax}`: plain text in highlighted code blocks.
163.. color-path:: `task/...:{status}`
165 Running and finished tasks:
167 - :samp:`task/decoration`: decoration before the task,
168 - ``task/progressbar/done``: filled portion of the progress bar,
169 - ``task/progressbar/done/start``: gradient start for the filled
170 portion of the progress bar,
171 - ``task/progressbar/done/end``: gradient end for the filled
172 portion of the progress bar,
173 - ``task/progressbar/pending``: unfilled portion of the progress bar,
174 - ``task/progressbar/pending/start``: gradient start for the unfilled
175 portion of the progress bar,
176 - ``task/progressbar/pending/end``: gradient end for the unfilled
177 portion of the progress bar,
178 - ``task/heading``: task title,
179 - ``task/progress``: number that indicates task progress,
180 - ``task/comment``: task comment.
182 ``status`` can be ``running``, ``done``, or ``error``.
184.. color-path:: `hl/{token}:{syntax}`
186 Color for highlighted part of code:
188 - ``hl/comment``: code comments,
189 - ``hl/kwd``: keyword,
190 - ``hl/lit``: non-string literals,
191 - ``hl/lit/builtin``: built-in literals, i.e. ``None``, ``True``, ``False``,
192 - ``hl/lit/num``: numeric literals,
193 - ``hl/lit/num/bin``: binary numeric literals,
194 - ``hl/lit/num/oct``: octal numeric literals,
195 - ``hl/lit/num/dec``: decimal numeric literals,
196 - ``hl/lit/num/hex``: hexadecimal numeric literals,
197 - ``hl/punct``: punctuation,
198 - ``hl/str``: string literals,
199 - ``hl/str/esc``: escape sequences in strings,
200 - ``hl/str/prefix``: string prefix, i.e. ``f`` in ``f"str"``,
201 - ``hl/type``: type names,
202 - ``hl/type/builtin``: type names for builtin types,
203 - ``hl/type/user``: type names for user-defined types,
204 - ``hl/meta``: diff meta info for diff highlighting,
205 - ``hl/added``: added lines in diff highlighting,
206 - ``hl/removed``: removed lines in diff highlighting,
207 - ``hl/prog``: program name in CLI usage and shell highlighting,
208 - ``hl/flag``: CLI flags,
209 - ``hl/metavar``: meta variables in CLI usage.
211.. color-path:: `tb/heading`
212 `tb/message`
213 `tb/frame/{location}/...`
215 For highlighted tracebacks:
217 - ``tb/heading``: traceback heading,
218 - ``tb/message``: error message,
219 - :samp:`tb/frame/{location}/file/module`: module name,
220 - :samp:`tb/frame/{location}/file/line`: line number,
221 - :samp:`tb/frame/{location}/file/path`: file path,
222 - :samp:`tb/frame/{location}/code`: code sample at the error line,
223 - :samp:`tb/frame/{location}/highlight`: highlighting under the code sample.
225 ``location`` is either ``lib`` or ``usr`` depending on whether the code
226 is located in site-packages or in user code.
228.. color-path:: `log/{part}:{level}`
230 Colors for log records. ``part`` is name of a `log record attribute`__,
231 level is lowercase name of logging level.
233 __ https://docs.python.org/3/library/logging.html#logrecord-attributes
235 .. seealso::
237 :class:`yuio.io.Formatter`.
239.. color-path:: input widget
241 Colors for :class:`yuio.widget.Input`:
243 - ``menu/decoration:input``: decoration before an input box,
244 - ``menu/text:input``: entered text in an input box,
245 - ``menu/text/esc:input``: highlights for invisible characters in an input box,
246 - ``menu/text/error:input``: highlights for error region reported by a parser,
247 - ``menu/text/placeholder:input``: placeholder text in an input box,
249.. color-path:: grid widgets
251 Colors for :class:`yuio.widget.Grid`, :class:`yuio.widget.Choice`, and other
252 similar widgets:
254 - :samp:`menu/decoration:choice/{status}/{color_tag}`:
255 decoration before a grid item,
256 - :samp:`menu/decoration/comment:choice/{status}/{color_tag}`:
257 decoration around comments for a grid item,
258 - :samp:`menu/text:choice/{status}/{color_tag}`:
259 text of a grid item,
260 - :samp:`menu/text/comment:choice/{status}/{color_tag}`:
261 comment for a grid item,
262 - :samp:`menu/text/prefix:choice/{status}/{color_tag}`:
263 prefix before the main text of a grid item
264 (see :attr:`yuio.widget.Option.display_text_prefix` and
265 :attr:`yuio.complete.Completion.dprefix`),
266 - :samp:`menu/text/suffix:choice/{status}/{color_tag}`:
267 suffix after the main text of a grid item
268 (see :attr:`yuio.widget.Option.display_text_suffix` and
269 :attr:`yuio.complete.Completion.dsuffix`),
270 - ``menu/text:choice/status_line``: status line (i.e. "Page x of y").
271 - ``menu/text:choice/status_line/number``: page numbers in a status line.
273 ``status`` is either ``normal`` or ``active``:
275 - ``normal`` for regular grid items,
276 - ``active`` for the currently selected item.
278 ``color_tag`` is whatever tag specified by :attr:`yuio.widget.Option.color_tag`
279 and :attr:`yuio.complete.Completion.group_color_tag`. Currently supported tags:
281 - ``none``: color tag is not given,
282 - ``selected``: items selected in :class:`yuio.widget.Multiselect`,
283 - ``dir``: directory (in file completion),
284 - ``exec``: executable file (in file completion),
285 - ``symlink``: symbolic link (in file completion),
286 - ``socket``: socket (in file completion),
287 - ``pipe``: FIFO pipe (in file completion),
288 - ``block_device``: block device (in file completion),
289 - ``char_device``: character device (in file completion),
290 - ``original``: original completion item before spelling correction,
291 - ``corrected``: completion item that was found after spelling correction.
293.. color-path:: full screen help menu
295 Colors for help menu that appears when pressing :kbd:`F1`:
297 - ``menu/text/heading:help_menu``: section heading,
298 - ``menu/text/help_key:help_menu``: key names,
299 - ``menu/text/help_sep:help_menu``: separators between key names,
300 - ``menu/decoration:help_menu``: decorations.
302.. color-path:: inline help menu
304 Colors for help items rendered under a widget:
306 - ``menu/text/help_info:help``: help items that aren't associated with any key,
307 - ``menu/text/help_msg:help``: regular help items,
308 - ``menu/text/help_key:help``: keybinding names,
309 - ``menu/text/help_sep:help``: separator between items.
312.. _all-decorations:
314Decorations
315-----------
317.. decoration-path:: `info`
319 Messages from :mod:`yuio.io.info`.
321.. decoration-path:: `warning`
323 Messages from :mod:`yuio.io.warning`.
325.. decoration-path:: `error`
327 Messages from :mod:`yuio.io.error`.
329.. decoration-path:: `success`
331 Messages from :mod:`yuio.io.success`.
333.. decoration-path:: `failure`
335 Messages from :mod:`yuio.io.failure`.
337.. decoration-path:: `heading/{level}`
339 Messages from :mod:`yuio.io.heading` and headings in markdown.
341.. decoration-path:: `heading/section`
343 First-level headings in CLI help.
345.. decoration-path:: `question`
347 Messages from :func:`yuio.io.ask`.
349.. decoration-path:: `list`
351 Bullets in markdown.
353.. decoration-path:: `quote`
355 Quote decorations in markdown.
357.. decoration-path:: `code`
359 Code decorations in markdown.
361.. decoration-path:: `thematic_break`
363 Thematic breaks (i.e. horizontal rulers) in markdown.
365.. decoration-path:: `overflow`
367 Ellipsis symbol for lines that don't fit terminal width. Must be one character wide.
369.. decoration-path:: `progress_bar/{position}`
371 Decorations for progress bars.
373 Available positions are:
375 :``start_symbol``:
376 Start of the progress bar.
377 :``done_symbol``:
378 Tiles finished portion of the progress bar, must be one character wide.
379 :``pending_symbol``:
380 Tiles unfinished portion of the progress bar, must be one character wide.
381 :``end_symbol``:
382 End of the progress bar.
383 :``transition_pattern``:
384 If this decoration is empty, there's no symbol between finished and unfinished
385 parts of the progress bar.
387 Otherwise, this decoration defines a left-to-right gradient of transition
388 characters, ordered from most filled to least filled. Each character
389 must be one character wide.
391 .. raw:: html
393 <div class="highlight-text notranslate">
394 <div class="highlight">
395 <pre class="ascii-graphics">
396 <span class="k">[------> ]</span>
397 │└┬───┘│└┬───────┘│
398 │ │ │ │ end_symbol
399 │ │ │ └ pending_symbol
400 │ │ └ transition_pattern
401 │ └ done_symbol
402 └ start_symbol
403 </pre>
404 </div>
405 </div>
407 **Example:**
409 To get the classic blocky look, you can do the following:
411 .. code-block:: python
413 class BlockProgressTheme(yuio.theme.DefaultTheme):
414 msg_decorations = {
415 "progress_bar/start_symbol": "|",
416 "progress_bar/end_symbol": "|",
417 "progress_bar/done_symbol": "█",
418 "progress_bar/pending_symbol": " ",
419 "progress_bar/transition_pattern": "█▉▊▋▌▍▎▏ ",
420 }
422.. decoration-path:: `spinner/pattern`
424 Defines a sequence of symbols that will be used to show spinners for tasks
425 without known progress. Next element of the sequence will be shown
426 every :attr:`~Theme.spinner_update_rate_ms`.
428 You can find some pre-made patterns in py-spinners__ package.
430 __ https://github.com/ManrajGrover/py-spinners?tab=readme-ov-file
432.. decoration-path:: `spinner/static_symbol`
434 Static spinner symbol, for sub-tasks that've finished running but'.
436.. decoration-path:: `hr/{weight}/{position}`
438 Decorations for horizontal rulers (see :func:`yuio.io.hr`
439 and :func:`yuio.string.Hr`).
441 Default theme defines three weights:
443 - ``0`` prints no ruler (but still prints centered text),
444 - ``1`` prints normal ruler,
445 - ``2`` prints bold ruler.
447 Available positions are:
449 :``left_start``:
450 Start of the ruler to the left of the message.
451 :``left_middle``:
452 Filler of the ruler to the left of the message.
453 :``left_end``:
454 End of the ruler to the left of the message.
455 :``middle``:
456 Filler of the ruler that's used if `msg` is empty.
457 :``right_start``:
458 Start of the ruler to the right of the message.
459 :``right_middle``:
460 Filler of the ruler to the right of the message.
461 :``right_end``:
462 End of the ruler to the right of the message.
464 .. raw:: html
466 <div class="highlight-text notranslate">
467 <div class="highlight">
468 <pre class="ascii-graphics">
469 <span class="k"><------>message<------></span>
470 │└┬───┘│ │└┬───┘│
471 │ │ │ │ │ right_end
472 │ │ │ │ └ right_middle
473 │ │ │ └ right_start
474 │ │ └ left_end
475 │ └ left_middle
476 └ left_start
478 <span class="k"><---------------------></span>
479 │└┬──────────────────┘│
480 │ middle right_end
481 └ left_start
482 </pre>
483 </div>
484 </div>
486.. decoration-path:: input widget
488 Decorations for :class:`yuio.widget.Input`:
490 - ``menu/input/decoration``: decoration before an input box,
491 - ``menu/input/decoration_search``: decoration before a search input box.
493.. decoration-path:: choice and multiselect widget
495 Decorations for :class:`yuio.widget.Choice` and :class:`yuio.widget.Multiselect`:
497 - ``menu/choice/decoration/active_item``: current item,
498 - ``menu/choice/decoration/selected_item``: selected item in multiselect widget,
499 - ``menu/choice/decoration/deselected_item``: deselected item in multiselect widget.
501.. decoration-path:: inline help and help menu
503 Decorations for widget help:
505 - ``menu/help/decoration``: decoration at the bottom of the help menu,
506 - :samp:`menu/help/key/{key}`: text for functional keys.
508 ``key`` can be one of ``ctrl``, ``shift``, ``enter``, ``escape``, ``insert``,
509 ``delete``, ``backspace``, ``tab``, ``home``, ``end``, ``page_up``,
510 ``page_down``, ``arrow_up``, ``arrow_down``, ``arrow_left``, ``arrow_right``,
511 ``space``, ``f1``...\\ ``f12``.
513"""
515from __future__ import annotations
517import dataclasses
518import functools
519import os
520import pathlib
521import warnings
522from dataclasses import dataclass
523from enum import IntFlag
525import yuio.color
526import yuio.term
528from typing import TYPE_CHECKING
530if TYPE_CHECKING:
531 import typing_extensions as _t
532else:
533 from yuio import _typing as _t
535__all__ = [
536 "BaseTheme",
537 "DefaultTheme",
538 "RecursiveThemeWarning",
539 "TableJunction",
540 "Theme",
541 "ThemeWarning",
542 "load",
543]
545K = _t.TypeVar("K")
546V = _t.TypeVar("V")
549class ThemeWarning(yuio.YuioWarning):
550 pass
553class RecursiveThemeWarning(ThemeWarning):
554 pass
557_COLOR_NAMES = [
558 "background",
559 "foreground",
560 "black",
561 "bright_black",
562 "red",
563 "bright_red",
564 "green",
565 "bright_green",
566 "yellow",
567 "bright_yellow",
568 "blue",
569 "bright_blue",
570 "magenta",
571 "bright_magenta",
572 "cyan",
573 "bright_cyan",
574 "white",
575 "bright_white",
576]
579@_t.final
580class _ImmutableDict(_t.Mapping[K, V], _t.Generic[K, V]):
581 def __init__(
582 self, data: dict[K, V], sources: dict[K, type[Theme] | None], attr: str
583 ):
584 self.__data = data
585 self.__sources = sources
586 self.__attr = attr
588 def items(self) -> _t.ItemsView[K, V]:
589 return self.__data.items()
591 def keys(self) -> _t.KeysView[K]:
592 return self.__data.keys()
594 def values(self) -> _t.ValuesView[V]:
595 return self.__data.values()
597 def __len__(self):
598 return len(self.__data)
600 def __getitem__(self, key):
601 return self.__data[key]
603 def __iter__(self):
604 return iter(self.__data)
606 def __contains__(self, key):
607 return key in self.__data
609 def __repr__(self):
610 return repr(self.__data)
612 def __setitem__(self, key, item):
613 raise TypeError(f"Theme.{self.__attr} is immutable")
615 def __delitem__(self, key):
616 raise TypeError(f"Theme.{self.__attr} is immutable")
618 def copy(self) -> _t.Self:
619 return self.__class__(
620 self.__data.copy(),
621 self.__sources.copy(),
622 self.__attr,
623 )
625 def _set(self, key: K, value: V, source: type[Theme]):
626 self.__data[key] = value
627 self.__sources[key] = source
629 def _set_if_not_overridden(self, key: K, value: V, source: type[Theme] | None):
630 if source is None:
631 raise TypeError(
632 f"Theme._set_{self.__attr}_if_not_overridden can't be called "
633 "outside of __init__"
634 )
635 prev_source = self.__sources.get(key)
636 if prev_source is None or issubclass(source, prev_source):
637 self._set(key, value, source)
640@_t.final
641class _ReadOnlyDescriptor:
642 def __set_name__(self, owner: object, attr: str):
643 self.__attr = attr
644 self.__private_name = f"_Theme__{attr}"
646 def __get__(self, instance: object | None, owner: type[object] | None = None):
647 if instance is None: # pragma: no cover
648 return self
649 elif (data := instance.__dict__.get(self.__private_name)) is not None:
650 return data
651 else:
652 data = owner.__dict__[self.__private_name].copy()
653 instance.__dict__[self.__private_name] = data
654 return data
656 def __set__(self, instance: object, value: _t.Any):
657 raise TypeError(f"Theme.{self.__attr} is immutable")
659 def __delete__(self, instance: object):
660 raise TypeError(f"Theme.{self.__attr} is immutable")
663class _ThemeMeta(type):
664 # BEWARE OF MAGIC!
665 #
666 #
667 # Descriptors
668 # -----------
669 #
670 # _ThemeMeta.__dict__["colors"]
671 # this is a `_ReadOnlyDescriptor` that handles access to `Theme.colors`,
672 # proxying it to `Theme.__dict__["_Theme__colors"]`.
673 #
674 # Accessing `Theme.colors` is equivalent to calling
675 # `_ThemeMeta.__dict__["colors"].__get__(Theme)`,
676 # which in turn will return `Theme.__dict__["_Theme__colors"]`.
677 #
678 # Value for `Theme.__dict__["_Theme__colors"]` is assigned by this metaclass.
679 #
680 # Theme.__dict__["colors"]
681 # this is a `_ReadOnlyDescriptor` that handles access to `theme.colors`,
682 # proxying it to `theme.__dict__["_Theme__colors"]`.
683 #
684 # Accessing `theme.colors` is equivalent to calling
685 # `Theme.__dict__["colors"].__get__(theme)`,
686 # which in turn will return `theme.__dict__["_Theme__colors"]`.
687 #
688 # If `theme.__dict__` does not contain `"_Theme__colors"`, then it will assign
689 # `theme.__dict__["_Theme__colors"] = Theme.__dict__["_Theme__colors"].copy()`.
690 #
691 # theme.__dict__["colors"]
692 # this attribute does not exist. Accessing `theme.colors` is handled
693 # by its descriptor.
694 #
695 #
696 # Data
697 # ----
698 #
699 # Theme.__dict__["_Theme__colors"]
700 # this is the data returned when accessing `Theme.colors`. It contains
701 # an `_ImmutableDict` with combination of all colors from all bases.
702 #
703 # Theme.__dict__["_Theme__colors__orig"]
704 # this is original data assigned to `colors` variable in `Theme`'s namespace.
705 #
706 # For example:
707 #
708 # class MyTheme(Theme):
709 # colors = {"foo": "#000000"}
710 #
711 # In this class:
712 #
713 # - `MyTheme.__dict__["_Theme__colors"]` will contain combination of
714 # colors defined in `Theme` and in `MyTheme`.
715 # - `MyTheme.__dict__["_Theme__colors__orig"]` will contain initial dict
716 # `{"foo": "#000000"}`.
717 #
718 # theme.__dict__["_Theme__colors"]
719 # this is lazily initialized copy of `Theme.__dict__["_Theme__colors"]`;
720 # `Theme.set_color` will mutate this value.
722 _managed_attrs = ["msg_decorations_ascii", "msg_decorations_unicode", "colors"]
723 for _attr in _managed_attrs:
724 locals()[_attr] = _ReadOnlyDescriptor()
725 del _attr # type: ignore
727 def __new__(mcs, name, bases, ns, **kwargs):
728 # Pop any overrides from class' namespace and save them in `_Theme__attr__orig`.
729 # Set up read-only descriptors for managed attributes.
730 for attr in mcs._managed_attrs:
731 ns[f"_Theme__{attr}__orig"] = ns.pop(attr, {})
732 ns[attr] = _ReadOnlyDescriptor()
734 # Create metaclass instance.
735 cls = super().__new__(mcs, name, bases, ns, **kwargs)
737 # Set up class-level data for managed attributes.
738 for attr in mcs._managed_attrs:
739 setattr(cls, f"_Theme__{attr}", mcs._collect_data(cls, attr))
741 # Patch `__init__` so that it handles `__expected_source`.
742 if init := cls.__dict__.get("__init__", None):
744 @functools.wraps(init)
745 def _wrapped_init(self, *args, **kwargs):
746 prev_expected_source = self._Theme__expected_source
747 self._Theme__expected_source = cls
748 try:
749 return init(self, *args, **kwargs)
750 finally:
751 self._Theme__expected_source = prev_expected_source
753 setattr(cls, "__init__", _wrapped_init)
755 return cls
757 def _collect_data(cls, attr):
758 attr_orig = f"_Theme__{attr}__orig"
759 data = {}
760 sources = {}
761 for base in reversed(cls.__mro__):
762 if base_data := base.__dict__.get(attr_orig):
763 data.update(base_data)
764 sources.update(dict.fromkeys(base_data, base))
765 return _ImmutableDict(data, sources, attr)
768class Theme(metaclass=_ThemeMeta):
769 """
770 Base class for Yuio themes.
772 .. warning::
774 Do not change theme contents after it was passed to :func:`yuio.io.setup`.
775 Otherwise there's a risc of race conditions.
777 """
779 msg_decorations_unicode: _t.Mapping[str, str] = {}
780 """
781 Decorative symbols for certain text elements, such as headings,
782 list items, etc.
784 This mapping becomes immutable once a theme class is created. The only possible
785 way to modify it is by using :meth:`~Theme.set_msg_decoration_ascii`
786 or :meth:`~Theme._set_msg_decoration_ascii_if_not_overridden`.
788 """
790 msg_decorations_ascii: _t.Mapping[str, str] = {}
791 """
792 Like :attr:`~Theme.msg_decorations_unicode`, but suitable for non-unicode terminals.
794 """
796 progress_bar_width: int = 15
797 """
798 Width of a progress bar for :class:`yuio.io.Task`.
800 """
802 spinner_update_rate_ms: int = 200
803 """
804 How often the spinner pattern changes.
806 """
808 separate_headings: bool = True
809 """
810 Whether to print newlines before and after :func:`yuio.io.heading`.
812 """
814 fallback_width: int = 80
815 """
816 Preferred width that will be used if printing to a stream that's redirected
817 to a file.
819 """
821 colors: _t.Mapping[str, str | yuio.color.Color] = {}
822 """
823 Mapping of color paths to actual colors.
825 Themes use color paths to describe styles and colors for different
826 parts of an application. Color paths are similar to file paths,
827 they use snake case identifiers separated by slashes, and consist of
828 two parts separated by a colon.
830 The first part represents an object, i.e. what we're coloring.
832 The second part represents a context, i.e. what is the state or location
833 of an object that we're coloring.
835 For example, a color for the filled part of the task's progress bar
836 has path ``"task/progressbar/done"``, a color for a text of an error
837 log record has path ``"log/message:error"``, and a color for a string escape
838 sequence in a highlighted python code has path ``"hl/str/esc:python"``.
840 A color at a certain path is propagated to all sub-paths. For example,
841 if ``"task/progressbar"`` is bold, and ``"task/progressbar/done"`` is green,
842 the final color will be bold green.
844 Each color path can be associated with an instance of :class:`~yuio.color.Color`
845 or with another path.
847 If path is mapped to a :class:`~yuio.color.Color`, then the path is associated
848 with that particular color.
850 If path is mapped to another path, then the path is associated with
851 the color value for that other path (please don't create recursions here).
853 You can combine multiple paths within the same mapping by separating them with
854 whitespaces. In this case colors for those paths are combined.
856 For example:
858 .. code-block:: python
860 colors = {
861 "heading_color": "bold",
862 "error_color": "red",
863 "tb/heading": "heading_color error_color",
864 }
866 Here, color of traceback's heading ``"tb/heading"`` will be bold and red.
868 When deriving from a theme, you can override this mapping. When looking up
869 colors via :meth:`~Theme.get_color`, base classes will be tried for color,
870 in order of method resolution.
872 This mapping becomes immutable once a theme class is created. The only possible
873 way to modify it is by using :meth:`~Theme.set_color`
874 or :meth:`~Theme._set_color_if_not_overridden`.
876 """
878 __expected_source: type[Theme] | None = None
879 """
880 When running an ``__init__`` function, this variable will be set to the class
881 that implemented it, regardless of type of `self`.
883 That is, inside ``DefaultTheme.__init__``, ``__expected_source`` is set
884 to ``DefaultTheme``, in ``MyTheme.__init__`` it is ``MyTheme``, etc.
886 This is possible because ``_ThemeMeta`` wraps any implementation
887 of ``__init__`` into a wrapper that sets this variable.
889 """
891 def __init__(self):
892 self.__color_cache: dict[str, yuio.color.Color | None] = {}
894 def _set_msg_decoration_unicode_if_not_overridden(
895 self,
896 name: str,
897 msg_decoration: str,
898 /,
899 ):
900 """
901 Set Unicode message decoration by name, but only if it wasn't overridden
902 in a subclass.
904 This method should be called from ``__init__`` implementations
905 to dynamically set message decorations. It will only set the decoration
906 if it was not overridden by any child class.
908 """
910 proxy = _t.cast(_ImmutableDict[str, str], self.msg_decorations_unicode)
911 proxy._set_if_not_overridden(
912 name,
913 msg_decoration,
914 self.__expected_source,
915 )
917 def set_msg_decoration_unicode(
918 self,
919 name: str,
920 msg_decoration: str,
921 /,
922 ):
923 """
924 Set Unicode message decoration by name.
926 """
928 proxy = _t.cast(_ImmutableDict[str, str], self.msg_decorations_unicode)
929 proxy._set(
930 name,
931 msg_decoration,
932 self.__expected_source or type(self),
933 )
935 def _set_msg_decoration_ascii_if_not_overridden(
936 self,
937 name: str,
938 msg_decoration: str,
939 /,
940 ):
941 """
942 Set ASCII message decoration by name, but only if it wasn't overridden
943 in a subclass.
945 This method should be called from ``__init__`` implementations
946 to dynamically set message decorations. It will only set the decoration
947 if it was not overridden by any child class.
949 """
951 proxy = _t.cast(_ImmutableDict[str, str], self.msg_decorations_ascii)
952 proxy._set_if_not_overridden(
953 name,
954 msg_decoration,
955 self.__expected_source,
956 )
958 def set_msg_decoration_ascii(
959 self,
960 name: str,
961 msg_decoration: str,
962 /,
963 ):
964 """
965 Set ASCII message decoration by name.
967 """
969 proxy = _t.cast(_ImmutableDict[str, str], self.msg_decorations_ascii)
970 proxy._set(
971 name,
972 msg_decoration,
973 self.__expected_source or type(self),
974 )
976 def get_msg_decoration(self, key: str, /, *, is_unicode: bool) -> str:
977 """
978 Get message decoration by name.
980 """
982 msg_decorations = (
983 self.msg_decorations_unicode if is_unicode else self.msg_decorations_ascii
984 )
985 return msg_decorations.get(key, "")
987 def _set_color_if_not_overridden(
988 self,
989 path: str,
990 color: str | yuio.color.Color,
991 /,
992 ):
993 """
994 Set color by path, but only if the color was not overridden in a subclass.
996 This method should be called from ``__init__`` implementations
997 to dynamically set colors. It will only set the color if it was not overridden
998 by any child class.
1000 """
1002 proxy = _t.cast(_ImmutableDict[str, str | yuio.color.Color], self.colors)
1003 proxy._set_if_not_overridden(
1004 path,
1005 color,
1006 self.__expected_source,
1007 )
1008 self.__color_cache.clear()
1009 self.__dict__.pop("_Theme__color_tree", None) # type: ignore
1011 def set_color(
1012 self,
1013 path: str,
1014 color: str | yuio.color.Color,
1015 /,
1016 ):
1017 """
1018 Set color by path.
1020 """
1022 proxy = _t.cast(_ImmutableDict[str, str | yuio.color.Color], self.colors)
1023 proxy._set(
1024 path,
1025 color,
1026 self.__expected_source or type(self),
1027 )
1028 self.__color_cache.clear()
1029 self.__dict__.pop("_Theme__color_tree", None) # type: ignore
1031 @dataclass(kw_only=True, slots=True)
1032 class __ColorTree:
1033 """
1034 Prefix-like tree that contains all of the theme's colors.
1036 """
1038 colors: str | yuio.color.Color = yuio.color.Color.NONE
1039 """
1040 Colors in this node.
1042 """
1044 loc: dict[str, Theme.__ColorTree] = dataclasses.field(default_factory=dict)
1045 """
1046 Location part of the tree.
1048 """
1050 ctx: dict[str, Theme.__ColorTree] = dataclasses.field(default_factory=dict)
1051 """
1052 Context part of the tree.
1054 """
1056 @functools.cached_property
1057 def __color_tree(self) -> Theme.__ColorTree:
1058 root = self.__ColorTree()
1060 for path, colors in self.colors.items():
1061 loc, ctx = self.__parse_path(path)
1063 node = root
1065 for part in loc:
1066 if part not in node.loc:
1067 node.loc[part] = self.__ColorTree()
1068 node = node.loc[part]
1070 for part in ctx:
1071 if part not in node.ctx:
1072 node.ctx[part] = self.__ColorTree()
1073 node = node.ctx[part]
1075 node.colors = colors
1077 return root
1079 @staticmethod
1080 def __parse_path(path: str, /) -> tuple[list[str], list[str]]:
1081 path_parts = path.split(":", maxsplit=1)
1082 if len(path_parts) == 1:
1083 loc, ctx = path_parts[0], ""
1084 else:
1085 loc, ctx = path_parts
1086 return loc.split("/") if loc else [], ctx.split("/") if ctx else []
1088 def get_color(self, paths: str, /) -> yuio.color.Color:
1089 """
1090 Lookup a color by path.
1092 """
1094 color = yuio.color.Color.NONE
1095 for path in paths.split():
1096 color |= self.__get_color(path)
1097 return color
1099 def __get_color(self, path: str, /) -> yuio.color.Color:
1100 res: yuio.color.Color | None | yuio.Missing = self.__color_cache.get(
1101 path, yuio.MISSING
1102 )
1103 if res is None:
1104 warnings.warn(f"recursive color path {path!r}", RecursiveThemeWarning)
1105 return yuio.color.Color.NONE
1106 elif res is not yuio.MISSING:
1107 return res
1109 self.__color_cache[path] = None
1110 if path.startswith("#") and len(path) == 7:
1111 try:
1112 res = yuio.color.Color.fore_from_hex(path)
1113 except ValueError as e:
1114 warnings.warn(f"invalid color code {path!r}: {e}", ThemeWarning)
1115 res = yuio.color.Color.NONE
1116 elif path[:3].lower() == "bg#" and len(path) == 9:
1117 try:
1118 res = yuio.color.Color.back_from_hex(path[2:])
1119 except ValueError as e:
1120 warnings.warn(f"invalid color code {path!r}: {e}", ThemeWarning)
1121 res = yuio.color.Color.NONE
1122 else:
1123 loc, ctx = self.__parse_path(path)
1124 res = self.__get_color_in_loc(self.__color_tree, loc, ctx)
1125 self.__color_cache[path] = res
1126 return res
1128 def __get_color_in_loc(
1129 self, node: Theme.__ColorTree, loc: list[str], ctx: list[str]
1130 ):
1131 color = yuio.color.Color.NONE
1133 for part in loc:
1134 if part not in node.loc:
1135 break
1136 color |= self.__get_color_in_ctx(node, ctx)
1137 node = node.loc[part]
1139 return color | self.__get_color_in_ctx(node, ctx)
1141 def __get_color_in_ctx(self, node: Theme.__ColorTree, ctx: list[str]):
1142 color = yuio.color.Color.NONE
1144 for part in ctx:
1145 if part not in node.ctx:
1146 break
1147 color |= self.__get_color_in_node(node)
1148 node = node.ctx[part]
1150 return color | self.__get_color_in_node(node)
1152 def __get_color_in_node(self, node: Theme.__ColorTree) -> yuio.color.Color:
1153 color = yuio.color.Color.NONE
1155 if isinstance(node.colors, str):
1156 color |= self.get_color(node.colors)
1157 else:
1158 color |= node.colors
1160 return color
1162 def to_color(
1163 self, color_or_path: yuio.color.Color | str | None, /
1164 ) -> yuio.color.Color:
1165 """
1166 Convert color or color path to color.
1168 """
1170 if color_or_path is None:
1171 return yuio.color.Color.NONE
1172 elif isinstance(color_or_path, yuio.color.Color):
1173 return color_or_path
1174 else:
1175 return self.get_color(color_or_path)
1177 def check(self):
1178 """
1179 Check theme for recursion.
1181 This method is slow, and should be called from unit tests of your application.
1183 """
1185 if "" in self.colors:
1186 warnings.warn("colors map contains an empty key", ThemeWarning)
1188 for k, v in self.colors.items():
1189 if not v:
1190 warnings.warn(f"color value for path {k!r} is empty", ThemeWarning)
1192 err_path = None
1193 with warnings.catch_warnings():
1194 warnings.simplefilter("error", category=RecursiveThemeWarning)
1195 for k in self.colors:
1196 try:
1197 self.get_color(k)
1198 except RecursiveThemeWarning:
1199 err_path = k
1200 if err_path is None:
1201 return
1203 self.__color_cache.clear()
1204 recursive_path = []
1205 get_color_inner = self.__get_color
1207 def get_color(path: str):
1208 recursive_path.append(path)
1209 return get_color_inner(path)
1211 self.__get_color = get_color
1213 try:
1214 with warnings.catch_warnings():
1215 warnings.simplefilter("error", category=RecursiveThemeWarning)
1216 self.get_color(err_path)
1217 except RecursiveThemeWarning:
1218 self.__get_color = get_color_inner
1219 else:
1220 assert False, (
1221 "unreachable, please report hitting this assert "
1222 "to https://github.com/taminomara/yuio/issues"
1223 )
1225 raise RecursiveThemeWarning(
1226 f"infinite recursion in color path {err_path!r}:\n "
1227 + "\n ".join(
1228 f"{path!r} -> {self.colors.get(path)!r}" for path in recursive_path[:-1]
1229 )
1230 )
1233class BaseTheme(Theme):
1234 """
1235 This theme defines :ref:`common colors <common-tags>` that are commonly used
1236 in :ref:`inline color tags <color-tags>`.
1238 """
1240 colors = {
1241 #
1242 # Common tags
1243 # -----------
1244 "code": "bold",
1245 "note": "cyan",
1246 "strong": "note",
1247 "em": "italic",
1248 "path": "underline",
1249 "flag": "note",
1250 "kbd": "note",
1251 "gui": "kbd",
1252 #
1253 # Styles
1254 # ------
1255 "bold": yuio.color.Color.STYLE_BOLD,
1256 "b": "bold",
1257 "dim": yuio.color.Color.STYLE_DIM,
1258 "d": "dim",
1259 "italic": yuio.color.Color.STYLE_ITALIC,
1260 "i": "italic",
1261 "underline": yuio.color.Color.STYLE_UNDERLINE,
1262 "u": "underline",
1263 "inverse": yuio.color.Color.STYLE_INVERSE,
1264 #
1265 # Foreground
1266 # ----------
1267 # Note: we don't have tags for background because it's impossible to guarantee
1268 # that they'll work nicely with whatever foreground you choose. Prefer using
1269 # `inverse` instead.
1270 "normal": yuio.color.Color.FORE_NORMAL,
1271 "muted": yuio.color.Color.FORE_NORMAL_DIM,
1272 "black": yuio.color.Color.FORE_BLACK,
1273 "red": yuio.color.Color.FORE_RED,
1274 "green": yuio.color.Color.FORE_GREEN,
1275 "yellow": yuio.color.Color.FORE_YELLOW,
1276 "blue": yuio.color.Color.FORE_BLUE,
1277 "magenta": yuio.color.Color.FORE_MAGENTA,
1278 "cyan": yuio.color.Color.FORE_CYAN,
1279 "white": yuio.color.Color.FORE_WHITE,
1280 }
1283class DefaultTheme(BaseTheme):
1284 """
1285 Default Yuio theme. Adapts for terminal background color,
1286 if one can be detected.
1288 This theme defines :ref:`main colors <main-colors>`, which you can override
1289 by subclassing. All other colors are expressed in terms of main colors,
1290 so changing a main color will have an effect on the entire theme.
1292 """
1294 msg_decorations_ascii = {
1295 "heading/section": "",
1296 "heading/1": "# ",
1297 "heading/2": "",
1298 "heading/3": "",
1299 "heading/4": "",
1300 "heading/5": "",
1301 "heading/6": "",
1302 "question": "> ",
1303 "task": "> ",
1304 "thematic_break": "-" * 8,
1305 "list": "* ",
1306 "quote": "> ",
1307 "code": " ",
1308 "admonition/title": "",
1309 "admonition/body": " ",
1310 "overflow": "~",
1311 "progress_bar/start_symbol": "[",
1312 "progress_bar/end_symbol": "]",
1313 "progress_bar/done_symbol": "-",
1314 "progress_bar/pending_symbol": " ",
1315 "progress_bar/transition_pattern": ">",
1316 "spinner/pattern": "|||/-\\",
1317 "spinner/static_symbol": ">",
1318 "menuselection_separator": "->",
1319 "hr/1/left_start": "-",
1320 "hr/1/left_middle": "-",
1321 "hr/1/left_end": " ",
1322 "hr/1/middle": "-",
1323 "hr/1/right_start": " ",
1324 "hr/1/right_middle": "-",
1325 "hr/1/right_end": "-",
1326 "hr/2/left_start": "=",
1327 "hr/2/left_middle": "=",
1328 "hr/2/left_end": " ",
1329 "hr/2/middle": "=",
1330 "hr/2/right_start": " ",
1331 "hr/2/right_middle": "=",
1332 "hr/2/right_end": "=",
1333 "menu/choice/decoration/active_item": "> ",
1334 "menu/choice/decoration/deselected_item": "- ",
1335 "menu/choice/decoration/selected_item": "* ",
1336 "menu/input/decoration_search": "/ ",
1337 "menu/input/decoration": "> ",
1338 "menu/help/key/alt": "M-",
1339 "menu/help/key/ctrl": "C-",
1340 "menu/help/key/shift": "S-",
1341 "menu/help/key/enter": "ret",
1342 "menu/help/key/escape": "esc",
1343 "menu/help/key/insert": "ins",
1344 "menu/help/key/delete": "del",
1345 "menu/help/key/backspace": "bsp",
1346 "menu/help/key/tab": "tab",
1347 "menu/help/key/home": "home",
1348 "menu/help/key/end": "end",
1349 "menu/help/key/page_up": "pgup",
1350 "menu/help/key/page_down": "pgdn",
1351 "menu/help/key/arrow_up": "up",
1352 "menu/help/key/arrow_down": "down",
1353 "menu/help/key/arrow_left": "left",
1354 "menu/help/key/arrow_right": "right",
1355 "menu/help/key/space": "space",
1356 "menu/help/key/f1": "f1",
1357 "menu/help/key/f2": "f2",
1358 "menu/help/key/f3": "f3",
1359 "menu/help/key/f4": "f4",
1360 "menu/help/key/f5": "f5",
1361 "menu/help/key/f6": "f6",
1362 "menu/help/key/f7": "f7",
1363 "menu/help/key/f8": "f8",
1364 "menu/help/key/f9": "f9",
1365 "menu/help/key/f10": "f10",
1366 "menu/help/key/f11": "f11",
1367 "menu/help/key/f12": "f12",
1368 }
1370 msg_decorations_unicode = {
1371 "heading/section": "",
1372 "heading/1": "⣿ ",
1373 "heading/2": "",
1374 "heading/3": "",
1375 "heading/4": "",
1376 "heading/5": "",
1377 "heading/6": "",
1378 "question": "> ",
1379 "task": "> ",
1380 "thematic_break": "╌╌╌╌╌╌╌╌",
1381 "list": "• ",
1382 "quote": "> ",
1383 "code": " ",
1384 "admonition/title": "",
1385 "admonition/body": " ",
1386 "overflow": "…",
1387 "hr/1/left_start": "─",
1388 "hr/1/left_middle": "─",
1389 "hr/1/left_end": "╴",
1390 "hr/1/middle": "─",
1391 "hr/1/right_start": "╶",
1392 "hr/1/right_middle": "─",
1393 "hr/1/right_end": "─",
1394 "hr/2/left_start": "━",
1395 "hr/2/left_middle": "━",
1396 "hr/2/left_end": "╸",
1397 "hr/2/middle": "━",
1398 "hr/2/right_start": "╺",
1399 "hr/2/right_middle": "━",
1400 "hr/2/right_end": "━",
1401 "progress_bar/start_symbol": "",
1402 "progress_bar/end_symbol": "",
1403 "progress_bar/done_symbol": "■", # "█",
1404 "progress_bar/pending_symbol": "□", # " ",
1405 "progress_bar/transition_pattern": "", # "█▉▊▋▌▍▎▏ ",
1406 "spinner/pattern": "⣤⣤⣤⠶⠛⠛⠛⠶",
1407 "spinner/static_symbol": "⣿",
1408 "menuselection_separator": " → ",
1409 "menu/input/decoration": "> ",
1410 "menu/input/decoration_search": "/ ",
1411 "menu/choice/decoration/active_item": "> ",
1412 "menu/choice/decoration/selected_item": "◉ ",
1413 "menu/choice/decoration/deselected_item": "○ ",
1414 "menu/help/decoration": ":",
1415 "menu/help/key/alt": "M-",
1416 "menu/help/key/ctrl": "C-",
1417 "menu/help/key/shift": "S-",
1418 "menu/help/key/enter": "ret",
1419 "menu/help/key/escape": "esc",
1420 "menu/help/key/insert": "ins",
1421 "menu/help/key/delete": "del",
1422 "menu/help/key/backspace": "bsp",
1423 "menu/help/key/tab": "tab",
1424 "menu/help/key/home": "home",
1425 "menu/help/key/end": "end",
1426 "menu/help/key/page_up": "pgup",
1427 "menu/help/key/page_down": "pgdn",
1428 "menu/help/key/arrow_up": "↑",
1429 "menu/help/key/arrow_down": "↓",
1430 "menu/help/key/arrow_left": "←",
1431 "menu/help/key/arrow_right": "→",
1432 "menu/help/key/space": "␣",
1433 "menu/help/key/f1": "f1",
1434 "menu/help/key/f2": "f2",
1435 "menu/help/key/f3": "f3",
1436 "menu/help/key/f4": "f4",
1437 "menu/help/key/f5": "f5",
1438 "menu/help/key/f6": "f6",
1439 "menu/help/key/f7": "f7",
1440 "menu/help/key/f8": "f8",
1441 "menu/help/key/f9": "f9",
1442 "menu/help/key/f10": "f10",
1443 "menu/help/key/f11": "f11",
1444 "menu/help/key/f12": "f12",
1445 }
1447 colors = {
1448 "note": "accent_color_2",
1449 #
1450 # Main settings
1451 # -------------
1452 # This section controls the overall theme look.
1453 # Most likely you'll want to change accent colors from here.
1454 "heading_color": "bold primary_color",
1455 "primary_color": "normal",
1456 "accent_color": "magenta",
1457 "accent_color_2": "cyan",
1458 "secondary_color": "muted",
1459 "error_color": "red",
1460 "warning_color": "yellow",
1461 "success_color": "green",
1462 "critical_color": "inverse error_color",
1463 "low_priority_color_a": "muted",
1464 "low_priority_color_b": "muted",
1465 #
1466 # IO messages and text
1467 # --------------------
1468 "msg/decoration": "secondary_color",
1469 "msg/decoration:heading": "heading_color accent_color",
1470 "msg/decoration:thematic_break": "secondary_color",
1471 "msg/text:code": "primary_color",
1472 "msg/text:heading": "heading_color",
1473 "msg/text:heading/1": "accent_color",
1474 "msg/text:heading/section": "green",
1475 "msg/text:heading/note": "green",
1476 "msg/text:question": "heading_color",
1477 "msg/text:error": "error_color",
1478 "msg/text:error/note": "green",
1479 "msg/text:warning": "warning_color",
1480 "msg/text:success": "heading_color success_color",
1481 "msg/text:failure": "heading_color error_color",
1482 "msg/text:info": "primary_color",
1483 "msg/text:thematic_break": "secondary_color",
1484 "msg/text:help/tail": "dim",
1485 "msg/text:admonition/title": "heading_color blue",
1486 "msg/text:admonition/body": "blue",
1487 "msg/text:admonition/title/attention": "warning_color",
1488 "msg/text:admonition/body/attention": "warning_color",
1489 "msg/text:admonition/title/caution": "warning_color",
1490 "msg/text:admonition/body/caution": "warning_color",
1491 "msg/text:admonition/title/danger": "error_color",
1492 "msg/text:admonition/body/danger": "error_color",
1493 "msg/text:admonition/title/error": "error_color",
1494 "msg/text:admonition/body/error": "error_color",
1495 "msg/text:admonition/title/hint": "success_color",
1496 "msg/text:admonition/body/hint": "success_color",
1497 "msg/text:admonition/title/important": "warning_color",
1498 "msg/text:admonition/body/important": "warning_color",
1499 "msg/text:admonition/title/seealso": "success_color",
1500 "msg/text:admonition/body/seealso": "success_color",
1501 "msg/text:admonition/title/tip": "success_color",
1502 "msg/text:admonition/body/tip": "success_color",
1503 "msg/text:admonition/title/warning": "warning_color",
1504 "msg/text:admonition/body/warning": "warning_color",
1505 "msg/text:admonition/title/versionadded": "success_color",
1506 "msg/text:admonition/body/versionadded": "success_color",
1507 "msg/text:admonition/title/versionchanged": "warning_color",
1508 "msg/text:admonition/body/versionchanged": "warning_color",
1509 "msg/text:admonition/title/deprecated": "error_color",
1510 "msg/text:admonition/body/deprecated": "error_color",
1511 "msg/text:admonition/title/definition": "primary_color",
1512 "msg/text:admonition/body/definition": "primary_color",
1513 "msg/text:admonition/title/field": "primary_color",
1514 "msg/text:admonition/body/field": "primary_color",
1515 "msg/text:admonition/title/unknown-dir": "primary_color",
1516 "msg/text:admonition/body/unknown-dir": "primary_color",
1517 #
1518 # Log messages
1519 # ------------
1520 "log/name": "dim accent_color_2",
1521 "log/pathname": "dim",
1522 "log/filename": "dim",
1523 "log/module": "dim",
1524 "log/lineno": "dim",
1525 "log/funcName": "dim",
1526 "log/created": "dim",
1527 "log/asctime": "dim",
1528 "log/msecs": "dim",
1529 "log/relativeCreated": "dim",
1530 "log/thread": "dim",
1531 "log/threadName": "dim",
1532 "log/taskName": "dim",
1533 "log/process": "dim",
1534 "log/processName": "dim",
1535 "log/levelno": "log/levelname",
1536 "log/levelno:critical": "log/levelname:critical",
1537 "log/levelno:error": "log/levelname:error",
1538 "log/levelno:warning": "log/levelname:warning",
1539 "log/levelno:info": "log/levelname:info",
1540 "log/levelno:debug": "log/levelname:debug",
1541 "log/levelname": "heading_color",
1542 "log/levelname:critical": "critical_color",
1543 "log/levelname:error": "error_color",
1544 "log/levelname:warning": "warning_color",
1545 "log/levelname:info": "success_color",
1546 "log/levelname:debug": "dim",
1547 "log/message": "primary_color",
1548 "log/message:critical": "bold error_color",
1549 "log/message:debug": "dim",
1550 "log/colMessage": "log/message",
1551 "log/colMessage:critical": "log/message:critical",
1552 "log/colMessage:error": "log/message:error",
1553 "log/colMessage:warning": "log/message:warning",
1554 "log/colMessage:info": "log/message:info",
1555 "log/colMessage:debug": "log/message:debug",
1556 #
1557 # Tasks and progress bars
1558 # -----------------------
1559 "task": "secondary_color",
1560 "task/decoration": "msg/decoration:heading",
1561 "task/decoration:running": "accent_color",
1562 "task/decoration:done": "success_color",
1563 "task/decoration:error": "error_color",
1564 "task/progressbar/done": "accent_color",
1565 "task/progressbar/done/start": "term/bright_blue",
1566 "task/progressbar/done/end": "term/bright_magenta",
1567 "task/progressbar/pending": "secondary_color",
1568 "task/heading": "heading_color",
1569 "task/progress": "secondary_color",
1570 "task/comment": "primary_color",
1571 #
1572 # Syntax highlighting
1573 # -------------------
1574 "hl/kwd": "bold",
1575 "hl/str": "yellow",
1576 "hl/str/esc": "accent_color",
1577 "hl/punct": "secondary_color",
1578 "hl/comment": "green",
1579 "hl/lit": "blue",
1580 "hl/type": "cyan",
1581 "hl/prog": "bold underline",
1582 "hl/flag": "flag",
1583 "hl/meta": "accent_color",
1584 "hl/added": "green",
1585 "hl/removed": "red",
1586 "hl/error": "bold error_color",
1587 "hl/doctest_marker": "accent_color",
1588 "hl/doctest_marker/continue": "secondary_color",
1589 "hl/metavar:sh-usage": "bold",
1590 "tb/heading": "bold error_color",
1591 "tb/message": "tb/heading",
1592 "tb/frame/usr/file/module": "accent_color",
1593 "tb/frame/usr/file/line": "accent_color",
1594 "tb/frame/usr/file/path": "accent_color",
1595 "tb/frame/usr/code": "primary_color",
1596 "tb/frame/usr/highlight": "error_color",
1597 "tb/frame/lib": "dim",
1598 "tb/frame/lib/file/module": "tb/frame/usr/file/module",
1599 "tb/frame/lib/file/line": "tb/frame/usr/file/line",
1600 "tb/frame/lib/file/path": "tb/frame/usr/file/path",
1601 "tb/frame/lib/code": "tb/frame/usr/code",
1602 "tb/frame/lib/highlight": "tb/frame/usr/highlight",
1603 #
1604 # Menu and widgets
1605 # ----------------
1606 "menu/text": "primary_color",
1607 "menu/text/heading": "menu/text heading_color",
1608 "menu/text/help_info:help": "low_priority_color_a",
1609 "menu/text/help_msg:help": "low_priority_color_b",
1610 "menu/text/help_key:help": "low_priority_color_a",
1611 "menu/text/help_sep:help": "low_priority_color_b",
1612 "menu/text/help_key:help_menu": "kbd",
1613 "menu/text/help_sep:help_menu": "secondary_color",
1614 "menu/text/esc": "white on_magenta",
1615 "menu/text/error": "bold underline error_color",
1616 "menu/text/comment": "accent_color_2",
1617 "menu/text:choice/active": "accent_color",
1618 "menu/text:choice/active/selected": "bold",
1619 "menu/text:choice/normal/selected": "accent_color_2 bold",
1620 "menu/text:choice/normal/dir": "blue",
1621 "menu/text:choice/normal/exec": "red",
1622 "menu/text:choice/normal/symlink": "magenta",
1623 "menu/text:choice/normal/socket": "green",
1624 "menu/text:choice/normal/pipe": "yellow",
1625 "menu/text:choice/normal/block_device": "cyan bold",
1626 "menu/text:choice/normal/char_device": "yellow bold",
1627 "menu/text/comment:choice/normal/original": "success_color",
1628 "menu/text/comment:choice/active/original": "success_color",
1629 "menu/text/comment:choice/normal/corrected": "error_color",
1630 "menu/text/comment:choice/active/corrected": "error_color",
1631 "menu/text/prefix:choice/normal": "primary_color",
1632 "menu/text/prefix:choice/normal/selected": "accent_color_2 bold",
1633 "menu/text/prefix:choice/active": "accent_color",
1634 "menu/text/prefix:choice/active/selected": "bold",
1635 "menu/text/suffix:choice/normal": "primary_color",
1636 "menu/text/suffix:choice/normal/selected": "accent_color_2 bold",
1637 "menu/text/suffix:choice/active": "accent_color",
1638 "menu/text/suffix:choice/active/selected": "bold",
1639 "menu/text:choice/status_line": "low_priority_color_b",
1640 "menu/text:choice/status_line/number": "low_priority_color_a",
1641 "menu/text/placeholder": "secondary_color",
1642 "menu/decoration": "accent_color",
1643 "menu/decoration/comment": "secondary_color",
1644 "menu/decoration:choice/normal": "menu/text",
1645 "menu/decoration:choice/normal/selected": "accent_color_2 bold",
1646 "menu/decoration:choice/active/selected": "bold",
1647 **{f"term/{name}": yuio.color.Color.NONE for name in _COLOR_NAMES},
1648 #
1649 # Documentation roles
1650 # -------------------
1651 "role/footnote": "secondary_color",
1652 "role/flag": "flag",
1653 "role/code": "code",
1654 "role/literal": "em",
1655 "role/math": "em",
1656 "role/abbr": "em",
1657 "role/command": "em",
1658 "role/dfn": "em",
1659 "role/mailheader": "em",
1660 "role/makevar": "em",
1661 "role/mimetype": "em",
1662 "role/newsgroup": "em",
1663 "role/program": "flag",
1664 "role/regexp": "code",
1665 "role/cve": "em",
1666 "role/cwe": "em",
1667 "role/pep": "em",
1668 "role/rfc": "em",
1669 "role/manpage": "em",
1670 "role/any": "em",
1671 "role/doc": "em",
1672 "role/download": "em",
1673 "role/envvar": "code",
1674 "role/keyword": "em",
1675 "role/numref": "em",
1676 "role/option": "flag",
1677 "role/cmdoption": "flag",
1678 "role/ref": "em",
1679 "role/term": "em",
1680 "role/token": "em",
1681 "role/eq": "em",
1682 "role/kbd": "kbd",
1683 "role/guilabel": "note",
1684 "role/guilabel/accelerator": "underline",
1685 "role/menuselection": "note",
1686 # "role/menuselection/separator": "secondary_color",
1687 "role/menuselection/accelerator": "underline",
1688 "role/file": "path",
1689 "role/file/variable": "italic",
1690 "role/samp": "code",
1691 "role/samp/variable": "italic",
1692 "role/cli/cfg": "code",
1693 "role/cli/field": "code",
1694 "role/cli/obj": "code",
1695 "role/cli/env": "code",
1696 "role/cli/any": "code",
1697 "role/cli/cmd": "flag",
1698 "role/cli/flag": "flag",
1699 "role/cli/arg": "flag",
1700 "role/cli/opt": "flag",
1701 "role/cli/cli": "flag",
1702 "role/unknown": "code",
1703 }
1704 """
1705 Colors for default theme are separated into several sections.
1707 The main section (the first one) has common settings which are referenced
1708 from all other sections. You'll probably want to override
1710 """
1712 def __init__(self, term: yuio.term.Term):
1713 super().__init__()
1715 if (colors := term.terminal_theme) is None:
1716 return
1718 # Gradients look bad in other modes.
1719 if term.supports_colors_true:
1720 for name in _COLOR_NAMES:
1721 self._set_color_if_not_overridden(
1722 f"term/{name}", yuio.color.Color(fore=getattr(colors, name))
1723 )
1725 if colors.lightness == yuio.term.Lightness.UNKNOWN:
1726 return
1728 background = colors.background
1729 foreground = colors.foreground
1731 if colors.lightness is colors.lightness.DARK:
1732 self._set_color_if_not_overridden(
1733 "low_priority_color_a",
1734 yuio.color.Color(
1735 fore=foreground.match_luminosity(background.lighten(0.30))
1736 ),
1737 )
1738 self._set_color_if_not_overridden(
1739 "low_priority_color_b",
1740 yuio.color.Color(
1741 fore=foreground.match_luminosity(background.lighten(0.25))
1742 ),
1743 )
1744 else:
1745 self._set_color_if_not_overridden(
1746 "low_priority_color_a",
1747 yuio.color.Color(
1748 fore=foreground.match_luminosity(background.darken(0.30))
1749 ),
1750 )
1751 self._set_color_if_not_overridden(
1752 "low_priority_color_b",
1753 yuio.color.Color(
1754 fore=foreground.match_luminosity(background.darken(0.25))
1755 ),
1756 )
1759def load(
1760 term: yuio.term.Term,
1761 theme_ctor: _t.Callable[[yuio.term.Term], Theme] | None = None,
1762 /,
1763) -> Theme:
1764 """
1765 Loads a default theme.
1767 """
1769 # NOTE: loading themes from json is beta, don't use it yet.
1771 if theme_ctor is None:
1772 theme_ctor = DefaultTheme
1774 if not (path := os.environ.get("YUIO_THEME_PATH")):
1775 return theme_ctor(term)
1777 import yuio.config
1778 import yuio.parse
1780 class ThemeData(yuio.config.Config):
1781 include: list[str] | str | None = None
1782 progress_bar_width: _t.Annotated[int, yuio.parse.Ge(0)] | None = None
1783 spinner_update_rate_ms: _t.Annotated[int, yuio.parse.Ge(0)] | None = None
1784 separate_headings: bool | None = None
1785 fallback_width: _t.Annotated[int, yuio.parse.Gt(0)] | None = None
1786 msg_decorations_unicode: dict[str, str] = yuio.config.field(
1787 default={},
1788 merge=lambda l, r: {**l, **r},
1789 )
1790 msg_decorations_ascii: dict[str, str] = yuio.config.field(
1791 default={},
1792 merge=lambda l, r: {**l, **r},
1793 )
1794 colors: dict[str, str] = yuio.config.field(
1795 default={},
1796 merge=lambda l, r: {**l, **r},
1797 )
1799 seen = set()
1800 stack = [pathlib.Path(path)]
1801 loaded_partials = []
1802 while stack:
1803 path = stack.pop()
1804 if path in seen:
1805 continue
1806 if not path.exists():
1807 warnings.warn(f"theme file {path} does not exist", ThemeWarning)
1808 continue
1809 if not path.is_file():
1810 warnings.warn(f"theme file {path} is not a file", ThemeWarning)
1811 continue
1812 try:
1813 loaded = ThemeData.load_from_json_file(path, ignore_unknown_fields=True)
1814 except yuio.parse.ParsingError as e:
1815 warnings.warn(str(e), ThemeWarning)
1816 continue
1817 loaded_partials.append(loaded)
1818 include = loaded.include
1819 if isinstance(include, str):
1820 include = [include]
1821 if include:
1822 stack.extend([path.parent / new_path for new_path in include])
1824 theme_data = ThemeData()
1825 for partial in reversed(loaded_partials):
1826 theme_data.update(partial)
1828 theme = theme_ctor(term)
1830 if theme_data.progress_bar_width is not None:
1831 theme.progress_bar_width = theme_data.progress_bar_width
1832 if theme_data.spinner_update_rate_ms is not None:
1833 theme.spinner_update_rate_ms = theme_data.spinner_update_rate_ms
1834 if theme_data.separate_headings is not None:
1835 theme.separate_headings = theme_data.separate_headings
1836 if theme_data.fallback_width is not None:
1837 theme.fallback_width = theme_data.fallback_width
1839 for k, v in theme_data.msg_decorations_ascii.items():
1840 theme.set_msg_decoration_ascii(k, v)
1841 for k, v in theme_data.msg_decorations_unicode.items():
1842 theme.set_msg_decoration_unicode(k, v)
1843 for k, v in theme_data.colors.items():
1844 theme.set_color(k, v)
1846 return theme
1849class TableJunction(IntFlag):
1850 WEST = 1 << 0
1851 WEST_ALT = 1 << 1
1852 SOUTH = 1 << 2
1853 SOUTH_ALT = 1 << 3
1854 EAST = 1 << 4
1855 EAST_ALT = 1 << 5
1856 NORTH = 1 << 6
1857 NORTH_ALT = 1 << 7
1858 ALT_STYLE = 1 << 8
1860 def __repr__(self) -> str:
1861 res = "".join(
1862 [
1863 ["", "n", "", "N"][
1864 bool(self & self.NORTH) + 2 * bool(self & self.NORTH_ALT)
1865 ],
1866 ["", "e", "", "E"][
1867 bool(self & self.EAST) + 2 * bool(self & self.EAST_ALT)
1868 ],
1869 ["", "s", "", "S"][
1870 bool(self & self.SOUTH) + 2 * bool(self & self.SOUTH_ALT)
1871 ],
1872 ["", "w", "", "W"][
1873 bool(self & self.WEST) + 2 * bool(self & self.WEST_ALT)
1874 ],
1875 ["-", "="][bool(self & self.ALT_STYLE)],
1876 ]
1877 )
1878 return f"<{self.__class__.__name__} {res}>"
1881# fmt: off
1882_TABLE_SYMBOLS_UNICODE: dict[int, str] = {
1883 0x000: " ", 0x040: "╵", 0x0C0: "╹", 0x010: "╶", 0x050: "└", 0x0D0: "┖", 0x030: "╺",
1884 0x070: "┕", 0x0F0: "┗", 0x001: "╴", 0x041: "┘", 0x0C1: "┚", 0x011: "─", 0x051: "┴",
1885 0x0D1: "┸", 0x031: "╼", 0x071: "┶", 0x0F1: "┺", 0x003: "╸", 0x043: "┙", 0x0C3: "┛",
1886 0x013: "╾", 0x053: "┵", 0x0D3: "┹", 0x033: "━", 0x073: "┷", 0x0F3: "┻", 0x004: "╷",
1887 0x044: "│", 0x0C4: "╿", 0x014: "┌", 0x054: "├", 0x0D4: "┞", 0x034: "┍", 0x074: "┝",
1888 0x0F4: "┡", 0x005: "┐", 0x045: "┤", 0x0C5: "┦", 0x015: "┬", 0x055: "┼", 0x0D5: "╀",
1889 0x035: "┮", 0x075: "┾", 0x0F5: "╄", 0x007: "┑", 0x047: "┥", 0x0C7: "┩", 0x017: "┭",
1890 0x057: "┽", 0x0D7: "╃", 0x037: "┯", 0x077: "┿", 0x0F7: "╇", 0x00C: "╻", 0x04C: "╽",
1891 0x0CC: "┃", 0x01C: "┎", 0x05C: "┟", 0x0DC: "┠", 0x03C: "┎", 0x07C: "┢", 0x0FC: "┣",
1892 0x00D: "┒", 0x04D: "┧", 0x0CD: "┨", 0x01D: "┰", 0x05D: "╁", 0x0DD: "╂", 0x03D: "┲",
1893 0x07D: "╆", 0x0FD: "╊", 0x00F: "┓", 0x04F: "┪", 0x0CF: "┫", 0x01F: "┱", 0x05F: "╅",
1894 0x0DF: "╉", 0x03F: "┳", 0x07F: "╈", 0x0FF: "╋", 0x100: " ", 0x140: "╵", 0x1C0: "║",
1895 0x110: "╶", 0x150: "└", 0x1D0: "╙", 0x130: "═", 0x170: "╘", 0x1F0: "╚", 0x101: "╴",
1896 0x141: "┘", 0x1C1: "╜", 0x111: "─", 0x151: "┴", 0x1D1: "╨", 0x131: "═", 0x171: "╧",
1897 0x1F1: "╩", 0x103: "╸", 0x143: "╛", 0x1C3: "╝", 0x113: "═", 0x153: "╧", 0x1D3: "╩",
1898 0x133: "═", 0x173: "╧", 0x1F3: "╩", 0x104: "╷", 0x144: "│", 0x1C4: "║", 0x114: "┌",
1899 0x154: "├", 0x1D4: "╟", 0x134: "╒", 0x174: "╞", 0x1F4: "╠", 0x105: "┐", 0x145: "┤",
1900 0x1C5: "╢", 0x115: "┬", 0x155: "┼", 0x1D5: "╫", 0x135: "╤", 0x175: "╪", 0x1F5: "╬",
1901 0x107: "╕", 0x147: "╡", 0x1C7: "╣", 0x117: "╤", 0x157: "╪", 0x1D7: "╬", 0x137: "╤",
1902 0x177: "╪", 0x1F7: "╬", 0x10C: "║", 0x14C: "║", 0x1CC: "║", 0x11C: "╓", 0x15C: "╟",
1903 0x1DC: "╟", 0x13C: "╔", 0x17C: "╠", 0x1FC: "╠", 0x10D: "╖", 0x14D: "╢", 0x1CD: "╢",
1904 0x11D: "╥", 0x15D: "╫", 0x1DD: "╫", 0x13D: "╦", 0x17D: "╬", 0x1FD: "╬", 0x10F: "╗",
1905 0x14F: "╣", 0x1CF: "╣", 0x11F: "╦", 0x15F: "╬", 0x1DF: "╬", 0x13F: "╦", 0x17F: "╬",
1906 0x1FF: "╬",
1907}
1908# fmt: on
1910# fmt: off
1911_TABLE_SYMBOLS_ASCII: dict[int, str] = {
1912 0x000: " ", 0x040: "+", 0x0C0: "+", 0x010: "+", 0x050: "+", 0x0D0: "+", 0x030: "+",
1913 0x070: "+", 0x0F0: "+", 0x001: "+", 0x041: "+", 0x0C1: "+", 0x011: "-", 0x051: "+",
1914 0x0D1: "+", 0x031: "+", 0x071: "+", 0x0F1: "+", 0x003: "+", 0x043: "+", 0x0C3: "+",
1915 0x013: "+", 0x053: "+", 0x0D3: "+", 0x033: "=", 0x073: "+", 0x0F3: "+", 0x004: "+",
1916 0x044: "|", 0x0C4: "+", 0x014: "+", 0x054: "+", 0x0D4: "+", 0x034: "+", 0x074: "+",
1917 0x0F4: "+", 0x005: "+", 0x045: "+", 0x0C5: "+", 0x015: "+", 0x055: "+", 0x0D5: "+",
1918 0x035: "+", 0x075: "+", 0x0F5: "+", 0x007: "+", 0x047: "+", 0x0C7: "+", 0x017: "+",
1919 0x057: "+", 0x0D7: "+", 0x037: "+", 0x077: "+", 0x0F7: "+", 0x00C: "+", 0x04C: "+",
1920 0x0CC: "|", 0x01C: "+", 0x05C: "+", 0x0DC: "+", 0x03C: "+", 0x07C: "+", 0x0FC: "+",
1921 0x00D: "+", 0x04D: "+", 0x0CD: "+", 0x01D: "+", 0x05D: "+", 0x0DD: "+", 0x03D: "+",
1922 0x07D: "+", 0x0FD: "+", 0x00F: "+", 0x04F: "+", 0x0CF: "+", 0x01F: "+", 0x05F: "+",
1923 0x0DF: "+", 0x03F: "+", 0x07F: "+", 0x0FF: "+", 0x100: " ", 0x140: "+", 0x1C0: "#",
1924 0x110: "+", 0x150: "+", 0x1D0: "#", 0x130: "#", 0x170: "#", 0x1F0: "#", 0x101: "+",
1925 0x141: "+", 0x1C1: "#", 0x111: "-", 0x151: "+", 0x1D1: "#", 0x131: "#", 0x171: "#",
1926 0x1F1: "#", 0x103: "#", 0x143: "#", 0x1C3: "#", 0x113: "#", 0x153: "#", 0x1D3: "#",
1927 0x133: "*", 0x173: "#", 0x1F3: "#", 0x104: "+", 0x144: "|", 0x1C4: "#", 0x114: "+",
1928 0x154: "+", 0x1D4: "#", 0x134: "#", 0x174: "#", 0x1F4: "#", 0x105: "+", 0x145: "+",
1929 0x1C5: "#", 0x115: "+", 0x155: "+", 0x1D5: "#", 0x135: "#", 0x175: "#", 0x1F5: "#",
1930 0x107: "#", 0x147: "#", 0x1C7: "#", 0x117: "#", 0x157: "#", 0x1D7: "#", 0x137: "#",
1931 0x177: "#", 0x1F7: "#", 0x10C: "#", 0x14C: "#", 0x1CC: "*", 0x11C: "#", 0x15C: "#",
1932 0x1DC: "#", 0x13C: "#", 0x17C: "#", 0x1FC: "#", 0x10D: "#", 0x14D: "#", 0x1CD: "#",
1933 0x11D: "#", 0x15D: "#", 0x1DD: "#", 0x13D: "#", 0x17D: "#", 0x1FD: "#", 0x10F: "#",
1934 0x14F: "#", 0x1CF: "#", 0x11F: "#", 0x15F: "#", 0x1DF: "#", 0x13F: "#", 0x17F: "#",
1935 0x1FF: "#",
1936}
1937# fmt: on