TomSchimansky / CustomTkinter

A modern and customizable python UI-library based on Tkinter
MIT License
11.43k stars 1.08k forks source link

Enhancement Request: More Flexible Theme Styling #1857

Open loafthecomputerphile opened 1 year ago

loafthecomputerphile commented 1 year ago

While looking through the source code for some insight on how the json theme files work i believe that i found a new styling method that can be implemented which can help make it easier, faster and more powerful to use theme sheets while keeping the old theme method the same.

This method is similar to how you would style HTML with CSS via the use of classes or ids. This could simply be done by adding an id parameter when calling a widget which is initialized with the name of that widget and then that id is passed to the ThemeManager in the init function of that widget.

Modified code


class CTkButton(CTkBaseClass):

    _image_label_spacing: int = 6

    def __init__(self,
                 master: any,
                 width: int = 140,
                 height: int = 28,
                 id: str = "CTkButton", #edit here
                 corner_radius: Optional[int] = None,
                 border_width: Optional[int] = None,
                 border_spacing: int = 2,

                 bg_color: Union[str, Tuple[str, str]] = "transparent",
                 fg_color: Optional[Union[str, Tuple[str, str]]] = None,
                 hover_color: Optional[Union[str, Tuple[str, str]]] = None,
                 border_color: Optional[Union[str, Tuple[str, str]]] = None,
                 text_color: Optional[Union[str, Tuple[str, str]]] = None,
                 text_color_disabled: Optional[Union[str, Tuple[str, str]]] = None,

                 background_corner_colors: Union[Tuple[Union[str, Tuple[str, str]]], None] = None,
                 round_width_to_even_numbers: bool = True,
                 round_height_to_even_numbers: bool = True,

                 text: str = "CTkButton",
                 font: Optional[Union[tuple, CTkFont]] = None,
                 textvariable: Union[tkinter.Variable, None] = None,
                 image: Union[CTkImage, "ImageTk.PhotoImage", None] = None,
                 state: str = "normal",
                 hover: bool = True,
                 command: Union[Callable[[], None], None] = None,
                 compound: str = "left",
                 anchor: str = "center",
                 **kwargs):

        # transfer basic functionality (bg_color, size, appearance_mode, scaling) to CTkBaseClass
        super().__init__(master=master, bg_color=bg_color, width=width, height=height, **kwargs)
        self.id: str = id #made it a class variable

                                       #+++++++++++++++++++++++++++++++++++++++++#
                     ######## used self.id in place of CTKButton with theme manager as a Key ######## 
                                      #^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^#

        # shape
        self._corner_radius: int = ThemeManager.theme[self.id]["corner_radius"] if corner_radius is None else corner_radius
        self._corner_radius = min(self._corner_radius, round(self._current_height / 2))
        self._border_width: int = ThemeManager.theme[self.id]["border_width"] if border_width is None else border_width
        self._border_spacing: int = border_spacing

        # color
        self._fg_color: Union[str, Tuple[str, str]] = ThemeManager.theme[self.id]["fg_color"] if fg_color is None else self._check_color_type(fg_color, transparency=True)
        self._hover_color: Union[str, Tuple[str, str]] = ThemeManager.theme[self.id]["hover_color"] if hover_color is None else self._check_color_type(hover_color)
        self._border_color: Union[str, Tuple[str, str]] = ThemeManager.theme[self.id]["border_color"] if border_color is None else self._check_color_type(border_color)
        self._text_color: Union[str, Tuple[str, str]] = ThemeManager.theme[self.id]["text_color"] if text_color is None else self._check_color_type(text_color)
        self._text_color_disabled: Union[str, Tuple[str, str]] = ThemeManager.theme[self.id]["text_color_disabled"] if text_color_disabled is None else self._check_color_type(text_color_disabled)

Theme.json Representation

so now if the id of the button was id = "primary_button"

theme.json


{
    "primary_button":{
        "corner_radius": 6,
        "border_width": 0,
        "fg_color": ["#3a7ebf", "#1f538d"],
        "hover_color": ["#325882", "#14375e"],
        "border_color": ["#3E454A", "#949A9F"],
        "text_color": ["#DCE4EE", "#DCE4EE"],
        "text_color_disabled": ["gray74", "gray60"]
    }
}

finally after importing the theme the button would automatically be themed without the need of constant restyling and allows unique theme styles for the same widget type but for different uses

from what i see in the source code this may work and should be easy to implement

thank you for your time reading this

ghost commented 1 year ago

So users would be able to add their own IDs to the .json file?

This would require an exception to be made in the case that the user passes a ID but that ID doesn't exist in the theme. Alternatively, if the ID doesn't exist in the theme, the widget would just use the default theme colors.

avalon60 commented 1 year ago

Would these id's not need to be associated with a primitive widget? Otherwise, how would theme manager know which theme properties to resolve to specific widget's and widget properties.

loafthecomputerphile commented 1 year ago

@avalon60 @dishb thanks for pointing that out

i believe that you can set to default by first allowing a secondary theme import which will merge with the base theme dictionary if it is used which helps with backwards compatibility while keeping valid original themes if there isn't conflicts. the priority theme is the user made theme.

then in the button file we check if the id is in the dictionary. if it isnt it reverts to the the original theme via id

Theme manager

class ThemeManager:

    theme: dict = {}  # contains all the theme data
    _priority_loaded_theme : Union[str, None] = None
    _built_in_themes: List[str] = ["blue", "green", "dark-blue", "sweetkind"]
    _currently_loaded_theme: Union[str, None] = None

    @classmethod
    def load_theme(cls, theme_name_or_path: str, priority_theme_path: Union[str, None]):
        script_directory = os.path.dirname(os.path.abspath(__file__))

        if theme_name_or_path in cls._built_in_themes:
            customtkinter_path = pathlib.Path(script_directory).parent.parent.parent
            with open(os.path.join(customtkinter_path, "assets", "themes", f"{theme_name_or_path}.json"), "r") as f:
                cls.theme = json.load(f)
        else:
            with open(theme_name_or_path, "r") as f:
                cls.theme = json.load(f)

        #loads and merges the dictionary and overwrites conflicts if there are any
        if priority_theme_path != None:
            with open(priority_theme_path, "r") as f:
                cls.theme = {**cls.theme, **json.load(f)}

        # store theme path for saving
        cls._priority_loaded_theme = priority_theme_path
        cls._currently_loaded_theme = theme_name_or_path

        # filter theme values for platform
        for key in cls.theme.keys():
            # check if values for key differ on platforms
            if "macOS" in cls.theme[key].keys():
                if sys.platform == "darwin":
                    cls.theme[key] = cls.theme[key]["macOS"]
                elif sys.platform.startswith("win"):
                    cls.theme[key] = cls.theme[key]["Windows"]
                else:
                    cls.theme[key] = cls.theme[key]["Linux"]

Button code

class CTkButton(CTkBaseClass):

    _image_label_spacing: int = 6

    def __init__(self,
                 master: any,
                 width: int = 140,
                 height: int = 28,
                 id: str = "CTkButton", #edit here
                 corner_radius: Optional[int] = None,
                 border_width: Optional[int] = None,
                 border_spacing: int = 2,

                 bg_color: Union[str, Tuple[str, str]] = "transparent",
                 fg_color: Optional[Union[str, Tuple[str, str]]] = None,
                 hover_color: Optional[Union[str, Tuple[str, str]]] = None,
                 border_color: Optional[Union[str, Tuple[str, str]]] = None,
                 text_color: Optional[Union[str, Tuple[str, str]]] = None,
                 text_color_disabled: Optional[Union[str, Tuple[str, str]]] = None,

                 background_corner_colors: Union[Tuple[Union[str, Tuple[str, str]]], None] = None,
                 round_width_to_even_numbers: bool = True,
                 round_height_to_even_numbers: bool = True,

                 text: str = "CTkButton",
                 font: Optional[Union[tuple, CTkFont]] = None,
                 textvariable: Union[tkinter.Variable, None] = None,
                 image: Union[CTkImage, "ImageTk.PhotoImage", None] = None,
                 state: str = "normal",
                 hover: bool = True,
                 command: Union[Callable[[], None], None] = None,
                 compound: str = "left",
                 anchor: str = "center",
                 **kwargs):

        # transfer basic functionality (bg_color, size, appearance_mode, scaling) to CTkBaseClass
        super().__init__(master=master, bg_color=bg_color, width=width, height=height, **kwargs)
        self.original_id: str = "CTkButton" #sets an original id for reverting
        self.id: str = id #made it a class variable

        #this checks if id is in the dictionary if not revert to original_id
        if ThemeManager.theme.get(self.id, None) == None:
            self.id = self.original_id

                                       #+++++++++++++++++++++++++++++++++++++++++#
                     ######## used self.id in place of CTKButton with theme manager as a Key ######## 
                                      #^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^#

        # shape
        self._corner_radius: int = ThemeManager.theme[self.id]["corner_radius"] if corner_radius is None else corner_radius
        self._corner_radius = min(self._corner_radius, round(self._current_height / 2))
        self._border_width: int = ThemeManager.theme[self.id]["border_width"] if border_width is None else border_width
        self._border_spacing: int = border_spacing

        # color
        self._fg_color: Union[str, Tuple[str, str]] = ThemeManager.theme[self.id]["fg_color"] if fg_color is None else self._check_color_type(fg_color, transparency=True)
        self._hover_color: Union[str, Tuple[str, str]] = ThemeManager.theme[self.id]["hover_color"] if hover_color is None else self._check_color_type(hover_color)
        self._border_color: Union[str, Tuple[str, str]] = ThemeManager.theme[self.id]["border_color"] if border_color is None else self._check_color_type(border_color)
        self._text_color: Union[str, Tuple[str, str]] = ThemeManager.theme[self.id]["text_color"] if text_color is None else self._check_color_type(text_color)
        self._text_color_disabled: Union[str, Tuple[str, str]] = ThemeManager.theme[self.id]["text_color_disabled"] if text_color_disabled is None else self._check_color_type(text_color_disabled)
avalon60 commented 1 year ago

BTW - great idea. This would allow you to define, as an example, "hot buttons".

avalon60 commented 1 year ago

So users would be able to add their own IDs to the .json file?

This would require an exception to be made in the case that the user passes a ID but that ID doesn't exist in the theme. Alternatively, if the ID doesn't exist in the theme, the widget would just use the default theme colors.

Yes @dishb, I would think it should have an option to fall back to the basic widget colours, rather than raise an exception. That way apps could still work, even if a chosen theme didn't have the bespoke styling.

ghost commented 1 year ago

While not raising an exception, a message should be printed out. Some sort of warning telling the developer that the ID doesn't exist in the theme. This way, (in most cases) the developer cannot put the code into production.

loafthecomputerphile commented 1 year ago

tion to fall back to the basic widget colours, rather than raise an exception. That way apps could still work, even if a chosen theme didn't have the bespoke styling.

yeah i believe adding:

warnings.warn(f' {self.id} was not found in theme imports ')

under the if statement that checks if the id is in the theme dictionary would do that