# Yuio project, MIT license.
#
# https://github.com/taminomara/yuio/
#
# You're free to copy this file to your project and edit it for your needs,
# just keep this copyright line please :3

"""
Controlling visual aspects of Yuio with themes.

Theme base class
----------------

The overall look and feel of a Yuio application is declared
in a :class:`Theme` object:

.. autoclass:: Theme

    .. autoattribute:: progress_bar_width

    .. autoattribute:: progress_bar_start_symbol

    .. autoattribute:: progress_bar_end_symbol

    .. autoattribute:: progress_bar_done_symbol

    .. autoattribute:: progress_bar_pending_symbol

    .. autoattribute:: spinner_pattern

    .. autoattribute:: spinner_update_rate_ms

    .. autoattribute:: spinner_static_symbol

    .. autoattribute:: msg_decorations

    .. automethod:: set_msg_decoration

    .. automethod:: _set_msg_decoration_if_not_overridden

    .. autoattribute:: colors

    .. automethod:: set_color

    .. automethod:: _set_color_if_not_overridden

    .. automethod:: get_color

    .. automethod:: to_color


Default theme
-------------

Use the following loader to create an instance of the default theme:

.. autofunction:: load

.. autoclass:: DefaultTheme

"""

from __future__ import annotations

import dataclasses
import functools
import os
from dataclasses import dataclass

import yuio.term
from yuio import _typing as _t

__all__ = [
    "Theme",
    "DefaultTheme",
    "load",
]

T = _t.TypeVar("T")


class _ImmutableDictProxy(_t.Mapping[str, T], _t.Generic[T]):
    def __init__(self, data: dict[str, T], /, *, attr: str):
        self.__data = data
        self.__attr = attr

    def items(self) -> _t.ItemsView[str, T]:
        return self.__data.items()

    def keys(self) -> _t.KeysView[str]:
        return self.__data.keys()

    def values(self) -> _t.ValuesView[T]:
        return self.__data.values()

    def __len__(self):
        return len(self.__data)

    def __getitem__(self, key):
        return self.__data[key]

    def __iter__(self):
        return iter(self.__data)

    def __contains__(self, key):
        return key in self.__data

    def __repr__(self):
        return repr(self.__data)

    def __setitem__(self, key, item):
        raise RuntimeError(f"Theme.{self.__attr} is immutable")

    def __delitem__(self, key):
        raise RuntimeError(f"Theme.{self.__attr} is immutable")


class Theme:
    """
    Base class for Yuio themes.

    """

    msg_decorations: _t.Mapping[str, str] = {
        "heading/section": "",
        "heading/1": "⣿ ",
        "heading/2": "",
        "heading/3": "",
        "heading/4": "",
        "heading/5": "",
        "heading/6": "",
        "question": "> ",
        "task": "> ",
        "thematic_break": "╌╌╌╌╌",
        "list": "•   ",
        "quote": ">   ",
        "code": " " * 8,
        # TODO: support these in widgets
        # 'menu_selected_item': '▶︎',
        # 'menu_default_item': '★',
        # 'menu_select': '#',
        # 'menu_search': '/',
    }
    """
    Decorative symbols for certain text elements, such as headings,
    list items, etc.

    This mapping becomes immutable once a theme class is created. The only possible
    way to modify it is by using :meth:`~Theme.set_msg_decoration`
    or :meth:`~Theme._set_msg_decoration_if_not_overridden`.

    """

    __msg_decorations: dict[str, str]
    """
    An actual mutable version of :attr:`~Theme.msg_decorations`
    is kept here, because `__init_subclass__` will replace
    :attr:`~Theme.msg_decorations` with an immutable proxy.

    """

    __msg_decoration_sources: dict[str, type | None] = {}
    """
    Keeps track of where a message decoration was inherited from. This var is used
    to avoid `__init__`-ing message decorations that were overridden in a subclass.

    """

    progress_bar_width: int = 15
    """
    Width of a progress bar for :class:`yuio.io.Task`.

    """

    progress_bar_start_symbol: str = ""
    """
    A symbol rendered on a left side of a progressbar.

    Set to ``"["`` to enclose progressbar in square brackets, for example.

    """

    progress_bar_end_symbol: str = ""
    """
    A symbol rendered on a right side of a progressbar.

    Set to ``"]"`` to enclose progressbar in square brackets, for example.

    """

    progress_bar_done_symbol: str = "■"
    """
    Symbol rendered in the filled portion of a progressbar.

    """

    progress_bar_pending_symbol: str = "□"
    """
    Symbol rendered in the unfilled portion of a progressbar.

    """

    spinner_pattern: _t.Sequence[str] = "⣤⣤⣤⠶⠛⠛⠛⠶"
    """
    Spinner pattern for running tasks that don't have a progressbar.

    Every tick, a symbol in front of a task's heading updates, showing elements
    of this sequence.

    """

    spinner_update_rate_ms: int = 200
    """
    How often the :attr:`~Theme.spinner_pattern` changes.

    """

    spinner_static_symbol = "⣿"
    """
    Symbol for finished and failed tasks.

    It meant to resemble a static spinner.

    """

    colors: _t.Mapping[str, str | yuio.term.Color | list[str | yuio.term.Color]] = {
        "code": "magenta",
        "note": "green",
        "bold": yuio.term.Color.STYLE_BOLD,
        "b": "bold",
        "dim": yuio.term.Color.STYLE_DIM,
        "d": "dim",
        "normal": yuio.term.Color.FORE_NORMAL,
        "normal_dim": yuio.term.Color.FORE_NORMAL_DIM,
        "red": yuio.term.Color.FORE_RED,
        "green": yuio.term.Color.FORE_GREEN,
        "yellow": yuio.term.Color.FORE_YELLOW,
        "blue": yuio.term.Color.FORE_BLUE,
        "magenta": yuio.term.Color.FORE_MAGENTA,
        "cyan": yuio.term.Color.FORE_CYAN,
    }
    """
    Mapping of color paths to actual colors.

    Themes use color paths to describe styles and colors for different
    parts of an application. yuio.term.Color paths are similar to file paths,
    they use snake case identifiers separated by slashes, and consist of
    two parts separated by a colon.

    The first part represents an object, i.e. what are we coloring.

    The second part represents a context, i.e. what is the state or location
    of an object that we're coloring.

    For example, a color for the filled part of the task's progress bar
    has path ``"task/progressbar/done"``, a color for a text of an error
    log record has path ``"log/message:error"``, and a color for a string escape
    sequence in a highlighted python code has path ``"hl/str/esc:python"``.

    A color at a certain path is propagated to all sub-paths. For example,
    if ``"task/progressbar"`` is bold, and ``"task/progressbar/done"`` is green,
    the final color will be bold green.

    Each color path can be associated with either an instance
    of :class:`~yuio.term.Color`, another path, or a list of colors and paths.

    If path is mapped to a :class:`~yuio.term.Color`, then the path is associated
    with that particular color.

    If path is mapped to another path, then the path is associated with
    the color value for that other path (please don't create recursions here).

    If path is mapped to a list of colors and paths, then those colors and paths
    are combined.

    For example::

        colors = {
            'heading_color': yuio.term.Color.BOLD,
            'error_color': yuio.term.Color.RED,
            'tb/heading': ['heading_color', 'error_color'],
        }

    Here, color of traceback's heading ``"tb/heading"`` will be bold and red.

    The base theme class provides colors for basic tags, such as `bold`, `red`,
    `code`, `note`, etc. :class:`DefaultTheme` expands on it, providing main
    colors that control the overall look of the theme, and then colors for all
    interface elements.

    When deriving from a theme, you can override this mapping. When looking up
    colors via :meth:`~Theme.get_color`, base classes will be tried for color,
    in order of method resolution.

    This mapping becomes immutable once a theme class is created. The only possible
    way to modify it is by using :meth:`~Theme.set_color`
    or :meth:`~Theme._set_color_if_not_overridden`.

    """

    __colors: dict[str, str | yuio.term.Color | list[str | yuio.term.Color]]
    """
    An actual mutable version of :attr:`~Theme.colors`
    is kept here, because `__init_subclass__` will replace
    :attr:`~Theme.colors` with an immutable proxy.

    """

    __color_sources: dict[str, type | None] = {}
    """
    Keeps track of where a color was inherited from. This var is used
    to avoid `__init__`-ing colors that were overridden in a subclass.

    """

    __expected_source: type | None = None
    """
    When running an `__init__` function, this variable will be set to the class
    that implemented it, regardless of type of `self`.

    That is, inside `DefaultTheme.__init__`, `__expected_source` is set
    to `DefaultTheme`, in `MyTheme.__init__` it is `MyTheme`, etc.

    This is possible because `__init_subclass__` wraps any implementation
    of `__init__` into a wrapper that sets this variable.

    """

    def __init__(self):
        pass

    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)

        colors = {}
        color_sources = {}
        for base in reversed(cls.__mro__):
            base_colors = getattr(base, "_Theme__colors", {})
            colors.update(base_colors)
            base_color_sources = getattr(base, "_Theme__color_sources", {})
            color_sources.update(base_color_sources)

        colors.update(cls.colors)
        color_sources.update(dict.fromkeys(cls.colors.keys(), cls))

        cls.__colors = colors
        cls.__color_sources = color_sources
        cls.colors = _ImmutableDictProxy(cls.__colors, attr="colors")

        msg_decorations = {}
        msg_decoration_sources = {}
        for base in reversed(cls.__mro__):
            base_msg_decorations = getattr(base, "_Theme__msg_decorations", {})
            msg_decorations.update(base_msg_decorations)
            base_msg_decoration_sources = getattr(
                base, "_Theme__msg_decoration_sources", {}
            )
            msg_decoration_sources.update(base_msg_decoration_sources)

        msg_decorations.update(cls.msg_decorations)
        msg_decoration_sources.update(dict.fromkeys(cls.msg_decorations.keys(), cls))

        cls.__msg_decorations = msg_decorations
        cls.__msg_decoration_sources = msg_decoration_sources
        cls.msg_decorations = _ImmutableDictProxy(
            cls.__msg_decorations, attr="msg_decorations"
        )

        if init := cls.__dict__.get("__init__", None):

            @functools.wraps(init)
            def _wrapped_init(_self, *args, **kwargs):
                prev_expected_source = _self._Theme__expected_source
                _self._Theme__expected_source = cls
                try:
                    return init(_self, *args, **kwargs)
                finally:
                    _self._Theme__expected_source = prev_expected_source

            cls.__init__ = _wrapped_init  # type: ignore

    def _set_msg_decoration_if_not_overridden(
        self,
        name: str,
        msg_decoration: str,
        /,
    ):
        """
        Set message decoration by name, but only if it wasn't overridden
        in a subclass.

        This method should be called from `__init__` implementations
        to dynamically set message decorations. It will only set the decoration
        if it was not overridden by any child class.

        """

        if self.__expected_source is None:
            raise RuntimeError(
                f"_set_msg_decoration_if_not_overridden should only be called from __init__"
            )
        source = self.__msg_decoration_sources.get(name, Theme)
        # The class that's `__init__` is currently running should be a parent
        # of the msg_decoration's source. This means that the msg_decoration was assigned by a parent.
        if source is not None and issubclass(self.__expected_source, source):
            self.set_msg_decoration(name, msg_decoration)

    def set_msg_decoration(
        self,
        name: str,
        msg_decoration: str,
        /,
    ):
        """
        Set message decoration by name.

        """

        if "_Theme__msg_decorations" not in self.__dict__:
            self.__msg_decorations = self.__class__.__msg_decorations.copy()
            self.__msg_decoration_sources = (
                self.__class__.__msg_decoration_sources.copy()
            )
            self.msg_decorations = _ImmutableDictProxy(
                self.__msg_decorations, attr="msg_decorations"
            )
        self.__msg_decorations[name] = msg_decoration
        self.__msg_decoration_sources[name] = self.__expected_source

    def _set_color_if_not_overridden(
        self,
        path: str,
        color: str | yuio.term.Color | list[str | yuio.term.Color],
        /,
    ):
        """
        Set color by path, but only if the color was not overridden in a subclass.

        This method should be called from `__init__` implementations
        to dynamically set colors. It will only set the color if it was not overridden
        by any child class.

        """

        if self.__expected_source is None:
            raise RuntimeError(
                f"_set_color_if_not_overridden should only be called from __init__"
            )
        source = self.__color_sources.get(path, Theme)
        # The class who's `__init__` is currently running should be a parent
        # of the color's source. This means that the color was assigned by a parent.
        if source is not None and issubclass(self.__expected_source, source):
            self.set_color(path, color)

    def set_color(
        self,
        path: str,
        color: str | yuio.term.Color | list[str | yuio.term.Color],
        /,
    ):
        """
        Set color by path.

        """

        if "_Theme__colors" not in self.__dict__:
            self.__colors = self.__class__.__colors.copy()
            self.__color_sources = self.__class__.__color_sources.copy()
            self.colors = _ImmutableDictProxy(self.__colors, attr="colors")
        self.__colors[path] = color
        self.__color_sources[path] = self.__expected_source
        self.get_color.cache_clear()
        self.__dict__.pop("_Theme__color_tree", None)

    @dataclass(**yuio._with_slots())
    class __ColorTree:
        """
        Prefix-like tree that contains all of the theme's colors.

        """

        colors: str | yuio.term.Color | list[str | yuio.term.Color] = (
            yuio.term.Color.NONE
        )
        """
        Colors in this node.

        """

        loc: dict[str, Theme.__ColorTree] = dataclasses.field(default_factory=dict)
        """
        Location part of the tree.

        """

        ctx: dict[str, Theme.__ColorTree] = dataclasses.field(default_factory=dict)
        """
        Context part of the tree.

        """

    @functools.cached_property
    def __color_tree(self) -> Theme.__ColorTree:
        root = self.__ColorTree()

        for path, colors in self.__colors.items():
            loc, ctx = self.__parse_path(path)

            node = root

            for part in loc:
                if part not in node.loc:
                    node.loc[part] = self.__ColorTree()
                node = node.loc[part]

            for part in ctx:
                if part not in node.ctx:
                    node.ctx[part] = self.__ColorTree()
                node = node.ctx[part]

            node.colors = colors

        return root

    @staticmethod
    def __parse_path(path: str, /) -> tuple[list[str], list[str]]:
        path_parts = path.split(":", maxsplit=1)
        if len(path_parts) == 1:
            loc, ctx = path_parts[0], ""
        else:
            loc, ctx = path_parts
        return loc.split("/") if loc else [], ctx.split("/") if ctx else []

    @_t.final
    @functools.cache
    def get_color(self, path: str, /) -> yuio.term.Color:
        """
        Lookup a color by path.

        """

        loc, ctx = self.__parse_path(path)
        return self.__get_color_in_loc(self.__color_tree, loc, ctx)

    def __get_color_in_loc(
        self, node: Theme.__ColorTree, loc: list[str], ctx: list[str]
    ):
        color = yuio.term.Color.NONE

        for part in loc:
            if part not in node.loc:
                break
            color |= self.__get_color_in_ctx(node, ctx)
            node = node.loc[part]

        return color | self.__get_color_in_ctx(node, ctx)

    def __get_color_in_ctx(self, node: Theme.__ColorTree, ctx: list[str]):
        color = yuio.term.Color.NONE

        for part in ctx:
            if part not in node.ctx:
                break
            color |= self.__get_color_in_node(node)
            node = node.ctx[part]

        return color | self.__get_color_in_node(node)

    def __get_color_in_node(self, node: Theme.__ColorTree) -> yuio.term.Color:
        color = yuio.term.Color.NONE

        if isinstance(node.colors, str):
            color |= self.get_color(node.colors)
        elif isinstance(node.colors, list):
            for c in node.colors:
                color |= self.get_color(c) if isinstance(c, str) else c
        else:
            color |= node.colors

        return color

    def to_color(self, color_or_path: yuio.term.Color | str | None) -> yuio.term.Color:
        """
        Convert color or color path to color.

        """

        if color_or_path is None:
            return yuio.term.Color.NONE
        elif isinstance(color_or_path, yuio.term.Color):
            return color_or_path
        else:
            return self.get_color(color_or_path)


Theme.__init_subclass__()


class DefaultTheme(Theme):
    """
    Default Yuio theme. Adapts for terminal background color,
    if one can be detected.

    This theme defines *main colors*, which you can override by subclassing.

    - ``"heading_color"``: for headings,
    - ``"primary_color"``: for main text,
    - ``"accent_color"``: for visually highlighted elements,
    - ``"secondary_color"``: for visually dimmed elements,
    - ``"error_color"``: for everything that indicates an error,
    - ``"warning_color"``: for everything that indicates a warning,
    - ``"success_color"``: for everything that indicates a success,
    - ``"low_priority_color_a"``: for auxiliary elements such as help widget,
    - ``"low_priority_color_b"``: for auxiliary elements such as help widget,
      even lower priority.

    """

    colors = {
        #
        # Main settings
        # -------------
        # This section controls the overall theme look.
        # Most likely you'll want to change accent colors from here.
        "heading_color": ["bold", "primary_color"],
        "primary_color": "normal",
        "accent_color": "magenta",
        "accent_color_2": "cyan",
        "secondary_color": "normal_dim",
        "error_color": "red",
        "warning_color": "yellow",
        "success_color": "green",
        "low_priority_color_a": "normal_dim",
        "low_priority_color_b": "normal_dim",
        #
        # Common tags
        # -----------
        "code": "accent_color",
        "note": "accent_color_2",
        "path": "accent_color",
        #
        # IO messages and text
        # --------------------
        "msg/decoration": "secondary_color",
        "msg/decoration:heading": "accent_color",
        "msg/decoration:thematic_break": "secondary_color",
        "msg/text": "primary_color",
        "msg/text:heading": "heading_color",
        "msg/text:heading/section": "accent_color",
        "msg/text:question": "heading_color",
        "msg/text:error": "error_color",
        "msg/text:warning": "warning_color",
        "msg/text:success": ["bold", "success_color"],
        "msg/text:failure": ["bold", "error_color"],
        "msg/text:info": "primary_color",
        "msg/text:thematic_break": "secondary_color",
        #
        # Log messages
        # ------------
        "log/asctime": "secondary_color",
        "log/logger": "secondary_color",
        "log/level": "heading_color",
        "log/level:critical": "error_color",
        "log/level:error": "error_color",
        "log/level:warning": "warning_color",
        "log/level:info": "success_color",
        "log/level:debug": "secondary_color",
        "log/message": "primary_color",
        #
        # Tasks and progress bars
        # -----------------------
        "task": "secondary_color",
        "task/decoration:running": "accent_color",
        "task/decoration:done": "success_color",
        "task/decoration:error": "error_color",
        "task/progressbar/done": "accent_color",
        "task/progressbar/pending": "secondary_color",
        "task/heading": "heading_color",
        "task/progress": "secondary_color",
        "task/comment": "primary_color",
        #
        # Syntax highlighting
        # -------------------
        "hl/kwd": "bold",
        "hl/str": yuio.term.Color.NONE,
        "hl/str/esc": "bold",
        "hl/lit": yuio.term.Color.NONE,
        "hl/punct": "blue",
        "hl/punct:sh-usage": "secondary_color",
        "hl/comment": "secondary_color",
        "hl/prog": "bold",
        "hl/flag": "accent_color_2",
        "hl/meta:diff": "accent_color",
        "hl/added:diff": "green",
        "hl/removed:diff": "red",
        "hl/metavar": "bold",
        "tb/heading": ["bold", "red"],
        "tb/message": "tb/heading",
        "tb/frame/usr/file/module": "code",
        "tb/frame/usr/file/line": "code",
        "tb/frame/usr/file/path": "code",
        "tb/frame/usr/code": yuio.term.Color.NONE,
        "tb/frame/usr/highlight": "low_priority_color_a",
        "tb/frame/lib": "dim",
        "tb/frame/lib/file/module": "tb/frame/usr/file/module",
        "tb/frame/lib/file/line": "tb/frame/usr/file/line",
        "tb/frame/lib/file/path": "tb/frame/usr/file/path",
        "tb/frame/lib/code": "tb/frame/usr/code",
        "tb/frame/lib/highlight": "tb/frame/usr/highlight",
        #
        # Menu and widgets
        # ----------------
        "menu/text": "primary_color",
        "menu/text/heading": ["menu/text", "heading_color"],
        "menu/text/help_info:help": "low_priority_color_a",
        "menu/text/help_msg:help": "low_priority_color_b",
        "menu/text/help_key:help": "low_priority_color_a",
        "menu/text/help_sep:help": "low_priority_color_b",
        "menu/text/help_key:help_menu": "accent_color_2",
        "menu/text/help_sep:help_menu": "secondary_color",
        "menu/text/esc": "accent_color",
        "menu/text/comment": "accent_color_2",
        "menu/text/comment/decoration": "secondary_color",
        "menu/text:choice/active": "accent_color",
        "menu/text:choice/active/selected": ["bold"],
        "menu/text:choice/normal/selected": ["accent_color_2", "bold"],
        "menu/text:choice/normal/dir": "blue",
        "menu/text:choice/normal/exec": "red",
        "menu/text:choice/normal/symlink": "magenta",
        "menu/text:choice/normal/socket": "green",
        "menu/text:choice/normal/pipe": "yellow",
        "menu/text:choice/normal/block_device": ["cyan", "bold"],
        "menu/text:choice/normal/char_device": ["yellow", "bold"],
        "menu/text/comment:choice/normal/original": "success_color",
        "menu/text/comment:choice/normal/corrected": "error_color",
        "menu/text/prefix:choice/normal": "primary_color",
        "menu/text/prefix:choice/normal/selected": ["accent_color_2", "bold"],
        "menu/text/prefix:choice/active": "accent_color",
        "menu/text/prefix:choice/active/selected": ["bold"],
        "menu/text/suffix:choice/normal": "primary_color",
        "menu/text/suffix:choice/normal/selected": ["accent_color_2", "bold"],
        "menu/text/suffix:choice/active": "accent_color",
        "menu/text/suffix:choice/active/selected": ["bold"],
        "menu/text:choice/status_line": "low_priority_color_b",
        "menu/text:choice/status_line/number": "low_priority_color_a",
        "menu/placeholder": "secondary_color",
        "menu/decoration": "accent_color",
        "menu/decoration/quick-select": "secondary_color",
        "menu/decoration:choice/normal": "menu/text",
    }
    """
    Colors for default theme are separated into several sections.

    The main section (the first one) has common settings which are referenced
    from all other sections. You'll probably want to override

    """

    def __init__(self, term: yuio.term.Term):
        super().__init__()

        if term.terminal_colors is None:
            return

        # Gradients look bad in other modes.
        if term.has_colors_true:
            self._set_color_if_not_overridden(
                "task/progressbar/done/start",
                yuio.term.Color(fore=term.terminal_colors.blue),
            )
            self._set_color_if_not_overridden(
                "task/progressbar/done/end",
                yuio.term.Color(fore=term.terminal_colors.magenta),
            )

        if term.terminal_colors.lightness == yuio.term.Lightness.UNKNOWN:
            return

        background = term.terminal_colors.background
        foreground = term.terminal_colors.foreground

        if term.terminal_colors.lightness is term.terminal_colors.lightness.DARK:
            self._set_color_if_not_overridden(
                "low_priority_color_a",
                yuio.term.Color(
                    fore=foreground.match_luminosity(background.lighten(0.30))
                ),
            )
            self._set_color_if_not_overridden(
                "low_priority_color_b",
                yuio.term.Color(
                    fore=foreground.match_luminosity(background.lighten(0.25))
                ),
            )
        else:
            self._set_color_if_not_overridden(
                "low_priority_color_a",
                yuio.term.Color(
                    fore=foreground.match_luminosity(background.darken(0.30))
                ),
            )
            self._set_color_if_not_overridden(
                "low_priority_color_b",
                yuio.term.Color(
                    fore=foreground.match_luminosity(background.darken(0.25))
                ),
            )


def load(term: yuio.term.Term, /) -> Theme:
    """
    Loads a default theme.

    """

    import yuio.term

    # NOTE: loading themes from json is beta, don't use it yet.

    theme = DefaultTheme(term)

    if not (path := os.environ.get("YUIO_THEME_PATH")):
        return theme

    import yuio.config

    class ThemeData(yuio.config.Config):
        progress_bar_width: int | None = None
        progress_bar_start_symbol: str | None = None
        progress_bar_end_symbol: str | None = None
        progress_bar_done_symbol: str | None = None
        progress_bar_pending_symbol: str | None = None
        spinner_pattern: str | None = None
        spinner_update_rate_ms: int | None = None
        spinner_static_symbol: str | None = None
        msg_decorations: dict[str, str] | None = None
        colors: dict[str, str] | None = None

    theme_data = ThemeData.load_from_json_file(path)

    if theme_data.progress_bar_width is not None:
        theme.progress_bar_width = theme_data.progress_bar_width
    if theme_data.progress_bar_start_symbol is not None:
        theme.progress_bar_start_symbol = theme_data.progress_bar_start_symbol
    if theme_data.progress_bar_end_symbol is not None:
        theme.progress_bar_end_symbol = theme_data.progress_bar_end_symbol
    if theme_data.progress_bar_done_symbol is not None:
        theme.progress_bar_done_symbol = theme_data.progress_bar_done_symbol
    if theme_data.progress_bar_pending_symbol is not None:
        theme.progress_bar_pending_symbol = theme_data.progress_bar_pending_symbol
    if theme_data.spinner_pattern is not None:
        theme.spinner_pattern = theme_data.spinner_pattern
    if theme_data.spinner_update_rate_ms is not None:
        theme.spinner_update_rate_ms = theme_data.spinner_update_rate_ms
    if theme_data.spinner_static_symbol is not None:
        theme.spinner_static_symbol = theme_data.spinner_static_symbol

    if theme_data.msg_decorations is not None:
        for k, v in theme_data.msg_decorations.items():
            theme.set_msg_decoration(k, v)

    if theme_data.colors is not None:
        theme.colors = theme_data.colors

        for k, v in theme_data.colors.items():
            v = [
                yuio.term.Color.fore_from_hex(c) if c.startswith("#") else c
                for c in v.split()
            ]
            theme.set_color(k, v[0] if len(v) == 1 else v)

    return theme
