Coverage for yuio / theme.py: 98%
357 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-05 11:41 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-05 11:41 +0000
1# Yuio project, MIT license.
2#
3# https://github.com/taminomara/yuio/
4#
5# You're free to copy this file to your project and edit it for your needs,
6# just keep this copyright line please :3
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/{part}:{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/punct``: punctuation,
192 - ``hl/str``: string literals,
193 - ``hl/str/esc``: escape sequences in strings,
194 - ``hl/type``: type names,
195 - ``hl/meta``: diff meta info for diff highlighting,
196 - ``hl/added``: added lines in diff highlighting,
197 - ``hl/removed``: removed lines in diff highlighting,
198 - ``hl/prog``: program name in CLI usage and shell highlighting,
199 - ``hl/flag``: CLI flags,
200 - ``hl/metavar``: meta variables in CLI usage.
202.. color-path:: `tb/heading`
203 `tb/message`
204 `tb/frame/{location}/...`
206 For highlighted tracebacks:
208 - ``tb/heading``: traceback heading,
209 - ``tb/message``: error message,
210 - :samp:`tb/frame/{location}/file/module`: module name,
211 - :samp:`tb/frame/{location}/file/line`: line number,
212 - :samp:`tb/frame/{location}/file/path`: file path,
213 - :samp:`tb/frame/{location}/code`: code sample at the error line,
214 - :samp:`tb/frame/{location}/highlight`: highlighting under the code sample.
216 ``location`` is either ``lib`` or ``usr`` depending on whether the code
217 is located in site-packages or in user code.
219.. color-path:: `log/{part}:{level}`
221 Colors for log records. ``part`` is name of a `log record attribute`__,
222 level is lowercase name of logging level.
224 __ https://docs.python.org/3/library/logging.html#logrecord-attributes
226 .. seealso::
228 :class:`yuio.io.Formatter`.
230.. color-path:: input widget
232 Colors for :class:`yuio.widget.Input`:
234 - ``menu/decoration:input``: decoration before an input box,
235 - ``menu/text:input``: entered text in an input box,
236 - ``menu/text/esc:input``: highlights for invisible characters in an input box,
237 - ``menu/text/error:input``: highlights for error region reported by a parser,
238 - ``menu/text/placeholder:input``: placeholder text in an input box,
240.. color-path:: grid widgets
242 Colors for :class:`yuio.widget.Grid`, :class:`yuio.widget.Choice`, and other
243 similar widgets:
245 - :samp:`menu/decoration:choice/{status}/{color_tag}`:
246 decoration before a grid item,
247 - :samp:`menu/decoration/comment:choice/{status}/{color_tag}`:
248 decoration around comments for a grid item,
249 - :samp:`menu/text:choice/{status}/{color_tag}`:
250 text of a grid item,
251 - :samp:`menu/text/comment:choice/{status}/{color_tag}`:
252 comment for a grid item,
253 - :samp:`menu/text/prefix:choice/{status}/{color_tag}`:
254 prefix before the main text of a grid item
255 (see :attr:`yuio.widget.Option.display_text_prefix` and
256 :attr:`yuio.complete.Completion.dprefix`),
257 - :samp:`menu/text/suffix:choice/{status}/{color_tag}`:
258 suffix after the main text of a grid item
259 (see :attr:`yuio.widget.Option.display_text_suffix` and
260 :attr:`yuio.complete.Completion.dsuffix`),
261 - ``menu/text:choice/status_line``: status line (i.e. "Page x of y").
262 - ``menu/text:choice/status_line/number``: page numbers in a status line.
264 ``status`` is either ``normal`` or ``active``:
266 - ``normal`` for regular grid items,
267 - ``active`` for the currently selected item.
269 ``color_tag`` is whatever tag specified by :attr:`yuio.widget.Option.color_tag`
270 and :attr:`yuio.complete.Completion.group_color_tag`. Currently supported tags:
272 - ``none``: color tag is not given,
273 - ``selected``: items selected in :class:`yuio.widget.Multiselect`,
274 - ``dir``: directory (in file completion),
275 - ``exec``: executable file (in file completion),
276 - ``symlink``: symbolic link (in file completion),
277 - ``socket``: socket (in file completion),
278 - ``pipe``: FIFO pipe (in file completion),
279 - ``block_device``: block device (in file completion),
280 - ``char_device``: character device (in file completion),
281 - ``original``: original completion item before spelling correction,
282 - ``corrected``: completion item that was found after spelling correction.
284.. color-path:: full screen help menu
286 Colors for help menu that appears when pressing :kbd:`F1`:
288 - ``menu/text/heading:help_menu``: section heading,
289 - ``menu/text/help_key:help_menu``: key names,
290 - ``menu/text/help_sep:help_menu``: separators between key names,
291 - ``menu/decoration:help_menu``: decorations.
293.. color-path:: inline help menu
295 Colors for help items rendered under a widget:
297 - ``menu/text/help_info:help``: help items that aren't associated with any key,
298 - ``menu/text/help_msg:help``: regular help items,
299 - ``menu/text/help_key:help``: keybinding names,
300 - ``menu/text/help_sep:help``: separator between items.
303.. _all-decorations:
305Decorations
306-----------
308.. decoration-path:: `info`
310 Messages from :mod:`yuio.io.info`.
312.. decoration-path:: `warning`
314 Messages from :mod:`yuio.io.warning`.
316.. decoration-path:: `error`
318 Messages from :mod:`yuio.io.error`.
320.. decoration-path:: `success`
322 Messages from :mod:`yuio.io.success`.
324.. decoration-path:: `failure`
326 Messages from :mod:`yuio.io.failure`.
328.. decoration-path:: `heading/{level}`
330 Messages from :mod:`yuio.io.heading` and headings in markdown.
332.. decoration-path:: `heading/section`
334 First-level headings in CLI help.
336.. decoration-path:: `question`
338 Messages from :func:`yuio.io.ask`.
340.. decoration-path:: `list`
342 Bullets in markdown.
344.. decoration-path:: `quote`
346 Quote decorations in markdown.
348.. decoration-path:: `code`
350 Code decorations in markdown.
352.. decoration-path:: `thematic_break`
354 Thematic breaks (i.e. horizontal rulers) in markdown.
356.. decoration-path:: `overflow`
358 Ellipsis symbol for lines that don't fit terminal width. Must be one character wide.
360.. decoration-path:: `progress_bar/{position}`
362 Decorations for progress bars.
364 Available positions are:
366 :``start_symbol``:
367 Start of the progress bar.
368 :``done_symbol``:
369 Tiles finished portion of the progress bar, must be one character wide.
370 :``pending_symbol``:
371 Tiles unfinished portion of the progress bar, must be one character wide.
372 :``end_symbol``:
373 End of the progress bar.
374 :``transition_pattern``:
375 If this decoration is empty, there's no symbol between finished and unfinished
376 parts of the progress bar.
378 Otherwise, this decoration defines a left-to-right gradient of transition
379 characters, ordered from most filled to least filled. Each character
380 must be one character wide.
382 .. raw:: html
384 <div class="highlight-text notranslate">
385 <div class="highlight">
386 <pre class="ascii-graphics">
387 <span class="k">[------> ]</span>
388 │└┬───┘│└┬───────┘│
389 │ │ │ │ end_symbol
390 │ │ │ └ pending_symbol
391 │ │ └ transition_pattern
392 │ └ done_symbol
393 └ start_symbol
394 </pre>
395 </div>
396 </div>
398 **Example:**
400 To get the classic blocky look, you can do the following:
402 .. code-block:: python
404 class BlockProgressTheme(yuio.theme.DefaultTheme):
405 msg_decorations = {
406 "progress_bar/start_symbol": "|",
407 "progress_bar/end_symbol": "|",
408 "progress_bar/done_symbol": "█",
409 "progress_bar/pending_symbol": " ",
410 "progress_bar/transition_pattern": "█▉▊▋▌▍▎▏ ",
411 }
413.. decoration-path:: `spinner/pattern`
415 Defines a sequence of symbols that will be used to show spinners for tasks
416 without known progress. Next element of the sequence will be shown
417 every :attr:`~Theme.spinner_update_rate_ms`.
419 You can find some pre-made patterns in py-spinners__ package.
421 __ https://github.com/ManrajGrover/py-spinners?tab=readme-ov-file
423.. decoration-path:: `spinner/static_symbol`
425 Static spinner symbol, for sub-tasks that've finished running but'.
427.. decoration-path:: `hr/{weight}/{position}`
429 Decorations for horizontal rulers (see :func:`yuio.io.hr`
430 and :func:`yuio.string.Hr`).
432 Default theme defines three weights:
434 - ``0`` prints no ruler (but still prints centered text),
435 - ``1`` prints normal ruler,
436 - ``2`` prints bold ruler.
438 Available positions are:
440 :``left_start``:
441 Start of the ruler to the left of the message.
442 :``left_middle``:
443 Filler of the ruler to the left of the message.
444 :``left_end``:
445 End of the ruler to the left of the message.
446 :``middle``:
447 Filler of the ruler that's used if `msg` is empty.
448 :``right_start``:
449 Start of the ruler to the right of the message.
450 :``right_middle``:
451 Filler of the ruler to the right of the message.
452 :``right_end``:
453 End of the ruler to the right of the message.
455 .. raw:: html
457 <div class="highlight-text notranslate">
458 <div class="highlight">
459 <pre class="ascii-graphics">
460 <span class="k"><------>message<------></span>
461 │└┬───┘│ │└┬───┘│
462 │ │ │ │ │ right_end
463 │ │ │ │ └ right_middle
464 │ │ │ └ right_start
465 │ │ └ left_end
466 │ └ left_middle
467 └ left_start
469 <span class="k"><---------------------></span>
470 │└┬──────────────────┘│
471 │ middle right_end
472 └ left_start
473 </pre>
474 </div>
475 </div>
477.. decoration-path:: input widget
479 Decorations for :class:`yuio.widget.Input`:
481 - ``menu/input/decoration``: decoration before an input box,
482 - ``menu/input/decoration_search``: decoration before a search input box.
484.. decoration-path:: choice and multiselect widget
486 Decorations for :class:`yuio.widget.Choice` and :class:`yuio.widget.Multiselect`:
488 - ``menu/choice/decoration/active_item``: current item,
489 - ``menu/choice/decoration/selected_item``: selected item in multiselect widget,
490 - ``menu/choice/decoration/deselected_item``: deselected item in multiselect widget.
492.. decoration-path:: inline help and help menu
494 Decorations for widget help:
496 - ``menu/help/decoration``: decoration at the bottom of the help menu,
497 - :samp:`menu/help/key/{key}`: text for functional keys.
499 ``key`` can be one of ``ctrl``, ``shift``, ``enter``, ``escape``, ``insert``,
500 ``delete``, ``backspace``, ``tab``, ``home``, ``end``, ``page_up``,
501 ``page_down``, ``arrow_up``, ``arrow_down``, ``arrow_left``, ``arrow_right``,
502 ``space``, ``f1``...\\ ``f12``.
504"""
506from __future__ import annotations
508import dataclasses
509import functools
510import os
511import pathlib
512import warnings
513from dataclasses import dataclass
514from enum import IntFlag
516import yuio.color
517import yuio.term
519from typing import TYPE_CHECKING
521if TYPE_CHECKING:
522 import typing_extensions as _t
523else:
524 from yuio import _typing as _t
526__all__ = [
527 "BaseTheme",
528 "DefaultTheme",
529 "RecursiveThemeWarning",
530 "TableJunction",
531 "Theme",
532 "ThemeWarning",
533 "load",
534]
536K = _t.TypeVar("K")
537V = _t.TypeVar("V")
540class ThemeWarning(yuio.YuioWarning):
541 pass
544class RecursiveThemeWarning(ThemeWarning):
545 pass
548_COLOR_NAMES = [
549 "background",
550 "foreground",
551 "black",
552 "bright_black",
553 "red",
554 "bright_red",
555 "green",
556 "bright_green",
557 "yellow",
558 "bright_yellow",
559 "blue",
560 "bright_blue",
561 "magenta",
562 "bright_magenta",
563 "cyan",
564 "bright_cyan",
565 "white",
566 "bright_white",
567]
570@_t.final
571class _ImmutableDict(_t.Mapping[K, V], _t.Generic[K, V]):
572 def __init__(
573 self, data: dict[K, V], sources: dict[K, type[Theme] | None], attr: str
574 ):
575 self.__data = data
576 self.__sources = sources
577 self.__attr = attr
579 def items(self) -> _t.ItemsView[K, V]:
580 return self.__data.items()
582 def keys(self) -> _t.KeysView[K]:
583 return self.__data.keys()
585 def values(self) -> _t.ValuesView[V]:
586 return self.__data.values()
588 def __len__(self):
589 return len(self.__data)
591 def __getitem__(self, key):
592 return self.__data[key]
594 def __iter__(self):
595 return iter(self.__data)
597 def __contains__(self, key):
598 return key in self.__data
600 def __repr__(self):
601 return repr(self.__data)
603 def __setitem__(self, key, item):
604 raise TypeError(f"Theme.{self.__attr} is immutable")
606 def __delitem__(self, key):
607 raise TypeError(f"Theme.{self.__attr} is immutable")
609 def copy(self) -> _t.Self:
610 return self.__class__(
611 self.__data.copy(),
612 self.__sources.copy(),
613 self.__attr,
614 )
616 def _set(self, key: K, value: V, source: type[Theme]):
617 self.__data[key] = value
618 self.__sources[key] = source
620 def _set_if_not_overridden(self, key: K, value: V, source: type[Theme] | None):
621 if source is None:
622 raise TypeError(
623 f"Theme._set_{self.__attr}_if_not_overridden can't be called "
624 "outside of __init__"
625 )
626 prev_source = self.__sources.get(key)
627 if prev_source is None or issubclass(source, prev_source):
628 self._set(key, value, source)
631@_t.final
632class _ReadOnlyDescriptor:
633 def __set_name__(self, owner: object, attr: str):
634 self.__attr = attr
635 self.__private_name = f"_Theme__{attr}"
637 def __get__(self, instance: object | None, owner: type[object] | None = None):
638 if instance is None: # pragma: no cover
639 return self
640 elif (data := instance.__dict__.get(self.__private_name)) is not None:
641 return data
642 else:
643 data = owner.__dict__[self.__private_name].copy()
644 instance.__dict__[self.__private_name] = data
645 return data
647 def __set__(self, instance: object, value: _t.Any):
648 raise TypeError(f"Theme.{self.__attr} is immutable")
650 def __delete__(self, instance: object):
651 raise TypeError(f"Theme.{self.__attr} is immutable")
654class _ThemeMeta(type):
655 # BEWARE OF MAGIC!
656 #
657 #
658 # Descriptors
659 # -----------
660 #
661 # _ThemeMeta.__dict__["colors"]
662 # this is a `_ReadOnlyDescriptor` that handles access to `Theme.colors`,
663 # proxying it to `Theme.__dict__["_Theme__colors"]`.
664 #
665 # Accessing `Theme.colors` is equivalent to calling
666 # `_ThemeMeta.__dict__["colors"].__get__(Theme)`,
667 # which in turn will return `Theme.__dict__["_Theme__colors"]`.
668 #
669 # Value for `Theme.__dict__["_Theme__colors"]` is assigned by this metaclass.
670 #
671 # Theme.__dict__["colors"]
672 # this is a `_ReadOnlyDescriptor` that handles access to `theme.colors`,
673 # proxying it to `theme.__dict__["_Theme__colors"]`.
674 #
675 # Accessing `theme.colors` is equivalent to calling
676 # `Theme.__dict__["colors"].__get__(theme)`,
677 # which in turn will return `theme.__dict__["_Theme__colors"]`.
678 #
679 # If `theme.__dict__` does not contain `"_Theme__colors"`, then it will assign
680 # `theme.__dict__["_Theme__colors"] = Theme.__dict__["_Theme__colors"].copy()`.
681 #
682 # theme.__dict__["colors"]
683 # this attribute does not exist. Accessing `theme.colors` is handled
684 # by its descriptor.
685 #
686 #
687 # Data
688 # ----
689 #
690 # Theme.__dict__["_Theme__colors"]
691 # this is the data returned when accessing `Theme.colors`. It contains
692 # an `_ImmutableDict` with combination of all colors from all bases.
693 #
694 # Theme.__dict__["_Theme__colors__orig"]
695 # this is original data assigned to `colors` variable in `Theme`'s namespace.
696 #
697 # For example:
698 #
699 # class MyTheme(Theme):
700 # colors = {"foo": "#000000"}
701 #
702 # In this class:
703 #
704 # - `MyTheme.__dict__["_Theme__colors"]` will contain combination of
705 # colors defined in `Theme` and in `MyTheme`.
706 # - `MyTheme.__dict__["_Theme__colors__orig"]` will contain initial dict
707 # `{"foo": "#000000"}`.
708 #
709 # theme.__dict__["_Theme__colors"]
710 # this is lazily initialized copy of `Theme.__dict__["_Theme__colors"]`;
711 # `Theme.set_color` will mutate this value.
713 _managed_attrs = ["msg_decorations_ascii", "msg_decorations_unicode", "colors"]
714 for _attr in _managed_attrs:
715 locals()[_attr] = _ReadOnlyDescriptor()
716 del _attr # type: ignore
718 def __new__(mcs, name, bases, ns, **kwargs):
719 # Pop any overrides from class' namespace and save them in `_Theme__attr__orig`.
720 # Set up read-only descriptors for managed attributes.
721 for attr in mcs._managed_attrs:
722 ns[f"_Theme__{attr}__orig"] = ns.pop(attr, {})
723 ns[attr] = _ReadOnlyDescriptor()
725 # Create metaclass instance.
726 cls = super().__new__(mcs, name, bases, ns, **kwargs)
728 # Set up class-level data for managed attributes.
729 for attr in mcs._managed_attrs:
730 setattr(cls, f"_Theme__{attr}", mcs._collect_data(cls, attr))
732 # Patch `__init__` so that it handles `__expected_source`.
733 if init := cls.__dict__.get("__init__", None):
735 @functools.wraps(init)
736 def _wrapped_init(self, *args, **kwargs):
737 prev_expected_source = self._Theme__expected_source
738 self._Theme__expected_source = cls
739 try:
740 return init(self, *args, **kwargs)
741 finally:
742 self._Theme__expected_source = prev_expected_source
744 setattr(cls, "__init__", _wrapped_init)
746 return cls
748 def _collect_data(cls, attr):
749 attr_orig = f"_Theme__{attr}__orig"
750 data = {}
751 sources = {}
752 for base in reversed(cls.__mro__):
753 if base_data := base.__dict__.get(attr_orig):
754 data.update(base_data)
755 sources.update(dict.fromkeys(base_data, base))
756 return _ImmutableDict(data, sources, attr)
759class Theme(metaclass=_ThemeMeta):
760 """
761 Base class for Yuio themes.
763 .. warning::
765 Do not change theme contents after it was passed to :func:`yuio.io.setup`.
766 Otherwise there's a risc of race conditions.
768 """
770 msg_decorations_unicode: _t.Mapping[str, str] = {}
771 """
772 Decorative symbols for certain text elements, such as headings,
773 list items, etc.
775 This mapping becomes immutable once a theme class is created. The only possible
776 way to modify it is by using :meth:`~Theme.set_msg_decoration_ascii`
777 or :meth:`~Theme._set_msg_decoration_ascii_if_not_overridden`.
779 """
781 msg_decorations_ascii: _t.Mapping[str, str] = {}
782 """
783 Like :attr:`~Theme.msg_decorations_unicode`, but suitable for non-unicode terminals.
785 """
787 progress_bar_width: int = 15
788 """
789 Width of a progress bar for :class:`yuio.io.Task`.
791 """
793 spinner_update_rate_ms: int = 200
794 """
795 How often the spinner pattern changes.
797 """
799 separate_headings: bool = True
800 """
801 Whether to print newlines before and after :func:`yuio.io.heading`.
803 """
805 fallback_width: int = 80
806 """
807 Preferred width that will be used if printing to a stream that's redirected
808 to a file.
810 """
812 colors: _t.Mapping[str, str | yuio.color.Color] = {}
813 """
814 Mapping of color paths to actual colors.
816 Themes use color paths to describe styles and colors for different
817 parts of an application. Color paths are similar to file paths,
818 they use snake case identifiers separated by slashes, and consist of
819 two parts separated by a colon.
821 The first part represents an object, i.e. what we're coloring.
823 The second part represents a context, i.e. what is the state or location
824 of an object that we're coloring.
826 For example, a color for the filled part of the task's progress bar
827 has path ``"task/progressbar/done"``, a color for a text of an error
828 log record has path ``"log/message:error"``, and a color for a string escape
829 sequence in a highlighted python code has path ``"hl/str/esc:python"``.
831 A color at a certain path is propagated to all sub-paths. For example,
832 if ``"task/progressbar"`` is bold, and ``"task/progressbar/done"`` is green,
833 the final color will be bold green.
835 Each color path can be associated with an instance of :class:`~yuio.color.Color`
836 or with another path.
838 If path is mapped to a :class:`~yuio.color.Color`, then the path is associated
839 with that particular color.
841 If path is mapped to another path, then the path is associated with
842 the color value for that other path (please don't create recursions here).
844 You can combine multiple paths within the same mapping by separating them with
845 whitespaces. In this case colors for those paths are combined.
847 For example:
849 .. code-block:: python
851 colors = {
852 "heading_color": "bold",
853 "error_color": "red",
854 "tb/heading": "heading_color error_color",
855 }
857 Here, color of traceback's heading ``"tb/heading"`` will be bold and red.
859 When deriving from a theme, you can override this mapping. When looking up
860 colors via :meth:`~Theme.get_color`, base classes will be tried for color,
861 in order of method resolution.
863 This mapping becomes immutable once a theme class is created. The only possible
864 way to modify it is by using :meth:`~Theme.set_color`
865 or :meth:`~Theme._set_color_if_not_overridden`.
867 """
869 __expected_source: type[Theme] | None = None
870 """
871 When running an ``__init__`` function, this variable will be set to the class
872 that implemented it, regardless of type of `self`.
874 That is, inside ``DefaultTheme.__init__``, ``__expected_source`` is set
875 to ``DefaultTheme``, in ``MyTheme.__init__`` it is ``MyTheme``, etc.
877 This is possible because ``_ThemeMeta`` wraps any implementation
878 of ``__init__`` into a wrapper that sets this variable.
880 """
882 def __init__(self):
883 self.__color_cache: dict[str, yuio.color.Color | None] = {}
885 def _set_msg_decoration_unicode_if_not_overridden(
886 self,
887 name: str,
888 msg_decoration: str,
889 /,
890 ):
891 """
892 Set Unicode message decoration by name, but only if it wasn't overridden
893 in a subclass.
895 This method should be called from ``__init__`` implementations
896 to dynamically set message decorations. It will only set the decoration
897 if it was not overridden by any child class.
899 """
901 proxy = _t.cast(_ImmutableDict[str, str], self.msg_decorations_unicode)
902 proxy._set_if_not_overridden(
903 name,
904 msg_decoration,
905 self.__expected_source,
906 )
908 def set_msg_decoration_unicode(
909 self,
910 name: str,
911 msg_decoration: str,
912 /,
913 ):
914 """
915 Set Unicode message decoration by name.
917 """
919 proxy = _t.cast(_ImmutableDict[str, str], self.msg_decorations_unicode)
920 proxy._set(
921 name,
922 msg_decoration,
923 self.__expected_source or type(self),
924 )
926 def _set_msg_decoration_ascii_if_not_overridden(
927 self,
928 name: str,
929 msg_decoration: str,
930 /,
931 ):
932 """
933 Set ASCII message decoration by name, but only if it wasn't overridden
934 in a subclass.
936 This method should be called from ``__init__`` implementations
937 to dynamically set message decorations. It will only set the decoration
938 if it was not overridden by any child class.
940 """
942 proxy = _t.cast(_ImmutableDict[str, str], self.msg_decorations_ascii)
943 proxy._set_if_not_overridden(
944 name,
945 msg_decoration,
946 self.__expected_source,
947 )
949 def set_msg_decoration_ascii(
950 self,
951 name: str,
952 msg_decoration: str,
953 /,
954 ):
955 """
956 Set ASCII message decoration by name.
958 """
960 proxy = _t.cast(_ImmutableDict[str, str], self.msg_decorations_ascii)
961 proxy._set(
962 name,
963 msg_decoration,
964 self.__expected_source or type(self),
965 )
967 def get_msg_decoration(self, key: str, /, *, is_unicode: bool) -> str:
968 """
969 Get message decoration by name.
971 """
973 msg_decorations = (
974 self.msg_decorations_unicode if is_unicode else self.msg_decorations_ascii
975 )
976 return msg_decorations.get(key, "")
978 def _set_color_if_not_overridden(
979 self,
980 path: str,
981 color: str | yuio.color.Color,
982 /,
983 ):
984 """
985 Set color by path, but only if the color was not overridden in a subclass.
987 This method should be called from ``__init__`` implementations
988 to dynamically set colors. It will only set the color if it was not overridden
989 by any child class.
991 """
993 proxy = _t.cast(_ImmutableDict[str, str | yuio.color.Color], self.colors)
994 proxy._set_if_not_overridden(
995 path,
996 color,
997 self.__expected_source,
998 )
999 self.__color_cache.clear()
1000 self.__dict__.pop("_Theme__color_tree", None)
1002 def set_color(
1003 self,
1004 path: str,
1005 color: str | yuio.color.Color,
1006 /,
1007 ):
1008 """
1009 Set color by path.
1011 """
1013 proxy = _t.cast(_ImmutableDict[str, str | yuio.color.Color], self.colors)
1014 proxy._set(
1015 path,
1016 color,
1017 self.__expected_source or type(self),
1018 )
1019 self.__color_cache.clear()
1020 self.__dict__.pop("_Theme__color_tree", None)
1022 @dataclass(kw_only=True, slots=True)
1023 class __ColorTree:
1024 """
1025 Prefix-like tree that contains all of the theme's colors.
1027 """
1029 colors: str | yuio.color.Color = yuio.color.Color.NONE
1030 """
1031 Colors in this node.
1033 """
1035 loc: dict[str, Theme.__ColorTree] = dataclasses.field(default_factory=dict)
1036 """
1037 Location part of the tree.
1039 """
1041 ctx: dict[str, Theme.__ColorTree] = dataclasses.field(default_factory=dict)
1042 """
1043 Context part of the tree.
1045 """
1047 @functools.cached_property
1048 def __color_tree(self) -> Theme.__ColorTree:
1049 root = self.__ColorTree()
1051 for path, colors in self.colors.items():
1052 loc, ctx = self.__parse_path(path)
1054 node = root
1056 for part in loc:
1057 if part not in node.loc:
1058 node.loc[part] = self.__ColorTree()
1059 node = node.loc[part]
1061 for part in ctx:
1062 if part not in node.ctx:
1063 node.ctx[part] = self.__ColorTree()
1064 node = node.ctx[part]
1066 node.colors = colors
1068 return root
1070 @staticmethod
1071 def __parse_path(path: str, /) -> tuple[list[str], list[str]]:
1072 path_parts = path.split(":", maxsplit=1)
1073 if len(path_parts) == 1:
1074 loc, ctx = path_parts[0], ""
1075 else:
1076 loc, ctx = path_parts
1077 return loc.split("/") if loc else [], ctx.split("/") if ctx else []
1079 def get_color(self, paths: str, /) -> yuio.color.Color:
1080 """
1081 Lookup a color by path.
1083 """
1085 color = yuio.color.Color.NONE
1086 for path in paths.split():
1087 color |= self.__get_color(path)
1088 return color
1090 def __get_color(self, path: str, /) -> yuio.color.Color:
1091 res: yuio.color.Color | None | yuio.Missing = self.__color_cache.get(
1092 path, yuio.MISSING
1093 )
1094 if res is None:
1095 warnings.warn(f"recursive color path {path!r}", RecursiveThemeWarning)
1096 return yuio.color.Color.NONE
1097 elif res is not yuio.MISSING:
1098 return res
1100 self.__color_cache[path] = None
1101 if path.startswith("#") and len(path) == 7:
1102 try:
1103 res = yuio.color.Color.fore_from_hex(path)
1104 except ValueError as e:
1105 warnings.warn(f"invalid color code {path!r}: {e}", ThemeWarning)
1106 res = yuio.color.Color.NONE
1107 elif path[:3].lower() == "bg#" and len(path) == 9:
1108 try:
1109 res = yuio.color.Color.back_from_hex(path[2:])
1110 except ValueError as e:
1111 warnings.warn(f"invalid color code {path!r}: {e}", ThemeWarning)
1112 res = yuio.color.Color.NONE
1113 else:
1114 loc, ctx = self.__parse_path(path)
1115 res = self.__get_color_in_loc(self.__color_tree, loc, ctx)
1116 self.__color_cache[path] = res
1117 return res
1119 def __get_color_in_loc(
1120 self, node: Theme.__ColorTree, loc: list[str], ctx: list[str]
1121 ):
1122 color = yuio.color.Color.NONE
1124 for part in loc:
1125 if part not in node.loc:
1126 break
1127 color |= self.__get_color_in_ctx(node, ctx)
1128 node = node.loc[part]
1130 return color | self.__get_color_in_ctx(node, ctx)
1132 def __get_color_in_ctx(self, node: Theme.__ColorTree, ctx: list[str]):
1133 color = yuio.color.Color.NONE
1135 for part in ctx:
1136 if part not in node.ctx:
1137 break
1138 color |= self.__get_color_in_node(node)
1139 node = node.ctx[part]
1141 return color | self.__get_color_in_node(node)
1143 def __get_color_in_node(self, node: Theme.__ColorTree) -> yuio.color.Color:
1144 color = yuio.color.Color.NONE
1146 if isinstance(node.colors, str):
1147 color |= self.get_color(node.colors)
1148 else:
1149 color |= node.colors
1151 return color
1153 def to_color(
1154 self, color_or_path: yuio.color.Color | str | None, /
1155 ) -> yuio.color.Color:
1156 """
1157 Convert color or color path to color.
1159 """
1161 if color_or_path is None:
1162 return yuio.color.Color.NONE
1163 elif isinstance(color_or_path, yuio.color.Color):
1164 return color_or_path
1165 else:
1166 return self.get_color(color_or_path)
1168 def check(self):
1169 """
1170 Check theme for recursion.
1172 This method is slow, and should be called from unit tests of your application.
1174 """
1176 if "" in self.colors:
1177 warnings.warn("colors map contains an empty key", ThemeWarning)
1179 for k, v in self.colors.items():
1180 if not v:
1181 warnings.warn(f"color value for path {k!r} is empty", ThemeWarning)
1183 err_path = None
1184 with warnings.catch_warnings():
1185 warnings.simplefilter("error", category=RecursiveThemeWarning)
1186 for k in self.colors:
1187 try:
1188 self.get_color(k)
1189 except RecursiveThemeWarning:
1190 err_path = k
1191 if err_path is None:
1192 return
1194 self.__color_cache.clear()
1195 recursive_path = []
1196 get_color_inner = self.__get_color
1198 def get_color(path: str):
1199 recursive_path.append(path)
1200 return get_color_inner(path)
1202 self.__get_color = get_color
1204 try:
1205 with warnings.catch_warnings():
1206 warnings.simplefilter("error", category=RecursiveThemeWarning)
1207 self.get_color(err_path)
1208 except RecursiveThemeWarning:
1209 self.__get_color = get_color_inner
1210 else:
1211 assert False, (
1212 "unreachable, please report hitting this assert "
1213 "to https://github.com/taminomara/yuio/issues"
1214 )
1216 raise RecursiveThemeWarning(
1217 f"infinite recursion in color path {err_path!r}:\n "
1218 + "\n ".join(
1219 f"{path!r} -> {self.colors.get(path)!r}" for path in recursive_path[:-1]
1220 )
1221 )
1224class BaseTheme(Theme):
1225 """
1226 This theme defines :ref:`common colors <common-tags>` that are commonly used
1227 in :ref:`inline color tags <color-tags>`.
1229 """
1231 colors = {
1232 #
1233 # Common tags
1234 # -----------
1235 "code": "italic",
1236 "note": "cyan",
1237 "path": "code",
1238 "flag": "note",
1239 #
1240 # Styles
1241 # ------
1242 "bold": yuio.color.Color.STYLE_BOLD,
1243 "b": "bold",
1244 "dim": yuio.color.Color.STYLE_DIM,
1245 "d": "dim",
1246 "italic": yuio.color.Color.STYLE_ITALIC,
1247 "i": "italic",
1248 "underline": yuio.color.Color.STYLE_UNDERLINE,
1249 "u": "underline",
1250 "inverse": yuio.color.Color.STYLE_INVERSE,
1251 #
1252 # Foreground
1253 # ----------
1254 # Note: we don't have tags for background because it's impossible to guarantee
1255 # that they'll work nicely with whatever foreground you choose. Prefer using
1256 # `inverse` instead.
1257 "normal": yuio.color.Color.FORE_NORMAL,
1258 "muted": yuio.color.Color.FORE_NORMAL_DIM,
1259 "black": yuio.color.Color.FORE_BLACK,
1260 "red": yuio.color.Color.FORE_RED,
1261 "green": yuio.color.Color.FORE_GREEN,
1262 "yellow": yuio.color.Color.FORE_YELLOW,
1263 "blue": yuio.color.Color.FORE_BLUE,
1264 "magenta": yuio.color.Color.FORE_MAGENTA,
1265 "cyan": yuio.color.Color.FORE_CYAN,
1266 "white": yuio.color.Color.FORE_WHITE,
1267 }
1270class DefaultTheme(BaseTheme):
1271 """
1272 Default Yuio theme. Adapts for terminal background color,
1273 if one can be detected.
1275 This theme defines :ref:`main colors <main-colors>`, which you can override
1276 by subclassing. All other colors are expressed in terms of main colors,
1277 so changing a main color will have an effect on the entire theme.
1279 """
1281 msg_decorations_ascii = {
1282 "heading/section": "",
1283 "heading/1": "# ",
1284 "heading/2": "",
1285 "heading/3": "",
1286 "heading/4": "",
1287 "heading/5": "",
1288 "heading/6": "",
1289 "question": "> ",
1290 "task": "> ",
1291 "thematic_break": "-" * 8,
1292 "list": "* ",
1293 "quote": "> ",
1294 "code": " " * 8,
1295 "overflow": "~",
1296 "progress_bar/start_symbol": "[",
1297 "progress_bar/end_symbol": "]",
1298 "progress_bar/done_symbol": "-",
1299 "progress_bar/pending_symbol": " ",
1300 "progress_bar/transition_pattern": ">",
1301 "spinner/pattern": "|||/-\\",
1302 "spinner/static_symbol": ">",
1303 "hr/1/left_start": "-",
1304 "hr/1/left_middle": "-",
1305 "hr/1/left_end": " ",
1306 "hr/1/middle": "-",
1307 "hr/1/right_start": " ",
1308 "hr/1/right_middle": "-",
1309 "hr/1/right_end": "-",
1310 "hr/2/left_start": "=",
1311 "hr/2/left_middle": "=",
1312 "hr/2/left_end": " ",
1313 "hr/2/middle": "=",
1314 "hr/2/right_start": " ",
1315 "hr/2/right_middle": "=",
1316 "hr/2/right_end": "=",
1317 "menu/choice/decoration/active_item": "> ",
1318 "menu/choice/decoration/deselected_item": "- ",
1319 "menu/choice/decoration/selected_item": "* ",
1320 "menu/input/decoration_search": "/ ",
1321 "menu/input/decoration": "> ",
1322 "menu/help/key/alt": "M-",
1323 "menu/help/key/ctrl": "C-",
1324 "menu/help/key/shift": "S-",
1325 "menu/help/key/enter": "ret",
1326 "menu/help/key/escape": "esc",
1327 "menu/help/key/insert": "ins",
1328 "menu/help/key/delete": "del",
1329 "menu/help/key/backspace": "bsp",
1330 "menu/help/key/tab": "tab",
1331 "menu/help/key/home": "home",
1332 "menu/help/key/end": "end",
1333 "menu/help/key/page_up": "pgup",
1334 "menu/help/key/page_down": "pgdn",
1335 "menu/help/key/arrow_up": "up",
1336 "menu/help/key/arrow_down": "down",
1337 "menu/help/key/arrow_left": "left",
1338 "menu/help/key/arrow_right": "right",
1339 "menu/help/key/space": "space",
1340 "menu/help/key/f1": "f1",
1341 "menu/help/key/f2": "f2",
1342 "menu/help/key/f3": "f3",
1343 "menu/help/key/f4": "f4",
1344 "menu/help/key/f5": "f5",
1345 "menu/help/key/f6": "f6",
1346 "menu/help/key/f7": "f7",
1347 "menu/help/key/f8": "f8",
1348 "menu/help/key/f9": "f9",
1349 "menu/help/key/f10": "f10",
1350 "menu/help/key/f11": "f11",
1351 "menu/help/key/f12": "f12",
1352 }
1354 msg_decorations_unicode = {
1355 "heading/section": "",
1356 "heading/1": "⣿ ",
1357 "heading/2": "",
1358 "heading/3": "",
1359 "heading/4": "",
1360 "heading/5": "",
1361 "heading/6": "",
1362 "question": "> ",
1363 "task": "> ",
1364 "thematic_break": "╌╌╌╌╌╌╌╌",
1365 "list": "• ",
1366 "quote": "> ",
1367 "code": " " * 8,
1368 "overflow": "…",
1369 "hr/1/left_start": "─",
1370 "hr/1/left_middle": "─",
1371 "hr/1/left_end": "╴",
1372 "hr/1/middle": "─",
1373 "hr/1/right_start": "╶",
1374 "hr/1/right_middle": "─",
1375 "hr/1/right_end": "─",
1376 "hr/2/left_start": "━",
1377 "hr/2/left_middle": "━",
1378 "hr/2/left_end": "╸",
1379 "hr/2/middle": "━",
1380 "hr/2/right_start": "╺",
1381 "hr/2/right_middle": "━",
1382 "hr/2/right_end": "━",
1383 "progress_bar/start_symbol": "",
1384 "progress_bar/end_symbol": "",
1385 "progress_bar/done_symbol": "■", # "█",
1386 "progress_bar/pending_symbol": "□", # " ",
1387 "progress_bar/transition_pattern": "", # "█▉▊▋▌▍▎▏ ",
1388 "spinner/pattern": "⣤⣤⣤⠶⠛⠛⠛⠶",
1389 "spinner/static_symbol": "⣿",
1390 "menu/input/decoration": "> ",
1391 "menu/input/decoration_search": "/ ",
1392 "menu/choice/decoration/active_item": "> ",
1393 "menu/choice/decoration/selected_item": "◉ ",
1394 "menu/choice/decoration/deselected_item": "○ ",
1395 "menu/help/decoration": ":",
1396 "menu/help/key/alt": "M-",
1397 "menu/help/key/ctrl": "C-",
1398 "menu/help/key/shift": "S-",
1399 "menu/help/key/enter": "ret",
1400 "menu/help/key/escape": "esc",
1401 "menu/help/key/insert": "ins",
1402 "menu/help/key/delete": "del",
1403 "menu/help/key/backspace": "bsp",
1404 "menu/help/key/tab": "tab",
1405 "menu/help/key/home": "home",
1406 "menu/help/key/end": "end",
1407 "menu/help/key/page_up": "pgup",
1408 "menu/help/key/page_down": "pgdn",
1409 "menu/help/key/arrow_up": "↑",
1410 "menu/help/key/arrow_down": "↓",
1411 "menu/help/key/arrow_left": "←",
1412 "menu/help/key/arrow_right": "→",
1413 "menu/help/key/space": "␣",
1414 "menu/help/key/f1": "f1",
1415 "menu/help/key/f2": "f2",
1416 "menu/help/key/f3": "f3",
1417 "menu/help/key/f4": "f4",
1418 "menu/help/key/f5": "f5",
1419 "menu/help/key/f6": "f6",
1420 "menu/help/key/f7": "f7",
1421 "menu/help/key/f8": "f8",
1422 "menu/help/key/f9": "f9",
1423 "menu/help/key/f10": "f10",
1424 "menu/help/key/f11": "f11",
1425 "menu/help/key/f12": "f12",
1426 }
1428 colors = {
1429 "note": "accent_color_2",
1430 #
1431 # Main settings
1432 # -------------
1433 # This section controls the overall theme look.
1434 # Most likely you'll want to change accent colors from here.
1435 "heading_color": "bold primary_color",
1436 "primary_color": "normal",
1437 "accent_color": "magenta",
1438 "accent_color_2": "cyan",
1439 "secondary_color": "muted",
1440 "error_color": "red",
1441 "warning_color": "yellow",
1442 "success_color": "green",
1443 "critical_color": "inverse error_color",
1444 "low_priority_color_a": "muted",
1445 "low_priority_color_b": "muted",
1446 #
1447 # IO messages and text
1448 # --------------------
1449 "msg/decoration": "secondary_color",
1450 "msg/decoration:heading": "heading_color accent_color",
1451 "msg/decoration:thematic_break": "secondary_color",
1452 "msg/text:code": "primary_color",
1453 "msg/text:heading": "heading_color",
1454 "msg/text:heading/1": "accent_color",
1455 "msg/text:heading/section": "green",
1456 "msg/text:heading/note": "green",
1457 "msg/text:question": "heading_color",
1458 "msg/text:error": "error_color",
1459 "msg/text:error/note": "green",
1460 "msg/text:warning": "warning_color",
1461 "msg/text:success": "heading_color success_color",
1462 "msg/text:failure": "heading_color error_color",
1463 "msg/text:info": "primary_color",
1464 "msg/text:thematic_break": "secondary_color",
1465 #
1466 # Log messages
1467 # ------------
1468 "log/name": "dim accent_color_2",
1469 "log/pathname": "dim",
1470 "log/filename": "dim",
1471 "log/module": "dim",
1472 "log/lineno": "dim",
1473 "log/funcName": "dim",
1474 "log/created": "dim",
1475 "log/asctime": "dim",
1476 "log/msecs": "dim",
1477 "log/relativeCreated": "dim",
1478 "log/thread": "dim",
1479 "log/threadName": "dim",
1480 "log/taskName": "dim",
1481 "log/process": "dim",
1482 "log/processName": "dim",
1483 "log/levelno": "log/levelname",
1484 "log/levelno:critical": "log/levelname:critical",
1485 "log/levelno:error": "log/levelname:error",
1486 "log/levelno:warning": "log/levelname:warning",
1487 "log/levelno:info": "log/levelname:info",
1488 "log/levelno:debug": "log/levelname:debug",
1489 "log/levelname": "heading_color",
1490 "log/levelname:critical": "critical_color",
1491 "log/levelname:error": "error_color",
1492 "log/levelname:warning": "warning_color",
1493 "log/levelname:info": "success_color",
1494 "log/levelname:debug": "dim",
1495 "log/message": "primary_color",
1496 "log/message:critical": "bold error_color",
1497 "log/message:debug": "dim",
1498 "log/colMessage": "log/message",
1499 "log/colMessage:critical": "log/message:critical",
1500 "log/colMessage:error": "log/message:error",
1501 "log/colMessage:warning": "log/message:warning",
1502 "log/colMessage:info": "log/message:info",
1503 "log/colMessage:debug": "log/message:debug",
1504 #
1505 # Tasks and progress bars
1506 # -----------------------
1507 "task": "secondary_color",
1508 "task/decoration": "msg/decoration:heading",
1509 "task/decoration:running": "accent_color",
1510 "task/decoration:done": "success_color",
1511 "task/decoration:error": "error_color",
1512 "task/progressbar/done": "accent_color",
1513 "task/progressbar/done/start": "term/bright_blue",
1514 "task/progressbar/done/end": "term/bright_magenta",
1515 "task/progressbar/pending": "secondary_color",
1516 "task/heading": "heading_color",
1517 "task/progress": "secondary_color",
1518 "task/comment": "primary_color",
1519 #
1520 # Syntax highlighting
1521 # -------------------
1522 "hl/kwd": "bold",
1523 "hl/str": "yellow",
1524 "hl/str/esc": "accent_color",
1525 "hl/punct": "secondary_color",
1526 "hl/comment": "green",
1527 "hl/lit": "blue",
1528 "hl/type": "cyan",
1529 "hl/prog": "bold underline",
1530 "hl/flag": "flag",
1531 "hl/meta": "accent_color",
1532 "hl/added": "green",
1533 "hl/removed": "red",
1534 "hl/error": "bold error_color",
1535 "tb/heading": "bold error_color",
1536 "tb/message": "tb/heading",
1537 "tb/frame/usr/file/module": "accent_color",
1538 "tb/frame/usr/file/line": "accent_color",
1539 "tb/frame/usr/file/path": "accent_color",
1540 "tb/frame/usr/code": "primary_color",
1541 "tb/frame/usr/highlight": "error_color",
1542 "tb/frame/lib": "dim",
1543 "tb/frame/lib/file/module": "tb/frame/usr/file/module",
1544 "tb/frame/lib/file/line": "tb/frame/usr/file/line",
1545 "tb/frame/lib/file/path": "tb/frame/usr/file/path",
1546 "tb/frame/lib/code": "tb/frame/usr/code",
1547 "tb/frame/lib/highlight": "tb/frame/usr/highlight",
1548 #
1549 # Menu and widgets
1550 # ----------------
1551 "menu/text": "primary_color",
1552 "menu/text/heading": "menu/text heading_color",
1553 "menu/text/help_info:help": "low_priority_color_a",
1554 "menu/text/help_msg:help": "low_priority_color_b",
1555 "menu/text/help_key:help": "low_priority_color_a",
1556 "menu/text/help_sep:help": "low_priority_color_b",
1557 "menu/text/help_key:help_menu": "accent_color_2",
1558 "menu/text/help_sep:help_menu": "secondary_color",
1559 "menu/text/esc": "white on_magenta",
1560 "menu/text/error": "bold underline error_color",
1561 "menu/text/comment": "accent_color_2",
1562 "menu/text:choice/active": "accent_color",
1563 "menu/text:choice/active/selected": "bold",
1564 "menu/text:choice/normal/selected": "accent_color_2 bold",
1565 "menu/text:choice/normal/dir": "blue",
1566 "menu/text:choice/normal/exec": "red",
1567 "menu/text:choice/normal/symlink": "magenta",
1568 "menu/text:choice/normal/socket": "green",
1569 "menu/text:choice/normal/pipe": "yellow",
1570 "menu/text:choice/normal/block_device": "cyan bold",
1571 "menu/text:choice/normal/char_device": "yellow bold",
1572 "menu/text/comment:choice/normal/original": "success_color",
1573 "menu/text/comment:choice/active/original": "success_color",
1574 "menu/text/comment:choice/normal/corrected": "error_color",
1575 "menu/text/comment:choice/active/corrected": "error_color",
1576 "menu/text/prefix:choice/normal": "primary_color",
1577 "menu/text/prefix:choice/normal/selected": "accent_color_2 bold",
1578 "menu/text/prefix:choice/active": "accent_color",
1579 "menu/text/prefix:choice/active/selected": "bold",
1580 "menu/text/suffix:choice/normal": "primary_color",
1581 "menu/text/suffix:choice/normal/selected": "accent_color_2 bold",
1582 "menu/text/suffix:choice/active": "accent_color",
1583 "menu/text/suffix:choice/active/selected": "bold",
1584 "menu/text:choice/status_line": "low_priority_color_b",
1585 "menu/text:choice/status_line/number": "low_priority_color_a",
1586 "menu/text/placeholder": "secondary_color",
1587 "menu/decoration": "accent_color",
1588 "menu/decoration/comment": "secondary_color",
1589 "menu/decoration:choice/normal": "menu/text",
1590 "menu/decoration:choice/normal/selected": "accent_color_2 bold",
1591 "menu/decoration:choice/active/selected": "bold",
1592 **{f"term/{name}": yuio.color.Color.NONE for name in _COLOR_NAMES},
1593 }
1594 """
1595 Colors for default theme are separated into several sections.
1597 The main section (the first one) has common settings which are referenced
1598 from all other sections. You'll probably want to override
1600 """
1602 def __init__(self, term: yuio.term.Term):
1603 super().__init__()
1605 if (colors := term.terminal_theme) is None:
1606 return
1608 # Gradients look bad in other modes.
1609 if term.supports_colors_true:
1610 for name in _COLOR_NAMES:
1611 self._set_color_if_not_overridden(
1612 f"term/{name}", yuio.color.Color(fore=getattr(colors, name))
1613 )
1615 if colors.lightness == yuio.term.Lightness.UNKNOWN:
1616 return
1618 background = colors.background
1619 foreground = colors.foreground
1621 if colors.lightness is colors.lightness.DARK:
1622 self._set_color_if_not_overridden(
1623 "low_priority_color_a",
1624 yuio.color.Color(
1625 fore=foreground.match_luminosity(background.lighten(0.30))
1626 ),
1627 )
1628 self._set_color_if_not_overridden(
1629 "low_priority_color_b",
1630 yuio.color.Color(
1631 fore=foreground.match_luminosity(background.lighten(0.25))
1632 ),
1633 )
1634 else:
1635 self._set_color_if_not_overridden(
1636 "low_priority_color_a",
1637 yuio.color.Color(
1638 fore=foreground.match_luminosity(background.darken(0.30))
1639 ),
1640 )
1641 self._set_color_if_not_overridden(
1642 "low_priority_color_b",
1643 yuio.color.Color(
1644 fore=foreground.match_luminosity(background.darken(0.25))
1645 ),
1646 )
1649def load(
1650 term: yuio.term.Term,
1651 theme_ctor: _t.Callable[[yuio.term.Term], Theme] | None = None,
1652 /,
1653) -> Theme:
1654 """
1655 Loads a default theme.
1657 """
1659 # NOTE: loading themes from json is beta, don't use it yet.
1661 if theme_ctor is None:
1662 theme_ctor = DefaultTheme
1664 if not (path := os.environ.get("YUIO_THEME_PATH")):
1665 return theme_ctor(term)
1667 import yuio.config
1668 import yuio.parse
1670 class ThemeData(yuio.config.Config):
1671 include: list[str] | str | None = None
1672 progress_bar_width: _t.Annotated[int, yuio.parse.Ge(0)] | None = None
1673 spinner_update_rate_ms: _t.Annotated[int, yuio.parse.Ge(0)] | None = None
1674 separate_headings: bool | None = None
1675 fallback_width: _t.Annotated[int, yuio.parse.Gt(0)] | None = None
1676 msg_decorations_unicode: dict[str, str] = yuio.config.field(
1677 default={},
1678 merge=lambda l, r: {**l, **r},
1679 )
1680 msg_decorations_ascii: dict[str, str] = yuio.config.field(
1681 default={},
1682 merge=lambda l, r: {**l, **r},
1683 )
1684 colors: dict[str, str] = yuio.config.field(
1685 default={},
1686 merge=lambda l, r: {**l, **r},
1687 )
1689 seen = set()
1690 stack = [pathlib.Path(path)]
1691 loaded_partials = []
1692 while stack:
1693 path = stack.pop()
1694 if path in seen:
1695 continue
1696 if not path.exists():
1697 warnings.warn(f"theme file {path} does not exist", ThemeWarning)
1698 continue
1699 if not path.is_file():
1700 warnings.warn(f"theme file {path} is not a file", ThemeWarning)
1701 continue
1702 try:
1703 loaded = ThemeData.load_from_json_file(path, ignore_unknown_fields=True)
1704 except yuio.parse.ParsingError as e:
1705 warnings.warn(str(e), ThemeWarning)
1706 continue
1707 loaded_partials.append(loaded)
1708 include = loaded.include
1709 if isinstance(include, str):
1710 include = [include]
1711 if include:
1712 stack.extend([path.parent / new_path for new_path in include])
1714 theme_data = ThemeData()
1715 for partial in reversed(loaded_partials):
1716 theme_data.update(partial)
1718 theme = theme_ctor(term)
1720 if theme_data.progress_bar_width is not None:
1721 theme.progress_bar_width = theme_data.progress_bar_width
1722 if theme_data.spinner_update_rate_ms is not None:
1723 theme.spinner_update_rate_ms = theme_data.spinner_update_rate_ms
1724 if theme_data.separate_headings is not None:
1725 theme.separate_headings = theme_data.separate_headings
1726 if theme_data.fallback_width is not None:
1727 theme.fallback_width = theme_data.fallback_width
1729 for k, v in theme_data.msg_decorations_ascii.items():
1730 theme.set_msg_decoration_ascii(k, v)
1731 for k, v in theme_data.msg_decorations_unicode.items():
1732 theme.set_msg_decoration_unicode(k, v)
1733 for k, v in theme_data.colors.items():
1734 theme.set_color(k, v)
1736 return theme
1739class TableJunction(IntFlag):
1740 WEST = 1 << 0
1741 WEST_ALT = 1 << 1
1742 SOUTH = 1 << 2
1743 SOUTH_ALT = 1 << 3
1744 EAST = 1 << 4
1745 EAST_ALT = 1 << 5
1746 NORTH = 1 << 6
1747 NORTH_ALT = 1 << 7
1748 ALT_STYLE = 1 << 8
1750 def __repr__(self) -> str:
1751 res = "".join(
1752 [
1753 ["", "n", "", "N"][
1754 bool(self & self.NORTH) + 2 * bool(self & self.NORTH_ALT)
1755 ],
1756 ["", "e", "", "E"][
1757 bool(self & self.EAST) + 2 * bool(self & self.EAST_ALT)
1758 ],
1759 ["", "s", "", "S"][
1760 bool(self & self.SOUTH) + 2 * bool(self & self.SOUTH_ALT)
1761 ],
1762 ["", "w", "", "W"][
1763 bool(self & self.WEST) + 2 * bool(self & self.WEST_ALT)
1764 ],
1765 ["-", "="][bool(self & self.ALT_STYLE)],
1766 ]
1767 )
1768 return f"<{self.__class__.__name__} {res}>"
1771# fmt: off
1772_TABLE_SYMBOLS_UNICODE: dict[int, str] = {
1773 0x000: " ", 0x040: "╵", 0x0C0: "╹", 0x010: "╶", 0x050: "└", 0x0D0: "┖", 0x030: "╺",
1774 0x070: "┕", 0x0F0: "┗", 0x001: "╴", 0x041: "┘", 0x0C1: "┚", 0x011: "─", 0x051: "┴",
1775 0x0D1: "┸", 0x031: "╼", 0x071: "┶", 0x0F1: "┺", 0x003: "╸", 0x043: "┙", 0x0C3: "┛",
1776 0x013: "╾", 0x053: "┵", 0x0D3: "┹", 0x033: "━", 0x073: "┷", 0x0F3: "┻", 0x004: "╷",
1777 0x044: "│", 0x0C4: "╿", 0x014: "┌", 0x054: "├", 0x0D4: "┞", 0x034: "┍", 0x074: "┝",
1778 0x0F4: "┡", 0x005: "┐", 0x045: "┤", 0x0C5: "┦", 0x015: "┬", 0x055: "┼", 0x0D5: "╀",
1779 0x035: "┮", 0x075: "┾", 0x0F5: "╄", 0x007: "┑", 0x047: "┥", 0x0C7: "┩", 0x017: "┭",
1780 0x057: "┽", 0x0D7: "╃", 0x037: "┯", 0x077: "┿", 0x0F7: "╇", 0x00C: "╻", 0x04C: "╽",
1781 0x0CC: "┃", 0x01C: "┎", 0x05C: "┟", 0x0DC: "┠", 0x03C: "┎", 0x07C: "┢", 0x0FC: "┣",
1782 0x00D: "┒", 0x04D: "┧", 0x0CD: "┨", 0x01D: "┰", 0x05D: "╁", 0x0DD: "╂", 0x03D: "┲",
1783 0x07D: "╆", 0x0FD: "╊", 0x00F: "┓", 0x04F: "┪", 0x0CF: "┫", 0x01F: "┱", 0x05F: "╅",
1784 0x0DF: "╉", 0x03F: "┳", 0x07F: "╈", 0x0FF: "╋", 0x100: " ", 0x140: "╵", 0x1C0: "║",
1785 0x110: "╶", 0x150: "└", 0x1D0: "╙", 0x130: "═", 0x170: "╘", 0x1F0: "╚", 0x101: "╴",
1786 0x141: "┘", 0x1C1: "╜", 0x111: "─", 0x151: "┴", 0x1D1: "╨", 0x131: "═", 0x171: "╧",
1787 0x1F1: "╩", 0x103: "╸", 0x143: "╛", 0x1C3: "╝", 0x113: "═", 0x153: "╧", 0x1D3: "╩",
1788 0x133: "═", 0x173: "╧", 0x1F3: "╩", 0x104: "╷", 0x144: "│", 0x1C4: "║", 0x114: "┌",
1789 0x154: "├", 0x1D4: "╟", 0x134: "╒", 0x174: "╞", 0x1F4: "╠", 0x105: "┐", 0x145: "┤",
1790 0x1C5: "╢", 0x115: "┬", 0x155: "┼", 0x1D5: "╫", 0x135: "╤", 0x175: "╪", 0x1F5: "╬",
1791 0x107: "╕", 0x147: "╡", 0x1C7: "╣", 0x117: "╤", 0x157: "╪", 0x1D7: "╬", 0x137: "╤",
1792 0x177: "╪", 0x1F7: "╬", 0x10C: "║", 0x14C: "║", 0x1CC: "║", 0x11C: "╓", 0x15C: "╟",
1793 0x1DC: "╟", 0x13C: "╔", 0x17C: "╠", 0x1FC: "╠", 0x10D: "╖", 0x14D: "╢", 0x1CD: "╢",
1794 0x11D: "╥", 0x15D: "╫", 0x1DD: "╫", 0x13D: "╦", 0x17D: "╬", 0x1FD: "╬", 0x10F: "╗",
1795 0x14F: "╣", 0x1CF: "╣", 0x11F: "╦", 0x15F: "╬", 0x1DF: "╬", 0x13F: "╦", 0x17F: "╬",
1796 0x1FF: "╬",
1797}
1798# fmt: on
1800# fmt: off
1801_TABLE_SYMBOLS_ASCII: dict[int, str] = {
1802 0x000: " ", 0x040: "+", 0x0C0: "+", 0x010: "+", 0x050: "+", 0x0D0: "+", 0x030: "+",
1803 0x070: "+", 0x0F0: "+", 0x001: "+", 0x041: "+", 0x0C1: "+", 0x011: "-", 0x051: "+",
1804 0x0D1: "+", 0x031: "+", 0x071: "+", 0x0F1: "+", 0x003: "+", 0x043: "+", 0x0C3: "+",
1805 0x013: "+", 0x053: "+", 0x0D3: "+", 0x033: "=", 0x073: "+", 0x0F3: "+", 0x004: "+",
1806 0x044: "|", 0x0C4: "+", 0x014: "+", 0x054: "+", 0x0D4: "+", 0x034: "+", 0x074: "+",
1807 0x0F4: "+", 0x005: "+", 0x045: "+", 0x0C5: "+", 0x015: "+", 0x055: "+", 0x0D5: "+",
1808 0x035: "+", 0x075: "+", 0x0F5: "+", 0x007: "+", 0x047: "+", 0x0C7: "+", 0x017: "+",
1809 0x057: "+", 0x0D7: "+", 0x037: "+", 0x077: "+", 0x0F7: "+", 0x00C: "+", 0x04C: "+",
1810 0x0CC: "|", 0x01C: "+", 0x05C: "+", 0x0DC: "+", 0x03C: "+", 0x07C: "+", 0x0FC: "+",
1811 0x00D: "+", 0x04D: "+", 0x0CD: "+", 0x01D: "+", 0x05D: "+", 0x0DD: "+", 0x03D: "+",
1812 0x07D: "+", 0x0FD: "+", 0x00F: "+", 0x04F: "+", 0x0CF: "+", 0x01F: "+", 0x05F: "+",
1813 0x0DF: "+", 0x03F: "+", 0x07F: "+", 0x0FF: "+", 0x100: " ", 0x140: "+", 0x1C0: "#",
1814 0x110: "+", 0x150: "+", 0x1D0: "#", 0x130: "#", 0x170: "#", 0x1F0: "#", 0x101: "+",
1815 0x141: "+", 0x1C1: "#", 0x111: "-", 0x151: "+", 0x1D1: "#", 0x131: "#", 0x171: "#",
1816 0x1F1: "#", 0x103: "#", 0x143: "#", 0x1C3: "#", 0x113: "#", 0x153: "#", 0x1D3: "#",
1817 0x133: "*", 0x173: "#", 0x1F3: "#", 0x104: "+", 0x144: "|", 0x1C4: "#", 0x114: "+",
1818 0x154: "+", 0x1D4: "#", 0x134: "#", 0x174: "#", 0x1F4: "#", 0x105: "+", 0x145: "+",
1819 0x1C5: "#", 0x115: "+", 0x155: "+", 0x1D5: "#", 0x135: "#", 0x175: "#", 0x1F5: "#",
1820 0x107: "#", 0x147: "#", 0x1C7: "#", 0x117: "#", 0x157: "#", 0x1D7: "#", 0x137: "#",
1821 0x177: "#", 0x1F7: "#", 0x10C: "#", 0x14C: "#", 0x1CC: "*", 0x11C: "#", 0x15C: "#",
1822 0x1DC: "#", 0x13C: "#", 0x17C: "#", 0x1FC: "#", 0x10D: "#", 0x14D: "#", 0x1CD: "#",
1823 0x11D: "#", 0x15D: "#", 0x1DD: "#", 0x13D: "#", 0x17D: "#", 0x1FD: "#", 0x10F: "#",
1824 0x14F: "#", 0x1CF: "#", 0x11F: "#", 0x15F: "#", 0x1DF: "#", 0x13F: "#", 0x17F: "#",
1825 0x1FF: "#",
1826}
1827# fmt: on