diff --git a/customtkinter/__init__.py b/customtkinter/__init__.py new file mode 100644 index 0000000..3004e4d --- /dev/null +++ b/customtkinter/__init__.py @@ -0,0 +1,81 @@ +__version__ = "5.0.5" + +import os +import sys +from tkinter import Variable, StringVar, IntVar, DoubleVar, BooleanVar +from tkinter.constants import * +import tkinter.filedialog as filedialog + +# import manager classes +from .windows.widgets.appearance_mode import AppearanceModeTracker +from .windows.widgets.font import FontManager +from .windows.widgets.scaling import ScalingTracker +from .windows.widgets.theme import ThemeManager +from .windows.widgets.core_rendering import DrawEngine + +# import base widgets +from .windows.widgets.core_rendering import CTkCanvas +from .windows.widgets.core_widget_classes import CTkBaseClass + +# import widgets +from .windows.widgets import CTkButton +from .windows.widgets import CTkCheckBox +from .windows.widgets import CTkComboBox +from .windows.widgets import CTkEntry +from .windows.widgets import CTkFrame +from .windows.widgets import CTkLabel +from .windows.widgets import CTkOptionMenu +from .windows.widgets import CTkProgressBar +from .windows.widgets import CTkRadioButton +from .windows.widgets import CTkScrollbar +from .windows.widgets import CTkSegmentedButton +from .windows.widgets import CTkSlider +from .windows.widgets import CTkSwitch +from .windows.widgets import CTkTabview +from .windows.widgets import CTkTextbox + +# import windows +from .windows import CTk +from .windows import CTkToplevel +from .windows import CTkInputDialog + +# import font classes +from .windows.widgets.font import CTkFont + +# import image classes +from .windows.widgets.image import CTkImage + +_ = Variable, StringVar, IntVar, DoubleVar, BooleanVar, CENTER, filedialog # prevent IDE from removing unused imports + + +def set_appearance_mode(mode_string: str): + """ possible values: light, dark, system """ + AppearanceModeTracker.set_appearance_mode(mode_string) + + +def get_appearance_mode() -> str: + """ get current state of the appearance mode (light or dark) """ + if AppearanceModeTracker.appearance_mode == 0: + return "Light" + elif AppearanceModeTracker.appearance_mode == 1: + return "Dark" + + +def set_default_color_theme(color_string: str): + """ set color theme or load custom theme file by passing the path """ + ThemeManager.load_theme(color_string) + + +def set_widget_scaling(scaling_value: float): + """ set scaling for the widget dimensions """ + ScalingTracker.set_widget_scaling(scaling_value) + + +def set_window_scaling(scaling_value: float): + """ set scaling for window dimensions """ + ScalingTracker.set_window_scaling(scaling_value) + + +def deactivate_automatic_dpi_awareness(): + """ deactivate DPI awareness of current process (windll.shcore.SetProcessDpiAwareness(0)) """ + ScalingTracker.deactivate_automatic_dpi_awareness = True diff --git a/customtkinter/__pycache__/__init__.cpython-310.pyc b/customtkinter/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..56e450c Binary files /dev/null and b/customtkinter/__pycache__/__init__.cpython-310.pyc differ diff --git a/customtkinter/assets/.DS_Store b/customtkinter/assets/.DS_Store new file mode 100644 index 0000000..1056a75 Binary files /dev/null and b/customtkinter/assets/.DS_Store differ diff --git a/customtkinter/assets/fonts/CustomTkinter_shapes_font.otf b/customtkinter/assets/fonts/CustomTkinter_shapes_font.otf new file mode 100644 index 0000000..a891053 Binary files /dev/null and b/customtkinter/assets/fonts/CustomTkinter_shapes_font.otf differ diff --git a/customtkinter/assets/fonts/Roboto/Roboto-Medium.ttf b/customtkinter/assets/fonts/Roboto/Roboto-Medium.ttf new file mode 100644 index 0000000..e89b0b7 Binary files /dev/null and b/customtkinter/assets/fonts/Roboto/Roboto-Medium.ttf differ diff --git a/customtkinter/assets/fonts/Roboto/Roboto-Regular.ttf b/customtkinter/assets/fonts/Roboto/Roboto-Regular.ttf new file mode 100644 index 0000000..3d6861b Binary files /dev/null and b/customtkinter/assets/fonts/Roboto/Roboto-Regular.ttf differ diff --git a/customtkinter/assets/icons/.DS_Store b/customtkinter/assets/icons/.DS_Store new file mode 100644 index 0000000..57d1d87 Binary files /dev/null and b/customtkinter/assets/icons/.DS_Store differ diff --git a/customtkinter/assets/icons/CustomTkinter_icon_Windows.ico b/customtkinter/assets/icons/CustomTkinter_icon_Windows.ico new file mode 100644 index 0000000..fe8eeaf Binary files /dev/null and b/customtkinter/assets/icons/CustomTkinter_icon_Windows.ico differ diff --git a/customtkinter/assets/themes/blue.json b/customtkinter/assets/themes/blue.json new file mode 100644 index 0000000..73a1f4d --- /dev/null +++ b/customtkinter/assets/themes/blue.json @@ -0,0 +1,152 @@ +{ + "CTk": { + "fg_color": ["gray92", "gray14"] + }, + "CTkToplevel": { + "fg_color": ["gray92", "gray14"] + }, + "CTkFrame": { + "corner_radius": 6, + "border_width": 0, + "fg_color": ["gray86", "gray17"], + "top_fg_color": ["gray81", "gray20"], + "border_color": ["gray65", "gray28"] + }, + "CTkButton": { + "corner_radius": 6, + "border_width": 0, + "fg_color": ["#3B8ED0", "#1F6AA5"], + "hover_color": ["#36719F", "#144870"], + "border_color": ["#3E454A", "#949A9F"], + "text_color": ["#DCE4EE", "#DCE4EE"], + "text_color_disabled": ["gray74", "gray60"] + }, + "CTkLabel": { + "corner_radius": 0, + "fg_color": "transparent", + "text_color": ["gray10", "#DCE4EE"] + }, + "CTkEntry": { + "corner_radius": 6, + "border_width": 2, + "fg_color": ["#F9F9FA", "#343638"], + "border_color": ["#979DA2", "#565B5E"], + "text_color":["gray10", "#DCE4EE"], + "placeholder_text_color": ["gray52", "gray62"] + }, + "CTkCheckbox": { + "corner_radius": 6, + "border_width": 3, + "fg_color": ["#3B8ED0", "#1F6AA5"], + "border_color": ["#3E454A", "#949A9F"], + "hover_color": ["#3B8ED0", "#1F6AA5"], + "checkmark_color": ["#DCE4EE", "gray90"], + "text_color": ["gray10", "#DCE4EE"], + "text_color_disabled": ["gray60", "gray45"] + }, + "CTkSwitch": { + "corner_radius": 1000, + "border_width": 3, + "button_length": 0, + "fg_Color": ["#939BA2", "#4A4D50"], + "progress_color": ["#3B8ED0", "#1F6AA5"], + "button_color": ["gray36", "#D5D9DE"], + "button_hover_color": ["gray20", "gray100"], + "text_color": ["gray10", "#DCE4EE"], + "text_color_disabled": ["gray60", "gray45"] + }, + "CTkRadiobutton": { + "corner_radius": 1000, + "border_width_checked": 6, + "border_width_unchecked": 3, + "fg_color": ["#3B8ED0", "#1F6AA5"], + "border_color": ["#3E454A", "#949A9F"], + "hover_color": ["#36719F", "#144870"], + "text_color": ["gray10", "#DCE4EE"], + "text_color_disabled": ["gray60", "gray45"] + }, + "CTkProgressBar": { + "corner_radius": 1000, + "border_width": 0, + "fg_color": ["#939BA2", "#4A4D50"], + "progress_color": ["#3B8ED0", "#1F6AA5"], + "border_color": ["gray", "gray"] + }, + "CTkSlider": { + "corner_radius": 1000, + "button_corner_radius": 1000, + "border_width": 6, + "button_length": 0, + "fg_color": ["#939BA2", "#4A4D50"], + "progress_color": ["gray40", "#AAB0B5"], + "button_color": ["#3B8ED0", "#1F6AA5"], + "button_hover_color": ["#36719F", "#144870"] + }, + "CTkOptionMenu": { + "corner_radius": 6, + "fg_color": ["#3B8ED0", "#1F6AA5"], + "button_color": ["#36719F", "#144870"], + "button_hover_color": ["#27577D", "#203A4F"], + "text_color": ["#DCE4EE", "#DCE4EE"], + "text_color_disabled": ["gray74", "gray60"] + }, + "CTkComboBox": { + "corner_radius": 6, + "border_width": 2, + "fg_color": ["#F9F9FA", "#343638"], + "border_color": ["#979DA2", "#565B5E"], + "button_color": ["#979DA2", "#565B5E"], + "button_hover_color": ["#6E7174", "#7A848D"], + "text_color": ["gray10", "#DCE4EE"], + "text_color_disabled": ["gray50", "gray45"] + }, + "CTkScrollbar": { + "corner_radius": 1000, + "border_spacing": 4, + "fg_color": "transparent", + "button_color": ["gray55", "gray41"], + "button_hover_color": ["gray40", "gray53"] + }, + "CTkSegmentedButton": { + "corner_radius": 6, + "border_width": 2, + "fg_color": ["#979DA2", "gray29"], + "selected_color": ["#3B8ED0", "#1F6AA5"], + "selected_hover_color": ["#36719F", "#144870"], + "unselected_color": ["#979DA2", "gray29"], + "unselected_hover_color": ["gray70", "gray41"], + "text_color": ["#DCE4EE", "#DCE4EE"], + "text_color_disabled": ["gray74", "gray60"] + }, + "CTkTextbox": { + "corner_radius": 6, + "border_width": 0, + "fg_color": ["#F9F9FA", "#1D1E1E"], + "border_color": ["#979DA2", "#565B5E"], + "text_color":["gray10", "#DCE4EE"], + "scrollbar_button_color": ["gray55", "gray41"], + "scrollbar_button_hover_color": ["gray40", "gray53"] + }, + "DropdownMenu": { + "fg_color": ["gray90", "gray20"], + "hover_color": ["gray75", "gray28"], + "text_color": ["gray10", "gray90"] + }, + "CTkFont": { + "macOS": { + "family": "SF Display", + "size": 13, + "weight": "normal" + }, + "Windows": { + "family": "Roboto", + "size": 13, + "weight": "normal" + }, + "Linux": { + "family": "Roboto", + "size": 13, + "weight": "normal" + } + } +} diff --git a/customtkinter/assets/themes/dark-blue.json b/customtkinter/assets/themes/dark-blue.json new file mode 100644 index 0000000..1ecf8ab --- /dev/null +++ b/customtkinter/assets/themes/dark-blue.json @@ -0,0 +1,152 @@ +{ + "CTk": { + "fg_color": ["gray95", "gray10"] + }, + "CTkToplevel": { + "fg_color": ["gray95", "gray10"] + }, + "CTkFrame": { + "corner_radius": 6, + "border_width": 0, + "fg_color": ["gray90", "gray13"], + "top_fg_color": ["gray85", "gray16"], + "border_color": ["gray65", "gray28"] + }, + "CTkButton": { + "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"] + }, + "CTkLabel": { + "corner_radius": 0, + "fg_color": "transparent", + "text_color": ["gray14", "gray84"] + }, + "CTkEntry": { + "corner_radius": 6, + "border_width": 2, + "fg_color": ["#F9F9FA", "#343638"], + "border_color": ["#979DA2", "#565B5E"], + "text_color": ["gray14", "gray84"], + "placeholder_text_color": ["gray52", "gray62"] + }, + "CTkCheckbox": { + "corner_radius": 6, + "border_width": 3, + "fg_color": ["#3a7ebf", "#1f538d"], + "border_color": ["#3E454A", "#949A9F"], + "hover_color": ["#325882", "#14375e"], + "checkmark_color": ["#DCE4EE", "gray90"], + "text_color": ["gray14", "gray84"], + "text_color_disabled": ["gray60", "gray45"] + }, + "CTkSwitch": { + "corner_radius": 1000, + "border_width": 3, + "button_length": 0, + "fg_Color": ["#939BA2", "#4A4D50"], + "progress_color": ["#3a7ebf", "#1f538d"], + "button_color": ["gray36", "#D5D9DE"], + "button_hover_color": ["gray20", "gray100"], + "text_color": ["gray14", "gray84"], + "text_color_disabled": ["gray60", "gray45"] + }, + "CTkRadiobutton": { + "corner_radius": 1000, + "border_width_checked": 6, + "border_width_unchecked": 3, + "fg_color": ["#3a7ebf", "#1f538d"], + "border_color": ["#3E454A", "#949A9F"], + "hover_color": ["#325882", "#14375e"], + "text_color": ["gray14", "gray84"], + "text_color_disabled": ["gray60", "gray45"] + }, + "CTkProgressBar": { + "corner_radius": 1000, + "border_width": 0, + "fg_color": ["#939BA2", "#4A4D50"], + "progress_color": ["#3a7ebf", "#1f538d"], + "border_color": ["gray", "gray"] + }, + "CTkSlider": { + "corner_radius": 1000, + "button_corner_radius": 1000, + "border_width": 6, + "button_length": 0, + "fg_color": ["#939BA2", "#4A4D50"], + "progress_color": ["gray40", "#AAB0B5"], + "button_color": ["#3a7ebf", "#1f538d"], + "button_hover_color": ["#325882", "#14375e"] + }, + "CTkOptionMenu": { + "corner_radius": 6, + "fg_color": ["#3a7ebf", "#1f538d"], + "button_color": ["#325882", "#14375e"], + "button_hover_color": ["#234567", "#1e2c40"], + "text_color": ["#DCE4EE", "#DCE4EE"], + "text_color_disabled": ["gray74", "gray60"] + }, + "CTkComboBox": { + "corner_radius": 6, + "border_width": 2, + "fg_color": ["#F9F9FA", "#343638"], + "border_color": ["#979DA2", "#565B5E"], + "button_color": ["#979DA2", "#565B5E"], + "button_hover_color": ["#6E7174", "#7A848D"], + "text_color": ["gray14", "gray84"], + "text_color_disabled": ["gray50", "gray45"] + }, + "CTkScrollbar": { + "corner_radius": 1000, + "border_spacing": 4, + "fg_color": "transparent", + "button_color": ["gray55", "gray41"], + "button_hover_color": ["gray40", "gray53"] + }, + "CTkSegmentedButton": { + "corner_radius": 6, + "border_width": 2, + "fg_color": ["#979DA2", "gray29"], + "selected_color": ["#3a7ebf", "#1f538d"], + "selected_hover_color": ["#325882", "#14375e"], + "unselected_color": ["#979DA2", "gray29"], + "unselected_hover_color": ["gray70", "gray41"], + "text_color": ["#DCE4EE", "#DCE4EE"], + "text_color_disabled": ["gray74", "gray60"] + }, + "CTkTextbox": { + "corner_radius": 6, + "border_width": 0, + "fg_color": ["gray100", "gray20"], + "border_color": ["#979DA2", "#565B5E"], + "text_color": ["gray14", "gray84"], + "scrollbar_button_color": ["gray55", "gray41"], + "scrollbar_button_hover_color": ["gray40", "gray53"] + }, + "DropdownMenu": { + "fg_color": ["gray90", "gray20"], + "hover_color": ["gray75", "gray28"], + "text_color": ["gray14", "gray84"] + }, + "CTkFont": { + "macOS": { + "family": "SF Display", + "size": 13, + "weight": "normal" + }, + "Windows": { + "family": "Roboto", + "size": 13, + "weight": "normal" + }, + "Linux": { + "family": "Roboto", + "size": 13, + "weight": "normal" + } + } +} diff --git a/customtkinter/assets/themes/green.json b/customtkinter/assets/themes/green.json new file mode 100644 index 0000000..14cd8c6 --- /dev/null +++ b/customtkinter/assets/themes/green.json @@ -0,0 +1,152 @@ +{ + "CTk": { + "fg_color": ["gray92", "gray14"] + }, + "CTkToplevel": { + "fg_color": ["gray92", "gray14"] + }, + "CTkFrame": { + "corner_radius": 6, + "border_width": 0, + "fg_color": ["gray86", "gray17"], + "top_fg_color": ["gray81", "gray20"], + "border_color": ["gray65", "gray28"] + }, + "CTkButton": { + "corner_radius": 6, + "border_width": 0, + "fg_color": ["#2CC985", "#2FA572"], + "hover_color": ["#0C955A", "#106A43"], + "border_color": ["#3E454A", "#949A9F"], + "text_color": ["gray98", "#DCE4EE"], + "text_color_disabled": ["gray78", "gray68"] + }, + "CTkLabel": { + "corner_radius": 0, + "fg_color": "transparent", + "text_color": ["gray10", "#DCE4EE"] + }, + "CTkEntry": { + "corner_radius": 6, + "border_width": 2, + "fg_color": ["#F9F9FA", "#343638"], + "border_color": ["#979DA2", "#565B5E"], + "text_color":["gray10", "#DCE4EE"], + "placeholder_text_color": ["gray52", "gray62"] + }, + "CTkCheckbox": { + "corner_radius": 6, + "border_width": 3, + "fg_color": ["#2CC985", "#2FA572"], + "border_color": ["#3E454A", "#949A9F"], + "hover_color": ["#0C955A", "#106A43"], + "checkmark_color": ["#DCE4EE", "gray90"], + "text_color": ["gray10", "#DCE4EE"], + "text_color_disabled": ["gray60", "gray45"] + }, + "CTkSwitch": { + "corner_radius": 1000, + "border_width": 3, + "button_length": 0, + "fg_Color": ["#939BA2", "#4A4D50"], + "progress_color": ["#2CC985", "#2FA572"], + "button_color": ["gray36", "#D5D9DE"], + "button_hover_color": ["gray20", "gray100"], + "text_color": ["gray10", "#DCE4EE"], + "text_color_disabled": ["gray60", "gray45"] + }, + "CTkRadiobutton": { + "corner_radius": 1000, + "border_width_checked": 6, + "border_width_unchecked": 3, + "fg_color": ["#2CC985", "#2FA572"], + "border_color": ["#3E454A", "#949A9F"], + "hover_color":["#0C955A", "#106A43"], + "text_color": ["gray10", "#DCE4EE"], + "text_color_disabled": ["gray60", "gray45"] + }, + "CTkProgressBar": { + "corner_radius": 1000, + "border_width": 0, + "fg_color": ["#939BA2", "#4A4D50"], + "progress_color": ["#2CC985", "#2FA572"], + "border_color": ["gray", "gray"] + }, + "CTkSlider": { + "corner_radius": 1000, + "button_corner_radius": 1000, + "border_width": 6, + "button_length": 0, + "fg_color": ["#939BA2", "#4A4D50"], + "progress_color": ["gray40", "#AAB0B5"], + "button_color": ["#2CC985", "#2FA572"], + "button_hover_color": ["#0C955A", "#106A43"] + }, + "CTkOptionMenu": { + "corner_radius": 6, + "fg_color": ["#2cbe79", "#2FA572"], + "button_color": ["#0C955A", "#106A43"], + "button_hover_color": ["#0b6e3d", "#17472e"], + "text_color": ["gray98", "#DCE4EE"], + "text_color_disabled": ["gray78", "gray68"] + }, + "CTkComboBox": { + "corner_radius": 6, + "border_width": 2, + "fg_color": ["#F9F9FA", "#343638"], + "border_color": ["#979DA2", "#565B5E"], + "button_color": ["#979DA2", "#565B5E"], + "button_hover_color": ["#6E7174", "#7A848D"], + "text_color": ["gray10", "#DCE4EE"], + "text_color_disabled": ["gray50", "gray45"] + }, + "CTkScrollbar": { + "corner_radius": 1000, + "border_spacing": 4, + "fg_color": "transparent", + "button_color": ["gray55", "gray41"], + "button_hover_color": ["gray40", "gray53"] + }, + "CTkSegmentedButton": { + "corner_radius": 6, + "border_width": 2, + "fg_color": ["#979DA2", "gray29"], + "selected_color": ["#2CC985", "#2FA572"], + "selected_hover_color": ["#0C955A", "#106A43"], + "unselected_color": ["#979DA2", "gray29"], + "unselected_hover_color": ["gray70", "gray41"], + "text_color": ["gray98", "#DCE4EE"], + "text_color_disabled": ["gray78", "gray68"] + }, + "CTkTextbox": { + "corner_radius": 6, + "border_width": 0, + "fg_color": ["#F9F9FA", "gray23"], + "border_color": ["#979DA2", "#565B5E"], + "text_color":["gray10", "#DCE4EE"], + "scrollbar_button_color": ["gray55", "gray41"], + "scrollbar_button_hover_color": ["gray40", "gray53"] + }, + "DropdownMenu": { + "fg_color": ["gray90", "gray20"], + "hover_color": ["gray75", "gray28"], + "text_color": ["gray10", "gray90"] + }, + "CTkFont": { + "macOS": { + "family": "SF Display", + "size": 13, + "weight": "normal" + }, + "Windows": { + "family": "Roboto", + "size": 13, + "weight": "normal" + }, + "Linux": { + "family": "Roboto", + "size": 13, + "weight": "normal" + } + } +} diff --git a/customtkinter/windows/__init__.py b/customtkinter/windows/__init__.py new file mode 100644 index 0000000..ca681b7 --- /dev/null +++ b/customtkinter/windows/__init__.py @@ -0,0 +1,3 @@ +from .ctk_tk import CTk +from .ctk_toplevel import CTkToplevel +from .ctk_input_dialog import CTkInputDialog diff --git a/customtkinter/windows/__pycache__/__init__.cpython-310.pyc b/customtkinter/windows/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..624c913 Binary files /dev/null and b/customtkinter/windows/__pycache__/__init__.cpython-310.pyc differ diff --git a/customtkinter/windows/__pycache__/ctk_input_dialog.cpython-310.pyc b/customtkinter/windows/__pycache__/ctk_input_dialog.cpython-310.pyc new file mode 100644 index 0000000..c04083a Binary files /dev/null and b/customtkinter/windows/__pycache__/ctk_input_dialog.cpython-310.pyc differ diff --git a/customtkinter/windows/__pycache__/ctk_tk.cpython-310.pyc b/customtkinter/windows/__pycache__/ctk_tk.cpython-310.pyc new file mode 100644 index 0000000..47b7858 Binary files /dev/null and b/customtkinter/windows/__pycache__/ctk_tk.cpython-310.pyc differ diff --git a/customtkinter/windows/__pycache__/ctk_toplevel.cpython-310.pyc b/customtkinter/windows/__pycache__/ctk_toplevel.cpython-310.pyc new file mode 100644 index 0000000..b50f981 Binary files /dev/null and b/customtkinter/windows/__pycache__/ctk_toplevel.cpython-310.pyc differ diff --git a/customtkinter/windows/ctk_input_dialog.py b/customtkinter/windows/ctk_input_dialog.py new file mode 100644 index 0000000..fc37527 --- /dev/null +++ b/customtkinter/windows/ctk_input_dialog.py @@ -0,0 +1,110 @@ +from typing import Union, Tuple, Optional + +from .widgets import CTkLabel +from .widgets import CTkEntry +from .widgets import CTkButton +from .widgets.theme import ThemeManager +from .ctk_toplevel import CTkToplevel + + +class CTkInputDialog(CTkToplevel): + """ + Dialog with extra window, message, entry widget, cancel and ok button. + For detailed information check out the documentation. + """ + + def __init__(self, + fg_color: Optional[Union[str, Tuple[str, str]]] = None, + text_color: Optional[Union[str, Tuple[str, str]]] = None, + button_fg_color: Optional[Union[str, Tuple[str, str]]] = None, + button_hover_color: Optional[Union[str, Tuple[str, str]]] = None, + button_text_color: Optional[Union[str, Tuple[str, str]]] = None, + entry_fg_color: Optional[Union[str, Tuple[str, str]]] = None, + entry_border_color: Optional[Union[str, Tuple[str, str]]] = None, + entry_text_color: Optional[Union[str, Tuple[str, str]]] = None, + + title: str = "CTkDialog", + text: str = "CTkDialog"): + + super().__init__(fg_color=fg_color) + + self._fg_color = ThemeManager.theme["CTkToplevel"]["fg_color"] if fg_color is None else self._check_color_type(fg_color) + self._text_color = ThemeManager.theme["CTkLabel"]["text_color"] if text_color is None else self._check_color_type(button_hover_color) + self._button_fg_color = ThemeManager.theme["CTkButton"]["fg_color"] if button_fg_color is None else self._check_color_type(button_fg_color) + self._button_hover_color = ThemeManager.theme["CTkButton"]["hover_color"] if button_hover_color is None else self._check_color_type(button_hover_color) + self._button_text_color = ThemeManager.theme["CTkButton"]["text_color"] if button_text_color is None else self._check_color_type(button_text_color) + self._entry_fg_color = ThemeManager.theme["CTkEntry"]["fg_color"] if entry_fg_color is None else self._check_color_type(entry_fg_color) + self._entry_border_color = ThemeManager.theme["CTkEntry"]["border_color"] if entry_border_color is None else self._check_color_type(entry_border_color) + self._entry_text_color = ThemeManager.theme["CTkEntry"]["text_color"] if entry_text_color is None else self._check_color_type(entry_text_color) + + self._user_input: Union[str, None] = None + self._running: bool = False + self._text = text + + self.title(title) + self.lift() # lift window on top + self.attributes("-topmost", True) # stay on top + self.protocol("WM_DELETE_WINDOW", self._on_closing) + self.after(10, self._create_widgets) # create widgets with slight delay, to avoid white flickering of background + self.resizable(False, False) + self.grab_set() # make other windows not clickable + + def _create_widgets(self): + + self.grid_columnconfigure((0, 1), weight=1) + self.rowconfigure(0, weight=1) + + self._label = CTkLabel(master=self, + width=300, + wraplength=300, + fg_color="transparent", + text_color=self._text_color, + text=self._text,) + self._label.grid(row=0, column=0, columnspan=2, padx=20, pady=20, sticky="ew") + + self._entry = CTkEntry(master=self, + width=230, + fg_color=self._entry_fg_color, + border_color=self._entry_border_color, + text_color=self._entry_text_color) + self._entry.grid(row=1, column=0, columnspan=2, padx=20, pady=(0, 20), sticky="ew") + + self._ok_button = CTkButton(master=self, + width=100, + border_width=0, + fg_color=self._button_fg_color, + hover_color=self._button_hover_color, + text_color=self._button_text_color, + text='Ok', + command=self._ok_event) + self._ok_button.grid(row=2, column=0, columnspan=1, padx=(20, 10), pady=(0, 20), sticky="ew") + + self._cancel_button = CTkButton(master=self, + width=100, + border_width=0, + fg_color=self._button_fg_color, + hover_color=self._button_hover_color, + text_color=self._button_text_color, + text='Cancel', + command=self._ok_event) + self._cancel_button.grid(row=2, column=1, columnspan=1, padx=(10, 20), pady=(0, 20), sticky="ew") + + self.after(150, lambda: self._entry.focus()) # set focus to entry with slight delay, otherwise it won't work + self._entry.bind("", self._ok_event) + + def _ok_event(self, event=None): + self._user_input = self._entry.get() + self.grab_release() + self.destroy() + + def _on_closing(self): + self.grab_release() + self.destroy() + + def _cancel_event(self): + self.grab_release() + self.destroy() + + def get_input(self): + self.master.wait_window(self) + return self._user_input diff --git a/customtkinter/windows/ctk_tk.py b/customtkinter/windows/ctk_tk.py new file mode 100644 index 0000000..ac56289 --- /dev/null +++ b/customtkinter/windows/ctk_tk.py @@ -0,0 +1,316 @@ +import tkinter +from distutils.version import StrictVersion as Version +import sys +import os +import platform +import ctypes +from typing import Union, Tuple, Optional + +from .widgets.theme import ThemeManager +from .widgets.scaling import CTkScalingBaseClass +from .widgets.appearance_mode import CTkAppearanceModeBaseClass + +from customtkinter.windows.widgets.utility.utility_functions import pop_from_dict_by_set, check_kwargs_empty + + +class CTk(tkinter.Tk, CTkAppearanceModeBaseClass, CTkScalingBaseClass): + """ + Main app window with dark titlebar on Windows and macOS. + For detailed information check out the documentation. + """ + + _valid_tk_constructor_arguments: set = {"screenName", "baseName", "className", "useTk", "sync", "use"} + + _valid_tk_configure_arguments: set = {'bd', 'borderwidth', 'class', 'menu', 'relief', 'screen', + 'use', 'container', 'cursor', 'height', + 'highlightthickness', 'padx', 'pady', 'takefocus', 'visual', 'width'} + + _deactivate_macos_window_header_manipulation: bool = False + _deactivate_windows_window_header_manipulation: bool = False + + def __init__(self, + fg_color: Optional[Union[str, Tuple[str, str]]] = None, + **kwargs): + + self._enable_macos_dark_title_bar() + + # call init methods of super classes + tkinter.Tk.__init__(self, **pop_from_dict_by_set(kwargs, self._valid_tk_constructor_arguments)) + CTkAppearanceModeBaseClass.__init__(self) + CTkScalingBaseClass.__init__(self, scaling_type="window") + check_kwargs_empty(kwargs, raise_error=True) + + self._current_width = 600 # initial window size, independent of scaling + self._current_height = 500 + self._min_width: int = 0 + self._min_height: int = 0 + self._max_width: int = 1_000_000 + self._max_height: int = 1_000_000 + self._last_resizable_args: Union[Tuple[list, dict], None] = None # (args, kwargs) + + self._fg_color = ThemeManager.theme["CTk"]["fg_color"] if fg_color is None else self._check_color_type(fg_color) + + # set bg of tkinter.Tk + super().configure(bg=self._apply_appearance_mode(self._fg_color)) + + # set title + self.title("CTk") + + # indicator variables + self._iconbitmap_method_called = False # indicates if wm_iconbitmap method got called + self._state_before_windows_set_titlebar_color = None + self._window_exists = False # indicates if the window is already shown through update() or mainloop() after init + self._withdraw_called_before_window_exists = False # indicates if withdraw() was called before window is first shown through update() or mainloop() + self._iconify_called_before_window_exists = False # indicates if iconify() was called before window is first shown through update() or mainloop() + self._block_update_dimensions_event = False + + # set CustomTkinter titlebar icon (Windows only) + if sys.platform.startswith("win"): + self.after(200, self._windows_set_titlebar_icon) + + # set titlebar color (Windows only) + if sys.platform.startswith("win"): + self._windows_set_titlebar_color(self._get_appearance_mode()) + + self.bind('', self._update_dimensions_event) + self.bind('', self._focus_in_event) + + def destroy(self): + self._disable_macos_dark_title_bar() + + # call destroy methods of super classes + tkinter.Tk.destroy(self) + CTkAppearanceModeBaseClass.destroy(self) + CTkScalingBaseClass.destroy(self) + + def _focus_in_event(self, event): + # sometimes window looses jumps back on macOS if window is selected from Mission Control, so has to be lifted again + if sys.platform == "darwin": + self.lift() + + def _update_dimensions_event(self, event=None): + if not self._block_update_dimensions_event: + + detected_width = super().winfo_width() # detect current window size + detected_height = super().winfo_height() + + # detected_width = event.width + # detected_height = event.height + + if self._current_width != self._reverse_window_scaling(detected_width) or self._current_height != self._reverse_window_scaling(detected_height): + self._current_width = self._reverse_window_scaling(detected_width) # adjust current size according to new size given by event + self._current_height = self._reverse_window_scaling(detected_height) # _current_width and _current_height are independent of the scale + + def _set_scaling(self, new_widget_scaling, new_window_scaling): + super()._set_scaling(new_widget_scaling, new_window_scaling) + + # Force new dimensions on window by using min, max, and geometry. Without min, max it won't work. + super().minsize(self._apply_window_scaling(self._current_width), self._apply_window_scaling(self._current_height)) + super().maxsize(self._apply_window_scaling(self._current_width), self._apply_window_scaling(self._current_height)) + + super().geometry(f"{self._apply_window_scaling(self._current_width)}x{self._apply_window_scaling(self._current_height)}") + + # set new scaled min and max with delay (delay prevents weird bug where window dimensions snap to unscaled dimensions when mouse releases window) + self.after(1000, self._set_scaled_min_max) # Why 1000ms delay? Experience! (Everything tested on Windows 11) + + def block_update_dimensions_event(self): + self._block_update_dimensions_event = False + + def unblock_update_dimensions_event(self): + self._block_update_dimensions_event = False + + def _set_scaled_min_max(self): + if self._min_width is not None or self._min_height is not None: + super().minsize(self._apply_window_scaling(self._min_width), self._apply_window_scaling(self._min_height)) + if self._max_width is not None or self._max_height is not None: + super().maxsize(self._apply_window_scaling(self._max_width), self._apply_window_scaling(self._max_height)) + + def withdraw(self): + if self._window_exists is False: + self._withdraw_called_before_window_exists = True + super().withdraw() + + def iconify(self): + if self._window_exists is False: + self._iconify_called_before_window_exists = True + super().iconify() + + def update(self): + if self._window_exists is False: + self._window_exists = True + + if sys.platform.startswith("win"): + if not self._withdraw_called_before_window_exists and not self._iconify_called_before_window_exists: + # print("window dont exists -> deiconify in update") + self.deiconify() + + super().update() + + def mainloop(self, *args, **kwargs): + if not self._window_exists: + self._window_exists = True + + if sys.platform.startswith("win"): + if not self._withdraw_called_before_window_exists and not self._iconify_called_before_window_exists: + # print("window dont exists -> deiconify in mainloop") + self.deiconify() + + super().mainloop(*args, **kwargs) + + def resizable(self, width: bool = None, height: bool = None): + current_resizable_values = super().resizable(width, height) + self._last_resizable_args = ([], {"width": width, "height": height}) + + if sys.platform.startswith("win"): + self._windows_set_titlebar_color(self._get_appearance_mode()) + + return current_resizable_values + + def minsize(self, width: int = None, height: int = None): + self._min_width = width + self._min_height = height + if self._current_width < width: + self._current_width = width + if self._current_height < height: + self._current_height = height + super().minsize(self._apply_window_scaling(self._min_width), self._apply_window_scaling(self._min_height)) + + def maxsize(self, width: int = None, height: int = None): + self._max_width = width + self._max_height = height + if self._current_width > width: + self._current_width = width + if self._current_height > height: + self._current_height = height + super().maxsize(self._apply_window_scaling(self._max_width), self._apply_window_scaling(self._max_height)) + + def geometry(self, geometry_string: str = None): + if geometry_string is not None: + super().geometry(self._apply_geometry_scaling(geometry_string)) + + # update width and height attributes + width, height, x, y = self._parse_geometry_string(geometry_string) + if width is not None and height is not None: + self._current_width = max(self._min_width, min(width, self._max_width)) # bound value between min and max + self._current_height = max(self._min_height, min(height, self._max_height)) + else: + return self._reverse_geometry_scaling(super().geometry()) + + def configure(self, **kwargs): + if "fg_color" in kwargs: + self._fg_color = self._check_color_type(kwargs.pop("fg_color")) + super().configure(bg=self._apply_appearance_mode(self._fg_color)) + + for child in self.winfo_children(): + try: + child.configure(bg_color=self._fg_color) + except Exception: + pass + + super().configure(**pop_from_dict_by_set(kwargs, self._valid_tk_configure_arguments)) + check_kwargs_empty(kwargs) + + def cget(self, attribute_name: str) -> any: + if attribute_name == "fg_color": + return self._fg_color + else: + return super().cget(attribute_name) + + def wm_iconbitmap(self, bitmap=None, default=None): + self._iconbitmap_method_called = True + super().wm_iconbitmap(bitmap, default) + + def _windows_set_titlebar_icon(self): + try: + # if not the user already called iconbitmap method, set icon + if not self._iconbitmap_method_called: + customtkinter_directory = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + self.iconbitmap(os.path.join(customtkinter_directory, "assets", "icons", "CustomTkinter_icon_Windows.ico")) + except Exception: + pass + + @classmethod + def _enable_macos_dark_title_bar(cls): + if sys.platform == "darwin" and not cls._deactivate_macos_window_header_manipulation: # macOS + if Version(platform.python_version()) < Version("3.10"): + if Version(tkinter.Tcl().call("info", "patchlevel")) >= Version("8.6.9"): # Tcl/Tk >= 8.6.9 + os.system("defaults write -g NSRequiresAquaSystemAppearance -bool No") + # This command allows dark-mode for all programs + + @classmethod + def _disable_macos_dark_title_bar(cls): + if sys.platform == "darwin" and not cls._deactivate_macos_window_header_manipulation: # macOS + if Version(platform.python_version()) < Version("3.10"): + if Version(tkinter.Tcl().call("info", "patchlevel")) >= Version("8.6.9"): # Tcl/Tk >= 8.6.9 + os.system("defaults delete -g NSRequiresAquaSystemAppearance") + # This command reverts the dark-mode setting for all programs. + + def _windows_set_titlebar_color(self, color_mode: str): + """ + Set the titlebar color of the window to light or dark theme on Microsoft Windows. + + Credits for this function: + https://stackoverflow.com/questions/23836000/can-i-change-the-title-bar-in-tkinter/70724666#70724666 + + MORE INFO: + https://docs.microsoft.com/en-us/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute + """ + + if sys.platform.startswith("win") and not self._deactivate_windows_window_header_manipulation: + + if self._window_exists: + self._state_before_windows_set_titlebar_color = self.state() + # print("window_exists -> state_before_windows_set_titlebar_color: ", self.state_before_windows_set_titlebar_color) + + if self._state_before_windows_set_titlebar_color != "iconic" or self._state_before_windows_set_titlebar_color != "withdrawn": + super().withdraw() # hide window so that it can be redrawn after the titlebar change so that the color change is visible + else: + # print("window dont exists -> withdraw and update") + super().withdraw() + super().update() + + if color_mode.lower() == "dark": + value = 1 + elif color_mode.lower() == "light": + value = 0 + else: + return + + try: + hwnd = ctypes.windll.user32.GetParent(self.winfo_id()) + DWMWA_USE_IMMERSIVE_DARK_MODE = 20 + DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1 = 19 + + # try with DWMWA_USE_IMMERSIVE_DARK_MODE + if ctypes.windll.dwmapi.DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, + ctypes.byref(ctypes.c_int(value)), + ctypes.sizeof(ctypes.c_int(value))) != 0: + + # try with DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20h1 + ctypes.windll.dwmapi.DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1, + ctypes.byref(ctypes.c_int(value)), + ctypes.sizeof(ctypes.c_int(value))) + + except Exception as err: + print(err) + + if self._window_exists: + # print("window_exists -> return to original state: ", self.state_before_windows_set_titlebar_color) + if self._state_before_windows_set_titlebar_color == "normal": + self.deiconify() + elif self._state_before_windows_set_titlebar_color == "iconic": + self.iconify() + elif self._state_before_windows_set_titlebar_color == "zoomed": + self.state("zoomed") + else: + self.state(self._state_before_windows_set_titlebar_color) # other states + else: + pass # wait for update or mainloop to be called + + def _set_appearance_mode(self, mode_string: str): + super()._set_appearance_mode(mode_string) + + if sys.platform.startswith("win"): + self._windows_set_titlebar_color(mode_string) + + super().configure(bg=self._apply_appearance_mode(self._fg_color)) diff --git a/customtkinter/windows/ctk_toplevel.py b/customtkinter/windows/ctk_toplevel.py new file mode 100644 index 0000000..59b43f6 --- /dev/null +++ b/customtkinter/windows/ctk_toplevel.py @@ -0,0 +1,299 @@ +import tkinter +from distutils.version import StrictVersion as Version +import sys +import os +import platform +import ctypes +from typing import Union, Tuple, Optional + +from .widgets.theme import ThemeManager +from .widgets.scaling import CTkScalingBaseClass +from .widgets.appearance_mode import CTkAppearanceModeBaseClass + +from customtkinter.windows.widgets.utility.utility_functions import pop_from_dict_by_set, check_kwargs_empty + + +class CTkToplevel(tkinter.Toplevel, CTkAppearanceModeBaseClass, CTkScalingBaseClass): + """ + Toplevel window with dark titlebar on Windows and macOS. + For detailed information check out the documentation. + """ + + _valid_tk_toplevel_arguments: set = {"bd", "borderwidth", "class", "container", "cursor", "height", + "highlightbackground", "highlightthickness", "menu", "relief", + "screen", "takefocus", "use", "visual", "width"} + + _deactivate_macos_window_header_manipulation: bool = False + _deactivate_windows_window_header_manipulation: bool = False + + def __init__(self, *args, + fg_color: Optional[Union[str, Tuple[str, str]]] = None, + **kwargs): + + self._enable_macos_dark_title_bar() + + # call init methods of super classes + super().__init__(*args, **pop_from_dict_by_set(kwargs, self._valid_tk_toplevel_arguments)) + CTkAppearanceModeBaseClass.__init__(self) + CTkScalingBaseClass.__init__(self, scaling_type="window") + check_kwargs_empty(kwargs, raise_error=True) + + try: + # Set Windows titlebar icon + if sys.platform.startswith("win"): + customtkinter_directory = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + self.after(200, lambda: self.iconbitmap(os.path.join(customtkinter_directory, "assets", "icons", "CustomTkinter_icon_Windows.ico"))) + except Exception: + pass + + self._current_width = 200 # initial window size, always without scaling + self._current_height = 200 + self._min_width: int = 0 + self._min_height: int = 0 + self._max_width: int = 1_000_000 + self._max_height: int = 1_000_000 + self._last_resizable_args: Union[Tuple[list, dict], None] = None # (args, kwargs) + + self._fg_color = ThemeManager.theme["CTkToplevel"]["fg_color"] if fg_color is None else self._check_color_type(fg_color) + + # set bg color of tkinter.Toplevel + super().configure(bg=self._apply_appearance_mode(self._fg_color)) + + # set title of tkinter.Toplevel + super().title("CTkToplevel") + + # indicator variables + self._iconbitmap_method_called = True + self._state_before_windows_set_titlebar_color = None + self._windows_set_titlebar_color_called = False # indicates if windows_set_titlebar_color was called, stays True until revert_withdraw_after_windows_set_titlebar_color is called + self._withdraw_called_after_windows_set_titlebar_color = False # indicates if withdraw() was called after windows_set_titlebar_color + self._iconify_called_after_windows_set_titlebar_color = False # indicates if iconify() was called after windows_set_titlebar_color + self._block_update_dimensions_event = False + + # set CustomTkinter titlebar icon (Windows only) + if sys.platform.startswith("win"): + self.after(200, self._windows_set_titlebar_icon) + + # set titlebar color (Windows only) + if sys.platform.startswith("win"): + self._windows_set_titlebar_color(self._get_appearance_mode()) + + self.bind('', self._update_dimensions_event) + self.bind('', self._focus_in_event) + + def destroy(self): + self._disable_macos_dark_title_bar() + + # call destroy methods of super classes + tkinter.Toplevel.destroy(self) + CTkAppearanceModeBaseClass.destroy(self) + CTkScalingBaseClass.destroy(self) + + def _focus_in_event(self, event): + # sometimes window looses jumps back on macOS if window is selected from Mission Control, so has to be lifted again + if sys.platform == "darwin": + self.lift() + + def _update_dimensions_event(self, event=None): + if not self._block_update_dimensions_event: + detected_width = self.winfo_width() # detect current window size + detected_height = self.winfo_height() + + if self._current_width != self._reverse_window_scaling(detected_width) or self._current_height != self._reverse_window_scaling(detected_height): + self._current_width = self._reverse_window_scaling(detected_width) # adjust current size according to new size given by event + self._current_height = self._reverse_window_scaling(detected_height) # _current_width and _current_height are independent of the scale + + def _set_scaling(self, new_widget_scaling, new_window_scaling): + super()._set_scaling(new_widget_scaling, new_window_scaling) + + # Force new dimensions on window by using min, max, and geometry. Without min, max it won't work. + super().minsize(self._apply_window_scaling(self._current_width), self._apply_window_scaling(self._current_height)) + super().maxsize(self._apply_window_scaling(self._current_width), self._apply_window_scaling(self._current_height)) + + super().geometry(f"{self._apply_window_scaling(self._current_width)}x{self._apply_window_scaling(self._current_height)}") + + # set new scaled min and max with delay (delay prevents weird bug where window dimensions snap to unscaled dimensions when mouse releases window) + self.after(1000, self._set_scaled_min_max) # Why 1000ms delay? Experience! (Everything tested on Windows 11) + + def block_update_dimensions_event(self): + self._block_update_dimensions_event = False + + def unblock_update_dimensions_event(self): + self._block_update_dimensions_event = False + + def _set_scaled_min_max(self): + if self._min_width is not None or self._min_height is not None: + super().minsize(self._apply_window_scaling(self._min_width), self._apply_window_scaling(self._min_height)) + if self._max_width is not None or self._max_height is not None: + super().maxsize(self._apply_window_scaling(self._max_width), self._apply_window_scaling(self._max_height)) + + def geometry(self, geometry_string: str = None): + if geometry_string is not None: + super().geometry(self._apply_geometry_scaling(geometry_string)) + + # update width and height attributes + width, height, x, y = self._parse_geometry_string(geometry_string) + if width is not None and height is not None: + self._current_width = max(self._min_width, min(width, self._max_width)) # bound value between min and max + self._current_height = max(self._min_height, min(height, self._max_height)) + else: + return self._reverse_geometry_scaling(super().geometry()) + + def withdraw(self): + if self._windows_set_titlebar_color_called: + self._withdraw_called_after_windows_set_titlebar_color = True + super().withdraw() + + def iconify(self): + if self._windows_set_titlebar_color_called: + self._iconify_called_after_windows_set_titlebar_color = True + super().iconify() + + def resizable(self, width: bool = None, height: bool = None): + current_resizable_values = super().resizable(width, height) + self._last_resizable_args = ([], {"width": width, "height": height}) + + if sys.platform.startswith("win"): + self.after(10, lambda: self._windows_set_titlebar_color(self._get_appearance_mode())) + + return current_resizable_values + + def minsize(self, width=None, height=None): + self._min_width = width + self._min_height = height + if self._current_width < width: + self._current_width = width + if self._current_height < height: + self._current_height = height + super().minsize(self._apply_window_scaling(self._min_width), self._apply_window_scaling(self._min_height)) + + def maxsize(self, width=None, height=None): + self._max_width = width + self._max_height = height + if self._current_width > width: + self._current_width = width + if self._current_height > height: + self._current_height = height + super().maxsize(self._apply_window_scaling(self._max_width), self._apply_window_scaling(self._max_height)) + + def configure(self, **kwargs): + if "fg_color" in kwargs: + self._fg_color = self._check_color_type(kwargs.pop("fg_color")) + super().configure(bg=self._apply_appearance_mode(self._fg_color)) + + for child in self.winfo_children(): + try: + child.configure(bg_color=self._fg_color) + except Exception: + pass + + super().configure(**pop_from_dict_by_set(kwargs, self._valid_tk_toplevel_arguments)) + check_kwargs_empty(kwargs) + + def cget(self, attribute_name: str) -> any: + if attribute_name == "fg_color": + return self._fg_color + else: + return super().cget(attribute_name) + + def wm_iconbitmap(self, bitmap=None, default=None): + self._iconbitmap_method_called = True + super().wm_iconbitmap(bitmap, default) + + def _windows_set_titlebar_icon(self): + try: + # if not the user already called iconbitmap method, set icon + if not self._iconbitmap_method_called: + customtkinter_directory = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + self.iconbitmap(os.path.join(customtkinter_directory, "assets", "icons", "CustomTkinter_icon_Windows.ico")) + except Exception: + pass + + @classmethod + def _enable_macos_dark_title_bar(cls): + if sys.platform == "darwin" and not cls._deactivate_macos_window_header_manipulation: # macOS + if Version(platform.python_version()) < Version("3.10"): + if Version(tkinter.Tcl().call("info", "patchlevel")) >= Version("8.6.9"): # Tcl/Tk >= 8.6.9 + os.system("defaults write -g NSRequiresAquaSystemAppearance -bool No") + + @classmethod + def _disable_macos_dark_title_bar(cls): + if sys.platform == "darwin" and not cls._deactivate_macos_window_header_manipulation: # macOS + if Version(platform.python_version()) < Version("3.10"): + if Version(tkinter.Tcl().call("info", "patchlevel")) >= Version("8.6.9"): # Tcl/Tk >= 8.6.9 + os.system("defaults delete -g NSRequiresAquaSystemAppearance") + # This command reverts the dark-mode setting for all programs. + + def _windows_set_titlebar_color(self, color_mode: str): + """ + Set the titlebar color of the window to light or dark theme on Microsoft Windows. + + Credits for this function: + https://stackoverflow.com/questions/23836000/can-i-change-the-title-bar-in-tkinter/70724666#70724666 + + MORE INFO: + https://docs.microsoft.com/en-us/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute + """ + + if sys.platform.startswith("win") and not self._deactivate_windows_window_header_manipulation: + + self._state_before_windows_set_titlebar_color = self.state() + super().withdraw() # hide window so that it can be redrawn after the titlebar change so that the color change is visible + super().update() + + if color_mode.lower() == "dark": + value = 1 + elif color_mode.lower() == "light": + value = 0 + else: + return + + try: + hwnd = ctypes.windll.user32.GetParent(self.winfo_id()) + DWMWA_USE_IMMERSIVE_DARK_MODE = 20 + DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1 = 19 + + # try with DWMWA_USE_IMMERSIVE_DARK_MODE + if ctypes.windll.dwmapi.DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, + ctypes.byref(ctypes.c_int(value)), + ctypes.sizeof(ctypes.c_int(value))) != 0: + # try with DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20h1 + ctypes.windll.dwmapi.DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1, + ctypes.byref(ctypes.c_int(value)), + ctypes.sizeof(ctypes.c_int(value))) + + except Exception as err: + print(err) + + self._windows_set_titlebar_color_called = True + self.after(5, self._revert_withdraw_after_windows_set_titlebar_color) + + def _revert_withdraw_after_windows_set_titlebar_color(self): + """ if in a short time (5ms) after """ + if self._windows_set_titlebar_color_called: + + if self._withdraw_called_after_windows_set_titlebar_color: + pass # leave it withdrawed + elif self._iconify_called_after_windows_set_titlebar_color: + super().iconify() + else: + if self._state_before_windows_set_titlebar_color == "normal": + self.deiconify() + elif self._state_before_windows_set_titlebar_color == "iconic": + self.iconify() + elif self._state_before_windows_set_titlebar_color == "zoomed": + self.state("zoomed") + else: + self.state(self._state_before_windows_set_titlebar_color) # other states + + self._windows_set_titlebar_color_called = False + self._withdraw_called_after_windows_set_titlebar_color = False + self._iconify_called_after_windows_set_titlebar_color = False + + def _set_appearance_mode(self, mode_string): + super()._set_appearance_mode(mode_string) + + if sys.platform.startswith("win"): + self._windows_set_titlebar_color(mode_string) + + super().configure(bg=self._apply_appearance_mode(self._fg_color)) diff --git a/customtkinter/windows/widgets/__init__.py b/customtkinter/windows/widgets/__init__.py new file mode 100644 index 0000000..2e21484 --- /dev/null +++ b/customtkinter/windows/widgets/__init__.py @@ -0,0 +1,15 @@ +from .ctk_button import CTkButton +from .ctk_checkbox import CTkCheckBox +from .ctk_combobox import CTkComboBox +from .ctk_entry import CTkEntry +from .ctk_frame import CTkFrame +from .ctk_label import CTkLabel +from .ctk_optionmenu import CTkOptionMenu +from .ctk_progressbar import CTkProgressBar +from .ctk_radiobutton import CTkRadioButton +from .ctk_scrollbar import CTkScrollbar +from .ctk_segmented_button import CTkSegmentedButton +from .ctk_slider import CTkSlider +from .ctk_switch import CTkSwitch +from .ctk_tabview import CTkTabview +from .ctk_textbox import CTkTextbox diff --git a/customtkinter/windows/widgets/__pycache__/__init__.cpython-310.pyc b/customtkinter/windows/widgets/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..73f0ec8 Binary files /dev/null and b/customtkinter/windows/widgets/__pycache__/__init__.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/__pycache__/ctk_button.cpython-310.pyc b/customtkinter/windows/widgets/__pycache__/ctk_button.cpython-310.pyc new file mode 100644 index 0000000..86dca20 Binary files /dev/null and b/customtkinter/windows/widgets/__pycache__/ctk_button.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/__pycache__/ctk_checkbox.cpython-310.pyc b/customtkinter/windows/widgets/__pycache__/ctk_checkbox.cpython-310.pyc new file mode 100644 index 0000000..32780b3 Binary files /dev/null and b/customtkinter/windows/widgets/__pycache__/ctk_checkbox.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/__pycache__/ctk_combobox.cpython-310.pyc b/customtkinter/windows/widgets/__pycache__/ctk_combobox.cpython-310.pyc new file mode 100644 index 0000000..69de504 Binary files /dev/null and b/customtkinter/windows/widgets/__pycache__/ctk_combobox.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/__pycache__/ctk_entry.cpython-310.pyc b/customtkinter/windows/widgets/__pycache__/ctk_entry.cpython-310.pyc new file mode 100644 index 0000000..9d46fcc Binary files /dev/null and b/customtkinter/windows/widgets/__pycache__/ctk_entry.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/__pycache__/ctk_frame.cpython-310.pyc b/customtkinter/windows/widgets/__pycache__/ctk_frame.cpython-310.pyc new file mode 100644 index 0000000..7cd10e9 Binary files /dev/null and b/customtkinter/windows/widgets/__pycache__/ctk_frame.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/__pycache__/ctk_label.cpython-310.pyc b/customtkinter/windows/widgets/__pycache__/ctk_label.cpython-310.pyc new file mode 100644 index 0000000..2bb792f Binary files /dev/null and b/customtkinter/windows/widgets/__pycache__/ctk_label.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/__pycache__/ctk_optionmenu.cpython-310.pyc b/customtkinter/windows/widgets/__pycache__/ctk_optionmenu.cpython-310.pyc new file mode 100644 index 0000000..da0d6f0 Binary files /dev/null and b/customtkinter/windows/widgets/__pycache__/ctk_optionmenu.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/__pycache__/ctk_progressbar.cpython-310.pyc b/customtkinter/windows/widgets/__pycache__/ctk_progressbar.cpython-310.pyc new file mode 100644 index 0000000..4c4d4ec Binary files /dev/null and b/customtkinter/windows/widgets/__pycache__/ctk_progressbar.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/__pycache__/ctk_radiobutton.cpython-310.pyc b/customtkinter/windows/widgets/__pycache__/ctk_radiobutton.cpython-310.pyc new file mode 100644 index 0000000..8923c8a Binary files /dev/null and b/customtkinter/windows/widgets/__pycache__/ctk_radiobutton.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/__pycache__/ctk_scrollbar.cpython-310.pyc b/customtkinter/windows/widgets/__pycache__/ctk_scrollbar.cpython-310.pyc new file mode 100644 index 0000000..924b2c9 Binary files /dev/null and b/customtkinter/windows/widgets/__pycache__/ctk_scrollbar.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/__pycache__/ctk_segmented_button.cpython-310.pyc b/customtkinter/windows/widgets/__pycache__/ctk_segmented_button.cpython-310.pyc new file mode 100644 index 0000000..26db9d4 Binary files /dev/null and b/customtkinter/windows/widgets/__pycache__/ctk_segmented_button.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/__pycache__/ctk_slider.cpython-310.pyc b/customtkinter/windows/widgets/__pycache__/ctk_slider.cpython-310.pyc new file mode 100644 index 0000000..d177816 Binary files /dev/null and b/customtkinter/windows/widgets/__pycache__/ctk_slider.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/__pycache__/ctk_switch.cpython-310.pyc b/customtkinter/windows/widgets/__pycache__/ctk_switch.cpython-310.pyc new file mode 100644 index 0000000..1504b0a Binary files /dev/null and b/customtkinter/windows/widgets/__pycache__/ctk_switch.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/__pycache__/ctk_tabview.cpython-310.pyc b/customtkinter/windows/widgets/__pycache__/ctk_tabview.cpython-310.pyc new file mode 100644 index 0000000..a4101ab Binary files /dev/null and b/customtkinter/windows/widgets/__pycache__/ctk_tabview.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/__pycache__/ctk_textbox.cpython-310.pyc b/customtkinter/windows/widgets/__pycache__/ctk_textbox.cpython-310.pyc new file mode 100644 index 0000000..2ddb1dc Binary files /dev/null and b/customtkinter/windows/widgets/__pycache__/ctk_textbox.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/appearance_mode/__init__.py b/customtkinter/windows/widgets/appearance_mode/__init__.py new file mode 100644 index 0000000..e979ca8 --- /dev/null +++ b/customtkinter/windows/widgets/appearance_mode/__init__.py @@ -0,0 +1,4 @@ +from .appearance_mode_base_class import CTkAppearanceModeBaseClass +from .appearance_mode_tracker import AppearanceModeTracker + +AppearanceModeTracker.init_appearance_mode() diff --git a/customtkinter/windows/widgets/appearance_mode/__pycache__/__init__.cpython-310.pyc b/customtkinter/windows/widgets/appearance_mode/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..0a824e1 Binary files /dev/null and b/customtkinter/windows/widgets/appearance_mode/__pycache__/__init__.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/appearance_mode/__pycache__/appearance_mode_base_class.cpython-310.pyc b/customtkinter/windows/widgets/appearance_mode/__pycache__/appearance_mode_base_class.cpython-310.pyc new file mode 100644 index 0000000..bc10541 Binary files /dev/null and b/customtkinter/windows/widgets/appearance_mode/__pycache__/appearance_mode_base_class.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/appearance_mode/__pycache__/appearance_mode_tracker.cpython-310.pyc b/customtkinter/windows/widgets/appearance_mode/__pycache__/appearance_mode_tracker.cpython-310.pyc new file mode 100644 index 0000000..6508b64 Binary files /dev/null and b/customtkinter/windows/widgets/appearance_mode/__pycache__/appearance_mode_tracker.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/appearance_mode/appearance_mode_base_class.py b/customtkinter/windows/widgets/appearance_mode/appearance_mode_base_class.py new file mode 100644 index 0000000..0d19147 --- /dev/null +++ b/customtkinter/windows/widgets/appearance_mode/appearance_mode_base_class.py @@ -0,0 +1,61 @@ +from typing import Union, Tuple, List + +from .appearance_mode_tracker import AppearanceModeTracker + + +class CTkAppearanceModeBaseClass: + """ + Super-class that manages the appearance mode. Methods: + + - destroy() must be called when sub-class is destroyed + - _set_appearance_mode() abstractmethod, gets called when appearance mode changes, must be overridden + - _apply_appearance_mode() + + """ + def __init__(self): + AppearanceModeTracker.add(self._set_appearance_mode, self) + self.__appearance_mode = AppearanceModeTracker.get_mode() # 0: "Light" 1: "Dark" + + def destroy(self): + AppearanceModeTracker.remove(self._set_appearance_mode) + + def _set_appearance_mode(self, mode_string: str): + """ can be overridden but super method must be called at the beginning """ + if mode_string.lower() == "dark": + self.__appearance_mode = 1 + elif mode_string.lower() == "light": + self.__appearance_mode = 0 + + def _get_appearance_mode(self) -> str: + """ get appearance mode as a string, 'light' or 'dark' """ + if self.__appearance_mode == 0: + return "light" + else: + return "dark" + + def _apply_appearance_mode(self, color: Union[str, Tuple[str, str], List[str]]) -> str: + """ + color can be either a single hex color string or a color name or it can be a + tuple color with (light_color, dark_color). The functions returns + always a single color string + """ + + if isinstance(color, (tuple, list)): + return color[self.__appearance_mode] + else: + return color + + @staticmethod + def _check_color_type(color: any, transparency: bool = False): + if color is None: + raise ValueError(f"color is None, for transparency set color='transparent'") + elif isinstance(color, (tuple, list)) and (color[0] == "transparent" or color[1] == "transparent"): + raise ValueError(f"transparency is not allowed in tuple color {color}, use 'transparent'") + elif color == "transparent" and transparency is False: + raise ValueError(f"transparency is not allowed for this attribute") + elif isinstance(color, str): + return color + elif isinstance(color, (tuple, list)) and len(color) == 2 and isinstance(color[0], str) and isinstance(color[1], str): + return color + else: + raise ValueError(f"color {color} must be string ('transparent' or 'color-name' or 'hex-color') or tuple of two strings, not {type(color)}") diff --git a/customtkinter/windows/widgets/appearance_mode/appearance_mode_tracker.py b/customtkinter/windows/widgets/appearance_mode/appearance_mode_tracker.py new file mode 100644 index 0000000..4958b93 --- /dev/null +++ b/customtkinter/windows/widgets/appearance_mode/appearance_mode_tracker.py @@ -0,0 +1,135 @@ +import sys +import tkinter +from distutils.version import StrictVersion as Version +from typing import Callable + +try: + import darkdetect + + if Version(darkdetect.__version__) < Version("0.3.1"): + sys.stderr.write("WARNING: You have to upgrade the darkdetect library: pip3 install --upgrade darkdetect\n") + if sys.platform != "darwin": + exit() +except ImportError as err: + raise err +except Exception: + sys.stderr.write("customtkinter.appearance_mode_tracker warning: failed to import darkdetect") + + +class AppearanceModeTracker: + + callback_list = [] + app_list = [] + update_loop_running = False + update_loop_interval = 30 # milliseconds + + appearance_mode_set_by = "system" + appearance_mode = 0 # Light (standard) + + @classmethod + def init_appearance_mode(cls): + if cls.appearance_mode_set_by == "system": + new_appearance_mode = cls.detect_appearance_mode() + + if new_appearance_mode != cls.appearance_mode: + cls.appearance_mode = new_appearance_mode + cls.update_callbacks() + + @classmethod + def add(cls, callback: Callable, widget=None): + cls.callback_list.append(callback) + + if widget is not None: + app = cls.get_tk_root_of_widget(widget) + if app not in cls.app_list: + cls.app_list.append(app) + + if not cls.update_loop_running: + app.after(cls.update_loop_interval, cls.update) + cls.update_loop_running = True + + @classmethod + def remove(cls, callback: Callable): + try: + cls.callback_list.remove(callback) + except ValueError: + return + + @staticmethod + def detect_appearance_mode() -> int: + try: + if darkdetect.theme() == "Dark": + return 1 # Dark + else: + return 0 # Light + except NameError: + return 0 # Light + + @classmethod + def get_tk_root_of_widget(cls, widget): + current_widget = widget + + while isinstance(current_widget, tkinter.Tk) is False: + current_widget = current_widget.master + + return current_widget + + @classmethod + def update_callbacks(cls): + if cls.appearance_mode == 0: + for callback in cls.callback_list: + try: + callback("Light") + except Exception: + continue + + elif cls.appearance_mode == 1: + for callback in cls.callback_list: + try: + callback("Dark") + except Exception: + continue + + @classmethod + def update(cls): + if cls.appearance_mode_set_by == "system": + new_appearance_mode = cls.detect_appearance_mode() + + if new_appearance_mode != cls.appearance_mode: + cls.appearance_mode = new_appearance_mode + cls.update_callbacks() + + # find an existing tkinter.Tk object for the next call of .after() + for app in cls.app_list: + try: + app.after(cls.update_loop_interval, cls.update) + return + except Exception: + continue + + cls.update_loop_running = False + + @classmethod + def get_mode(cls) -> int: + return cls.appearance_mode + + @classmethod + def set_appearance_mode(cls, mode_string: str): + if mode_string.lower() == "dark": + cls.appearance_mode_set_by = "user" + new_appearance_mode = 1 + + if new_appearance_mode != cls.appearance_mode: + cls.appearance_mode = new_appearance_mode + cls.update_callbacks() + + elif mode_string.lower() == "light": + cls.appearance_mode_set_by = "user" + new_appearance_mode = 0 + + if new_appearance_mode != cls.appearance_mode: + cls.appearance_mode = new_appearance_mode + cls.update_callbacks() + + elif mode_string.lower() == "system": + cls.appearance_mode_set_by = "system" diff --git a/customtkinter/windows/widgets/core_rendering/__init__.py b/customtkinter/windows/widgets/core_rendering/__init__.py new file mode 100644 index 0000000..ccadbc7 --- /dev/null +++ b/customtkinter/windows/widgets/core_rendering/__init__.py @@ -0,0 +1,12 @@ +import sys + +from .ctk_canvas import CTkCanvas +from .draw_engine import DrawEngine + +CTkCanvas.init_font_character_mapping() + +# determine draw method based on current platform +if sys.platform == "darwin": + DrawEngine.preferred_drawing_method = "polygon_shapes" +else: + DrawEngine.preferred_drawing_method = "font_shapes" diff --git a/customtkinter/windows/widgets/core_rendering/__pycache__/__init__.cpython-310.pyc b/customtkinter/windows/widgets/core_rendering/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..1123f3c Binary files /dev/null and b/customtkinter/windows/widgets/core_rendering/__pycache__/__init__.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/core_rendering/__pycache__/ctk_canvas.cpython-310.pyc b/customtkinter/windows/widgets/core_rendering/__pycache__/ctk_canvas.cpython-310.pyc new file mode 100644 index 0000000..d8926ce Binary files /dev/null and b/customtkinter/windows/widgets/core_rendering/__pycache__/ctk_canvas.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/core_rendering/__pycache__/draw_engine.cpython-310.pyc b/customtkinter/windows/widgets/core_rendering/__pycache__/draw_engine.cpython-310.pyc new file mode 100644 index 0000000..ac10c84 Binary files /dev/null and b/customtkinter/windows/widgets/core_rendering/__pycache__/draw_engine.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/core_rendering/ctk_canvas.py b/customtkinter/windows/widgets/core_rendering/ctk_canvas.py new file mode 100644 index 0000000..f291e2c --- /dev/null +++ b/customtkinter/windows/widgets/core_rendering/ctk_canvas.py @@ -0,0 +1,117 @@ +import tkinter +import sys +from typing import Union, Tuple + + +class CTkCanvas(tkinter.Canvas): + """ + Canvas with additional functionality to draw antialiased circles on Windows/Linux. + + Call .init_font_character_mapping() at program start to load the correct character + dictionary according to the operating system. Characters (circle sizes) are optimised + to look best for rendering CustomTkinter shapes on the different operating systems. + + - .create_aa_circle() creates antialiased circle and returns int identifier. + - .coords() is modified to support the aa-circle shapes correctly like you would expect. + - .itemconfig() is also modified to support aa-cricle shapes. + + The aa-circles are created by choosing a character from the custom created and loaded + font 'CustomTkinter_shapes_font'. It contains circle shapes with different sizes filling + either the whole character space or just pert of it (characters A to R). Circles with a smaller + radius need a smaller circle character to look correct when rendered on the canvas. + + For an optimal result, the draw-engine creates two aa-circles on top of each other, while + one is rotated by 90 degrees. This helps to make the circle look more symetric, which is + not can be a problem when using only a single circle character. + """ + + radius_to_char_fine: dict = None # dict to map radius to font circle character + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._aa_circle_canvas_ids = set() + + @classmethod + def init_font_character_mapping(cls): + """ optimizations made for Windows 10, 11 only """ + + radius_to_char_warped = {19: 'B', 18: 'B', 17: 'B', 16: 'B', 15: 'B', 14: 'B', 13: 'B', 12: 'B', 11: 'B', + 10: 'B', + 9: 'C', 8: 'D', 7: 'C', 6: 'E', 5: 'F', 4: 'G', 3: 'H', 2: 'H', 1: 'H', 0: 'A'} + + radius_to_char_fine_windows_10 = {19: 'A', 18: 'A', 17: 'B', 16: 'B', 15: 'B', 14: 'B', 13: 'C', 12: 'C', + 11: 'C', 10: 'C', + 9: 'D', 8: 'D', 7: 'D', 6: 'C', 5: 'D', 4: 'G', 3: 'G', 2: 'H', 1: 'H', + 0: 'A'} + + radius_to_char_fine_windows_11 = {19: 'A', 18: 'A', 17: 'B', 16: 'B', 15: 'B', 14: 'B', 13: 'C', 12: 'C', + 11: 'D', 10: 'D', + 9: 'E', 8: 'F', 7: 'C', 6: 'I', 5: 'E', 4: 'G', 3: 'P', 2: 'R', 1: 'R', + 0: 'A'} + + radius_to_char_fine_linux = {19: 'A', 18: 'A', 17: 'B', 16: 'B', 15: 'B', 14: 'B', 13: 'F', 12: 'C', + 11: 'F', 10: 'C', + 9: 'D', 8: 'G', 7: 'D', 6: 'F', 5: 'D', 4: 'G', 3: 'M', 2: 'H', 1: 'H', + 0: 'A'} + + if sys.platform.startswith("win"): + if sys.getwindowsversion().build > 20000: # Windows 11 + cls.radius_to_char_fine = radius_to_char_fine_windows_11 + else: # < Windows 11 + cls.radius_to_char_fine = radius_to_char_fine_windows_10 + elif sys.platform.startswith("linux"): # Optimized on Kali Linux + cls.radius_to_char_fine = radius_to_char_fine_linux + else: + cls.radius_to_char_fine = radius_to_char_fine_windows_10 + + def _get_char_from_radius(self, radius: int) -> str: + if radius >= 20: + return "A" + else: + return self.radius_to_char_fine[radius] + + def create_aa_circle(self, x_pos: int, y_pos: int, radius: int, angle: int = 0, fill: str = "white", + tags: Union[str, Tuple[str, ...]] = "", anchor: str = tkinter.CENTER) -> int: + # create a circle with a font element + circle_1 = self.create_text(x_pos, y_pos, text=self._get_char_from_radius(radius), anchor=anchor, fill=fill, + font=("CustomTkinter_shapes_font", -radius * 2), tags=tags, angle=angle) + self.addtag_withtag("ctk_aa_circle_font_element", circle_1) + self._aa_circle_canvas_ids.add(circle_1) + + return circle_1 + + def coords(self, tag_or_id, *args): + + if type(tag_or_id) == str and "ctk_aa_circle_font_element" in self.gettags(tag_or_id): + coords_id = self.find_withtag(tag_or_id)[0] # take the lowest id for the given tag + super().coords(coords_id, *args[:2]) + + if len(args) == 3: + super().itemconfigure(coords_id, font=("CustomTkinter_shapes_font", -int(args[2]) * 2), text=self._get_char_from_radius(args[2])) + + elif type(tag_or_id) == int and tag_or_id in self._aa_circle_canvas_ids: + super().coords(tag_or_id, *args[:2]) + + if len(args) == 3: + super().itemconfigure(tag_or_id, font=("CustomTkinter_shapes_font", -args[2] * 2), text=self._get_char_from_radius(args[2])) + + else: + super().coords(tag_or_id, *args) + + def itemconfig(self, tag_or_id, *args, **kwargs): + kwargs_except_outline = kwargs.copy() + if "outline" in kwargs_except_outline: + del kwargs_except_outline["outline"] + + if type(tag_or_id) == int: + if tag_or_id in self._aa_circle_canvas_ids: + super().itemconfigure(tag_or_id, *args, **kwargs_except_outline) + else: + super().itemconfigure(tag_or_id, *args, **kwargs) + else: + configure_ids = self.find_withtag(tag_or_id) + for configure_id in configure_ids: + if configure_id in self._aa_circle_canvas_ids: + super().itemconfigure(configure_id, *args, **kwargs_except_outline) + else: + super().itemconfigure(configure_id, *args, **kwargs) diff --git a/customtkinter/windows/widgets/core_rendering/draw_engine.py b/customtkinter/windows/widgets/core_rendering/draw_engine.py new file mode 100644 index 0000000..5acea56 --- /dev/null +++ b/customtkinter/windows/widgets/core_rendering/draw_engine.py @@ -0,0 +1,1235 @@ +from __future__ import annotations +import sys +import math +import tkinter +from typing import Union, TYPE_CHECKING + +if TYPE_CHECKING: + from ..core_rendering import CTkCanvas + + +class DrawEngine: + """ + This is the core of the CustomTkinter library where all the drawing on the tkinter.Canvas happens. + A year of experimenting and trying out different drawing methods have led to the current state of this + class, and I don't think there's much I can do to make the rendering look better than this with the + limited capabilities the tkinter.Canvas offers. + + Functions: + - draw_rounded_rect_with_border() + - draw_rounded_rect_with_border_vertical_split() + - draw_rounded_progress_bar_with_border() + - draw_rounded_slider_with_border_and_button() + - draw_rounded_scrollbar() + - draw_checkmark() + - draw_dropdown_arrow() + + """ + + preferred_drawing_method: str = None # 'polygon_shapes', 'font_shapes', 'circle_shapes' + + def __init__(self, canvas: CTkCanvas): + self._canvas = canvas + self._round_width_to_even_numbers: bool = True + self._round_height_to_even_numbers: bool = True + + def set_round_to_even_numbers(self, round_width_to_even_numbers: bool = True, round_height_to_even_numbers: bool = True): + self._round_width_to_even_numbers: bool = round_width_to_even_numbers + self._round_height_to_even_numbers: bool = round_height_to_even_numbers + + def __calc_optimal_corner_radius(self, user_corner_radius: Union[float, int]) -> Union[float, int]: + # optimize for drawing with polygon shapes + if self.preferred_drawing_method == "polygon_shapes": + if sys.platform == "darwin": + return user_corner_radius + else: + return round(user_corner_radius) + + # optimize for drawing with antialiased font shapes + elif self.preferred_drawing_method == "font_shapes": + return round(user_corner_radius) + + # optimize for drawing with circles and rects + elif self.preferred_drawing_method == "circle_shapes": + user_corner_radius = 0.5 * round(user_corner_radius / 0.5) # round to 0.5 steps + + # make sure the value is always with .5 at the end for smoother corners + if user_corner_radius == 0: + return 0 + elif user_corner_radius % 1 == 0: + return user_corner_radius + 0.5 + else: + return user_corner_radius + + def draw_background_corners(self, width: Union[float, int], height: Union[float, int], ): + if self._round_width_to_even_numbers: + width = math.floor(width / 2) * 2 # round (floor) _current_width and _current_height and restrict them to even values only + if self._round_height_to_even_numbers: + height = math.floor(height / 2) * 2 + + requires_recoloring = False + + if not self._canvas.find_withtag("background_corner_top_left"): + self._canvas.create_rectangle((0, 0, 0, 0), tags=("background_parts", "background_corner_top_left"), width=0) + requires_recoloring = True + if not self._canvas.find_withtag("background_corner_top_right"): + self._canvas.create_rectangle((0, 0, 0, 0), tags=("background_parts", "background_corner_top_right"), width=0) + requires_recoloring = True + if not self._canvas.find_withtag("background_corner_bottom_right"): + self._canvas.create_rectangle((0, 0, 0, 0), tags=("background_parts", "background_corner_bottom_right"), width=0) + requires_recoloring = True + if not self._canvas.find_withtag("background_corner_bottom_left"): + self._canvas.create_rectangle((0, 0, 0, 0), tags=("background_parts", "background_corner_bottom_left"), width=0) + requires_recoloring = True + + mid_width, mid_height = round(width / 2), round(height / 2) + self._canvas.coords("background_corner_top_left", (0, 0, mid_width, mid_height)) + self._canvas.coords("background_corner_top_right", (mid_width, 0, width, mid_height)) + self._canvas.coords("background_corner_bottom_right", (mid_width, mid_height, width, height)) + self._canvas.coords("background_corner_bottom_left", (0, mid_height, mid_width, height)) + + if requires_recoloring: # new parts were added -> manage z-order + self._canvas.tag_lower("background_parts") + + return requires_recoloring + + def draw_rounded_rect_with_border(self, width: Union[float, int], height: Union[float, int], corner_radius: Union[float, int], + border_width: Union[float, int], overwrite_preferred_drawing_method: str = None) -> bool: + """ Draws a rounded rectangle with a corner_radius and border_width on the canvas. The border elements have a 'border_parts' tag, + the main foreground elements have an 'inner_parts' tag to color the elements accordingly. + + returns bool if recoloring is necessary """ + + if self._round_width_to_even_numbers: + width = math.floor(width / 2) * 2 # round (floor) _current_width and _current_height and restrict them to even values only + if self._round_height_to_even_numbers: + height = math.floor(height / 2) * 2 + corner_radius = round(corner_radius) + + if corner_radius > width / 2 or corner_radius > height / 2: # restrict corner_radius if it's too large + corner_radius = min(width / 2, height / 2) + + border_width = round(border_width) + corner_radius = self.__calc_optimal_corner_radius(corner_radius) # optimize corner_radius for different drawing methods (different rounding) + + if corner_radius >= border_width: + inner_corner_radius = corner_radius - border_width + else: + inner_corner_radius = 0 + + if overwrite_preferred_drawing_method is not None: + preferred_drawing_method = overwrite_preferred_drawing_method + else: + preferred_drawing_method = self.preferred_drawing_method + + if preferred_drawing_method == "polygon_shapes": + return self.__draw_rounded_rect_with_border_polygon_shapes(width, height, corner_radius, border_width, inner_corner_radius) + elif preferred_drawing_method == "font_shapes": + return self.__draw_rounded_rect_with_border_font_shapes(width, height, corner_radius, border_width, inner_corner_radius, ()) + elif preferred_drawing_method == "circle_shapes": + return self.__draw_rounded_rect_with_border_circle_shapes(width, height, corner_radius, border_width, inner_corner_radius) + + def __draw_rounded_rect_with_border_polygon_shapes(self, width: int, height: int, corner_radius: int, border_width: int, inner_corner_radius: int) -> bool: + requires_recoloring = False + + # create border button parts (only if border exists) + if border_width > 0: + if not self._canvas.find_withtag("border_parts"): + self._canvas.create_polygon((0, 0, 0, 0), tags=("border_line_1", "border_parts")) + requires_recoloring = True + + self._canvas.coords("border_line_1", + (corner_radius, + corner_radius, + width - corner_radius, + corner_radius, + width - corner_radius, + height - corner_radius, + corner_radius, + height - corner_radius)) + self._canvas.itemconfig("border_line_1", + joinstyle=tkinter.ROUND, + width=corner_radius * 2) + + else: + self._canvas.delete("border_parts") + + # create inner button parts + if not self._canvas.find_withtag("inner_parts"): + self._canvas.create_polygon((0, 0, 0, 0), tags=("inner_line_1", "inner_parts"), joinstyle=tkinter.ROUND) + requires_recoloring = True + + if corner_radius <= border_width: + bottom_right_shift = -1 # weird canvas rendering inaccuracy that has to be corrected in some cases + else: + bottom_right_shift = 0 + + self._canvas.coords("inner_line_1", + border_width + inner_corner_radius, + border_width + inner_corner_radius, + width - (border_width + inner_corner_radius) + bottom_right_shift, + border_width + inner_corner_radius, + width - (border_width + inner_corner_radius) + bottom_right_shift, + height - (border_width + inner_corner_radius) + bottom_right_shift, + border_width + inner_corner_radius, + height - (border_width + inner_corner_radius) + bottom_right_shift) + self._canvas.itemconfig("inner_line_1", + width=inner_corner_radius * 2) + + if requires_recoloring: # new parts were added -> manage z-order + self._canvas.tag_lower("inner_parts") + self._canvas.tag_lower("border_parts") + self._canvas.tag_lower("background_parts") + + return requires_recoloring + + def __draw_rounded_rect_with_border_font_shapes(self, width: int, height: int, corner_radius: int, border_width: int, inner_corner_radius: int, + exclude_parts: tuple) -> bool: + requires_recoloring = False + + # create border button parts + if border_width > 0: + if corner_radius > 0: + # create canvas border corner parts if not already created, but only if needed, and delete if not needed + if not self._canvas.find_withtag("border_oval_1_a") and "border_oval_1" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_1_a", "border_corner_part", "border_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_1_b", "border_corner_part", "border_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("border_oval_1_a") and "border_oval_1" in exclude_parts: + self._canvas.delete("border_oval_1_a", "border_oval_1_b") + + if not self._canvas.find_withtag("border_oval_2_a") and width > 2 * corner_radius and "border_oval_2" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_2_a", "border_corner_part", "border_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_2_b", "border_corner_part", "border_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("border_oval_2_a") and (not width > 2 * corner_radius or "border_oval_2" in exclude_parts): + self._canvas.delete("border_oval_2_a", "border_oval_2_b") + + if not self._canvas.find_withtag("border_oval_3_a") and height > 2 * corner_radius \ + and width > 2 * corner_radius and "border_oval_3" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_3_a", "border_corner_part", "border_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_3_b", "border_corner_part", "border_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("border_oval_3_a") and (not (height > 2 * corner_radius + and width > 2 * corner_radius) or "border_oval_3" in exclude_parts): + self._canvas.delete("border_oval_3_a", "border_oval_3_b") + + if not self._canvas.find_withtag("border_oval_4_a") and height > 2 * corner_radius and "border_oval_4" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_4_a", "border_corner_part", "border_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_4_b", "border_corner_part", "border_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("border_oval_4_a") and (not height > 2 * corner_radius or "border_oval_4" in exclude_parts): + self._canvas.delete("border_oval_4_a", "border_oval_4_b") + + # change position of border corner parts + self._canvas.coords("border_oval_1_a", corner_radius, corner_radius, corner_radius) + self._canvas.coords("border_oval_1_b", corner_radius, corner_radius, corner_radius) + self._canvas.coords("border_oval_2_a", width - corner_radius, corner_radius, corner_radius) + self._canvas.coords("border_oval_2_b", width - corner_radius, corner_radius, corner_radius) + self._canvas.coords("border_oval_3_a", width - corner_radius, height - corner_radius, corner_radius) + self._canvas.coords("border_oval_3_b", width - corner_radius, height - corner_radius, corner_radius) + self._canvas.coords("border_oval_4_a", corner_radius, height - corner_radius, corner_radius) + self._canvas.coords("border_oval_4_b", corner_radius, height - corner_radius, corner_radius) + + else: + self._canvas.delete("border_corner_part") # delete border corner parts if not needed + + # create canvas border rectangle parts if not already created + if not self._canvas.find_withtag("border_rectangle_1"): + self._canvas.create_rectangle(0, 0, 0, 0, tags=("border_rectangle_1", "border_rectangle_part", "border_parts"), width=0) + self._canvas.create_rectangle(0, 0, 0, 0, tags=("border_rectangle_2", "border_rectangle_part", "border_parts"), width=0) + requires_recoloring = True + + # change position of border rectangle parts + self._canvas.coords("border_rectangle_1", (0, corner_radius, width, height - corner_radius)) + self._canvas.coords("border_rectangle_2", (corner_radius, 0, width - corner_radius, height)) + + else: + self._canvas.delete("border_parts") + + # create inner button parts + if inner_corner_radius > 0: + + # create canvas border corner parts if not already created, but only if they're needed and delete if not needed + if not self._canvas.find_withtag("inner_oval_1_a") and "inner_oval_1" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_1_a", "inner_corner_part", "inner_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_1_b", "inner_corner_part", "inner_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("inner_oval_1_a") and "inner_oval_1" in exclude_parts: + self._canvas.delete("inner_oval_1_a", "inner_oval_1_b") + + if not self._canvas.find_withtag("inner_oval_2_a") and width - (2 * border_width) > 2 * inner_corner_radius and "inner_oval_2" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_2_a", "inner_corner_part", "inner_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_2_b", "inner_corner_part", "inner_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("inner_oval_2_a") and (not width - (2 * border_width) > 2 * inner_corner_radius or "inner_oval_2" in exclude_parts): + self._canvas.delete("inner_oval_2_a", "inner_oval_2_b") + + if not self._canvas.find_withtag("inner_oval_3_a") and height - (2 * border_width) > 2 * inner_corner_radius \ + and width - (2 * border_width) > 2 * inner_corner_radius and "inner_oval_3" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_3_a", "inner_corner_part", "inner_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_3_b", "inner_corner_part", "inner_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("inner_oval_3_a") and (not (height - (2 * border_width) > 2 * inner_corner_radius + and width - (2 * border_width) > 2 * inner_corner_radius) or "inner_oval_3" in exclude_parts): + self._canvas.delete("inner_oval_3_a", "inner_oval_3_b") + + if not self._canvas.find_withtag("inner_oval_4_a") and height - (2 * border_width) > 2 * inner_corner_radius and "inner_oval_4" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_4_a", "inner_corner_part", "inner_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_4_b", "inner_corner_part", "inner_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("inner_oval_4_a") and (not height - (2 * border_width) > 2 * inner_corner_radius or "inner_oval_4" in exclude_parts): + self._canvas.delete("inner_oval_4_a", "inner_oval_4_b") + + # change position of border corner parts + self._canvas.coords("inner_oval_1_a", border_width + inner_corner_radius, border_width + inner_corner_radius, inner_corner_radius) + self._canvas.coords("inner_oval_1_b", border_width + inner_corner_radius, border_width + inner_corner_radius, inner_corner_radius) + self._canvas.coords("inner_oval_2_a", width - border_width - inner_corner_radius, border_width + inner_corner_radius, inner_corner_radius) + self._canvas.coords("inner_oval_2_b", width - border_width - inner_corner_radius, border_width + inner_corner_radius, inner_corner_radius) + self._canvas.coords("inner_oval_3_a", width - border_width - inner_corner_radius, height - border_width - inner_corner_radius, inner_corner_radius) + self._canvas.coords("inner_oval_3_b", width - border_width - inner_corner_radius, height - border_width - inner_corner_radius, inner_corner_radius) + self._canvas.coords("inner_oval_4_a", border_width + inner_corner_radius, height - border_width - inner_corner_radius, inner_corner_radius) + self._canvas.coords("inner_oval_4_b", border_width + inner_corner_radius, height - border_width - inner_corner_radius, inner_corner_radius) + else: + self._canvas.delete("inner_corner_part") # delete inner corner parts if not needed + + # create canvas inner rectangle parts if not already created + if not self._canvas.find_withtag("inner_rectangle_1"): + self._canvas.create_rectangle(0, 0, 0, 0, tags=("inner_rectangle_1", "inner_rectangle_part", "inner_parts"), width=0) + requires_recoloring = True + + if not self._canvas.find_withtag("inner_rectangle_2") and inner_corner_radius * 2 < height - (border_width * 2): + self._canvas.create_rectangle(0, 0, 0, 0, tags=("inner_rectangle_2", "inner_rectangle_part", "inner_parts"), width=0) + requires_recoloring = True + + elif self._canvas.find_withtag("inner_rectangle_2") and not inner_corner_radius * 2 < height - (border_width * 2): + self._canvas.delete("inner_rectangle_2") + + # change position of inner rectangle parts + self._canvas.coords("inner_rectangle_1", (border_width + inner_corner_radius, + border_width, + width - border_width - inner_corner_radius, + height - border_width)) + self._canvas.coords("inner_rectangle_2", (border_width, + border_width + inner_corner_radius, + width - border_width, + height - inner_corner_radius - border_width)) + + if requires_recoloring: # new parts were added -> manage z-order + self._canvas.tag_lower("inner_parts") + self._canvas.tag_lower("border_parts") + self._canvas.tag_lower("background_parts") + + return requires_recoloring + + def __draw_rounded_rect_with_border_circle_shapes(self, width: int, height: int, corner_radius: int, border_width: int, inner_corner_radius: int) -> bool: + requires_recoloring = False + + # border button parts + if border_width > 0: + if corner_radius > 0: + + if not self._canvas.find_withtag("border_oval_1"): + self._canvas.create_oval(0, 0, 0, 0, tags=("border_oval_1", "border_corner_part", "border_parts"), width=0) + self._canvas.create_oval(0, 0, 0, 0, tags=("border_oval_2", "border_corner_part", "border_parts"), width=0) + self._canvas.create_oval(0, 0, 0, 0, tags=("border_oval_3", "border_corner_part", "border_parts"), width=0) + self._canvas.create_oval(0, 0, 0, 0, tags=("border_oval_4", "border_corner_part", "border_parts"), width=0) + self._canvas.tag_lower("border_parts") + requires_recoloring = True + + self._canvas.coords("border_oval_1", 0, 0, corner_radius * 2 - 1, corner_radius * 2 - 1) + self._canvas.coords("border_oval_2", width - corner_radius * 2, 0, width - 1, corner_radius * 2 - 1) + self._canvas.coords("border_oval_3", 0, height - corner_radius * 2, corner_radius * 2 - 1, height - 1) + self._canvas.coords("border_oval_4", width - corner_radius * 2, height - corner_radius * 2, width - 1, height - 1) + + else: + self._canvas.delete("border_corner_part") + + if not self._canvas.find_withtag("border_rectangle_1"): + self._canvas.create_rectangle(0, 0, 0, 0, tags=("border_rectangle_1", "border_rectangle_part", "border_parts"), width=0) + self._canvas.create_rectangle(0, 0, 0, 0, tags=("border_rectangle_2", "border_rectangle_part", "border_parts"), width=0) + self._canvas.tag_lower("border_parts") + requires_recoloring = True + + self._canvas.coords("border_rectangle_1", (0, corner_radius, width, height - corner_radius)) + self._canvas.coords("border_rectangle_2", (corner_radius, 0, width - corner_radius, height)) + + else: + self._canvas.delete("border_parts") + + # inner button parts + if inner_corner_radius > 0: + + if not self._canvas.find_withtag("inner_oval_1"): + self._canvas.create_oval(0, 0, 0, 0, tags=("inner_oval_1", "inner_corner_part", "inner_parts"), width=0) + self._canvas.create_oval(0, 0, 0, 0, tags=("inner_oval_2", "inner_corner_part", "inner_parts"), width=0) + self._canvas.create_oval(0, 0, 0, 0, tags=("inner_oval_3", "inner_corner_part", "inner_parts"), width=0) + self._canvas.create_oval(0, 0, 0, 0, tags=("inner_oval_4", "inner_corner_part", "inner_parts"), width=0) + self._canvas.tag_raise("inner_parts") + requires_recoloring = True + + self._canvas.coords("inner_oval_1", (border_width, border_width, + border_width + inner_corner_radius * 2 - 1, border_width + inner_corner_radius * 2 - 1)) + self._canvas.coords("inner_oval_2", (width - border_width - inner_corner_radius * 2, border_width, + width - border_width - 1, border_width + inner_corner_radius * 2 - 1)) + self._canvas.coords("inner_oval_3", (border_width, height - border_width - inner_corner_radius * 2, + border_width + inner_corner_radius * 2 - 1, height - border_width - 1)) + self._canvas.coords("inner_oval_4", (width - border_width - inner_corner_radius * 2, height - border_width - inner_corner_radius * 2, + width - border_width - 1, height - border_width - 1)) + else: + self._canvas.delete("inner_corner_part") # delete inner corner parts if not needed + + if not self._canvas.find_withtag("inner_rectangle_1"): + self._canvas.create_rectangle(0, 0, 0, 0, tags=("inner_rectangle_1", "inner_rectangle_part", "inner_parts"), width=0) + self._canvas.create_rectangle(0, 0, 0, 0, tags=("inner_rectangle_2", "inner_rectangle_part", "inner_parts"), width=0) + self._canvas.tag_raise("inner_parts") + requires_recoloring = True + + self._canvas.coords("inner_rectangle_1", (border_width + inner_corner_radius, + border_width, + width - border_width - inner_corner_radius, + height - border_width)) + self._canvas.coords("inner_rectangle_2", (border_width, + border_width + inner_corner_radius, + width - border_width, + height - inner_corner_radius - border_width)) + + return requires_recoloring + + def draw_rounded_rect_with_border_vertical_split(self, width: Union[float, int], height: Union[float, int], corner_radius: Union[float, int], + border_width: Union[float, int], left_section_width: Union[float, int]) -> bool: + """ Draws a rounded rectangle with a corner_radius and border_width on the canvas which is split at left_section_width. + The border elements have the tags 'border_parts_left', 'border_parts_lright', + the main foreground elements have an 'inner_parts_left' and inner_parts_right' tag, + to color the elements accordingly. + + returns bool if recoloring is necessary """ + + left_section_width = round(left_section_width) + if self._round_width_to_even_numbers: + width = math.floor(width / 2) * 2 # round (floor) _current_width and _current_height and restrict them to even values only + if self._round_height_to_even_numbers: + height = math.floor(height / 2) * 2 + corner_radius = round(corner_radius) + + if corner_radius > width / 2 or corner_radius > height / 2: # restrict corner_radius if it's too larger + corner_radius = min(width / 2, height / 2) + + border_width = round(border_width) + corner_radius = self.__calc_optimal_corner_radius(corner_radius) # optimize corner_radius for different drawing methods (different rounding) + + if corner_radius >= border_width: + inner_corner_radius = corner_radius - border_width + else: + inner_corner_radius = 0 + + if left_section_width > width - corner_radius * 2: + left_section_width = width - corner_radius * 2 + elif left_section_width < corner_radius * 2: + left_section_width = corner_radius * 2 + + if self.preferred_drawing_method == "polygon_shapes" or self.preferred_drawing_method == "circle_shapes": + return self.__draw_rounded_rect_with_border_vertical_split_polygon_shapes(width, height, corner_radius, border_width, inner_corner_radius, left_section_width) + elif self.preferred_drawing_method == "font_shapes": + return self.__draw_rounded_rect_with_border_vertical_split_font_shapes(width, height, corner_radius, border_width, inner_corner_radius, left_section_width, ()) + + def __draw_rounded_rect_with_border_vertical_split_polygon_shapes(self, width: int, height: int, corner_radius: int, border_width: int, inner_corner_radius: int, + left_section_width: int) -> bool: + requires_recoloring = False + + # create border button parts (only if border exists) + if border_width > 0: + if not self._canvas.find_withtag("border_parts"): + self._canvas.create_polygon((0, 0, 0, 0), tags=("border_line_left_1", "border_parts_left", "border_parts", "left_parts")) + self._canvas.create_polygon((0, 0, 0, 0), tags=("border_line_right_1", "border_parts_right", "border_parts", "right_parts")) + self._canvas.create_rectangle((0, 0, 0, 0), tags=("border_rect_left_1", "border_parts_left", "border_parts", "left_parts"), width=0) + self._canvas.create_rectangle((0, 0, 0, 0), tags=("border_rect_right_1", "border_parts_right", "border_parts", "right_parts"), width=0) + requires_recoloring = True + + self._canvas.coords("border_line_left_1", + (corner_radius, + corner_radius, + left_section_width - corner_radius, + corner_radius, + left_section_width - corner_radius, + height - corner_radius, + corner_radius, + height - corner_radius)) + self._canvas.coords("border_line_right_1", + (left_section_width + corner_radius, + corner_radius, + width - corner_radius, + corner_radius, + width - corner_radius, + height - corner_radius, + left_section_width + corner_radius, + height - corner_radius)) + self._canvas.coords("border_rect_left_1", + (left_section_width - corner_radius, + 0, + left_section_width, + height)) + self._canvas.coords("border_rect_right_1", + (left_section_width, + 0, + left_section_width + corner_radius, + height)) + self._canvas.itemconfig("border_line_left_1", joinstyle=tkinter.ROUND, width=corner_radius * 2) + self._canvas.itemconfig("border_line_right_1", joinstyle=tkinter.ROUND, width=corner_radius * 2) + + else: + self._canvas.delete("border_parts") + + # create inner button parts + if not self._canvas.find_withtag("inner_parts"): + self._canvas.create_polygon((0, 0, 0, 0), tags=("inner_line_left_1", "inner_parts_left", "inner_parts", "left_parts"), joinstyle=tkinter.ROUND) + self._canvas.create_polygon((0, 0, 0, 0), tags=("inner_line_right_1", "inner_parts_right", "inner_parts", "right_parts"), joinstyle=tkinter.ROUND) + self._canvas.create_rectangle((0, 0, 0, 0), tags=("inner_rect_left_1", "inner_parts_left", "inner_parts", "left_parts"), width=0) + self._canvas.create_rectangle((0, 0, 0, 0), tags=("inner_rect_right_1", "inner_parts_right", "inner_parts", "right_parts"), width=0) + requires_recoloring = True + + self._canvas.coords("inner_line_left_1", + corner_radius, + corner_radius, + left_section_width - inner_corner_radius, + corner_radius, + left_section_width - inner_corner_radius, + height - corner_radius, + corner_radius, + height - corner_radius) + self._canvas.coords("inner_line_right_1", + left_section_width + inner_corner_radius, + corner_radius, + width - corner_radius, + corner_radius, + width - corner_radius, + height - corner_radius, + left_section_width + inner_corner_radius, + height - corner_radius) + self._canvas.coords("inner_rect_left_1", + (left_section_width - inner_corner_radius, + border_width, + left_section_width, + height - border_width)) + self._canvas.coords("inner_rect_right_1", + (left_section_width, + border_width, + left_section_width + inner_corner_radius, + height - border_width)) + self._canvas.itemconfig("inner_line_left_1", width=inner_corner_radius * 2) + self._canvas.itemconfig("inner_line_right_1", width=inner_corner_radius * 2) + + if requires_recoloring: # new parts were added -> manage z-order + self._canvas.tag_lower("inner_parts") + self._canvas.tag_lower("border_parts") + self._canvas.tag_lower("background_parts") + + return requires_recoloring + + def __draw_rounded_rect_with_border_vertical_split_font_shapes(self, width: int, height: int, corner_radius: int, border_width: int, inner_corner_radius: int, + left_section_width: int, exclude_parts: tuple) -> bool: + requires_recoloring = False + + # create border button parts + if border_width > 0: + if corner_radius > 0: + # create canvas border corner parts if not already created, but only if needed, and delete if not needed + if not self._canvas.find_withtag("border_oval_1_a") and "border_oval_1" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_1_a", "border_corner_part", "border_parts_left", "border_parts", "left_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_1_b", "border_corner_part", "border_parts_left", "border_parts", "left_parts"), anchor=tkinter.CENTER, + angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("border_oval_1_a") and "border_oval_1" in exclude_parts: + self._canvas.delete("border_oval_1_a", "border_oval_1_b") + + if not self._canvas.find_withtag("border_oval_2_a") and width > 2 * corner_radius and "border_oval_2" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_2_a", "border_corner_part", "border_parts_right", "border_parts", "right_parts"), + anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_2_b", "border_corner_part", "border_parts_right", "border_parts", "right_parts"), + anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("border_oval_2_a") and (not width > 2 * corner_radius or "border_oval_2" in exclude_parts): + self._canvas.delete("border_oval_2_a", "border_oval_2_b") + + if not self._canvas.find_withtag("border_oval_3_a") and height > 2 * corner_radius \ + and width > 2 * corner_radius and "border_oval_3" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_3_a", "border_corner_part", "border_parts_right", "border_parts", "right_parts"), + anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_3_b", "border_corner_part", "border_parts_right", "border_parts", "right_parts"), + anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("border_oval_3_a") and (not (height > 2 * corner_radius + and width > 2 * corner_radius) or "border_oval_3" in exclude_parts): + self._canvas.delete("border_oval_3_a", "border_oval_3_b") + + if not self._canvas.find_withtag("border_oval_4_a") and height > 2 * corner_radius and "border_oval_4" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_4_a", "border_corner_part", "border_parts_left", "border_parts", "left_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_4_b", "border_corner_part", "border_parts_left", "border_parts", "left_parts"), anchor=tkinter.CENTER, + angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("border_oval_4_a") and (not height > 2 * corner_radius or "border_oval_4" in exclude_parts): + self._canvas.delete("border_oval_4_a", "border_oval_4_b") + + # change position of border corner parts + self._canvas.coords("border_oval_1_a", corner_radius, corner_radius, corner_radius) + self._canvas.coords("border_oval_1_b", corner_radius, corner_radius, corner_radius) + self._canvas.coords("border_oval_2_a", width - corner_radius, corner_radius, corner_radius) + self._canvas.coords("border_oval_2_b", width - corner_radius, corner_radius, corner_radius) + self._canvas.coords("border_oval_3_a", width - corner_radius, height - corner_radius, corner_radius) + self._canvas.coords("border_oval_3_b", width - corner_radius, height - corner_radius, corner_radius) + self._canvas.coords("border_oval_4_a", corner_radius, height - corner_radius, corner_radius) + self._canvas.coords("border_oval_4_b", corner_radius, height - corner_radius, corner_radius) + + else: + self._canvas.delete("border_corner_part") # delete border corner parts if not needed + + # create canvas border rectangle parts if not already created + if not self._canvas.find_withtag("border_rectangle_1"): + self._canvas.create_rectangle(0, 0, 0, 0, tags=("border_rectangle_left_1", "border_rectangle_part", "border_parts_left", "border_parts", "left_parts"), width=0) + self._canvas.create_rectangle(0, 0, 0, 0, tags=("border_rectangle_left_2", "border_rectangle_part", "border_parts_left", "border_parts", "left_parts"), width=0) + self._canvas.create_rectangle(0, 0, 0, 0, tags=("border_rectangle_right_1", "border_rectangle_part", "border_parts_right", "border_parts", "right_parts"), width=0) + self._canvas.create_rectangle(0, 0, 0, 0, tags=("border_rectangle_right_2", "border_rectangle_part", "border_parts_right", "border_parts", "right_parts"), width=0) + requires_recoloring = True + + # change position of border rectangle parts + self._canvas.coords("border_rectangle_left_1", (0, corner_radius, left_section_width, height - corner_radius)) + self._canvas.coords("border_rectangle_left_2", (corner_radius, 0, left_section_width, height)) + self._canvas.coords("border_rectangle_right_1", (left_section_width, corner_radius, width, height - corner_radius)) + self._canvas.coords("border_rectangle_right_2", (left_section_width, 0, width - corner_radius, height)) + + else: + self._canvas.delete("border_parts") + + # create inner button parts + if inner_corner_radius > 0: + + # create canvas border corner parts if not already created, but only if they're needed and delete if not needed + if not self._canvas.find_withtag("inner_oval_1_a") and "inner_oval_1" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_1_a", "inner_corner_part", "inner_parts_left", "inner_parts", "left_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_1_b", "inner_corner_part", "inner_parts_left", "inner_parts", "left_parts"), anchor=tkinter.CENTER, + angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("inner_oval_1_a") and "inner_oval_1" in exclude_parts: + self._canvas.delete("inner_oval_1_a", "inner_oval_1_b") + + if not self._canvas.find_withtag("inner_oval_2_a") and width - (2 * border_width) > 2 * inner_corner_radius and "inner_oval_2" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_2_a", "inner_corner_part", "inner_parts_right", "inner_parts", "right_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_2_b", "inner_corner_part", "inner_parts_right", "inner_parts", "right_parts"), anchor=tkinter.CENTER, + angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("inner_oval_2_a") and (not width - (2 * border_width) > 2 * inner_corner_radius or "inner_oval_2" in exclude_parts): + self._canvas.delete("inner_oval_2_a", "inner_oval_2_b") + + if not self._canvas.find_withtag("inner_oval_3_a") and height - (2 * border_width) > 2 * inner_corner_radius \ + and width - (2 * border_width) > 2 * inner_corner_radius and "inner_oval_3" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_3_a", "inner_corner_part", "inner_parts_right", "inner_parts", "right_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_3_b", "inner_corner_part", "inner_parts_right", "inner_parts", "right_parts"), anchor=tkinter.CENTER, + angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("inner_oval_3_a") and (not (height - (2 * border_width) > 2 * inner_corner_radius + and width - (2 * border_width) > 2 * inner_corner_radius) or "inner_oval_3" in exclude_parts): + self._canvas.delete("inner_oval_3_a", "inner_oval_3_b") + + if not self._canvas.find_withtag("inner_oval_4_a") and height - (2 * border_width) > 2 * inner_corner_radius and "inner_oval_4" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_4_a", "inner_corner_part", "inner_parts_left", "inner_parts", "left_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_4_b", "inner_corner_part", "inner_parts_left", "inner_parts", "left_parts"), anchor=tkinter.CENTER, + angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("inner_oval_4_a") and (not height - (2 * border_width) > 2 * inner_corner_radius or "inner_oval_4" in exclude_parts): + self._canvas.delete("inner_oval_4_a", "inner_oval_4_b") + + # change position of border corner parts + self._canvas.coords("inner_oval_1_a", border_width + inner_corner_radius, border_width + inner_corner_radius, inner_corner_radius) + self._canvas.coords("inner_oval_1_b", border_width + inner_corner_radius, border_width + inner_corner_radius, inner_corner_radius) + self._canvas.coords("inner_oval_2_a", width - border_width - inner_corner_radius, border_width + inner_corner_radius, inner_corner_radius) + self._canvas.coords("inner_oval_2_b", width - border_width - inner_corner_radius, border_width + inner_corner_radius, inner_corner_radius) + self._canvas.coords("inner_oval_3_a", width - border_width - inner_corner_radius, height - border_width - inner_corner_radius, inner_corner_radius) + self._canvas.coords("inner_oval_3_b", width - border_width - inner_corner_radius, height - border_width - inner_corner_radius, inner_corner_radius) + self._canvas.coords("inner_oval_4_a", border_width + inner_corner_radius, height - border_width - inner_corner_radius, inner_corner_radius) + self._canvas.coords("inner_oval_4_b", border_width + inner_corner_radius, height - border_width - inner_corner_radius, inner_corner_radius) + else: + self._canvas.delete("inner_corner_part") # delete inner corner parts if not needed + + # create canvas inner rectangle parts if not already created + if not self._canvas.find_withtag("inner_rectangle_1"): + self._canvas.create_rectangle(0, 0, 0, 0, tags=("inner_rectangle_left_1", "inner_rectangle_part", "inner_parts_left", "inner_parts", "left_parts"), width=0) + self._canvas.create_rectangle(0, 0, 0, 0, tags=("inner_rectangle_right_1", "inner_rectangle_part", "inner_parts_right", "inner_parts", "right_parts"), width=0) + requires_recoloring = True + + if not self._canvas.find_withtag("inner_rectangle_2") and inner_corner_radius * 2 < height - (border_width * 2): + self._canvas.create_rectangle(0, 0, 0, 0, tags=("inner_rectangle_left_2", "inner_rectangle_part", "inner_parts_left", "inner_parts", "left_parts"), width=0) + self._canvas.create_rectangle(0, 0, 0, 0, tags=("inner_rectangle_right_2", "inner_rectangle_part", "inner_parts_right", "inner_parts", "right_parts"), width=0) + requires_recoloring = True + + elif self._canvas.find_withtag("inner_rectangle_2") and not inner_corner_radius * 2 < height - (border_width * 2): + self._canvas.delete("inner_rectangle_left_2") + self._canvas.delete("inner_rectangle_right_2") + + # change position of inner rectangle parts + self._canvas.coords("inner_rectangle_left_1", (border_width + inner_corner_radius, + border_width, + left_section_width, + height - border_width)) + self._canvas.coords("inner_rectangle_left_2", (border_width, + border_width + inner_corner_radius, + left_section_width, + height - inner_corner_radius - border_width)) + self._canvas.coords("inner_rectangle_right_1", (left_section_width, + border_width, + width - border_width - inner_corner_radius, + height - border_width)) + self._canvas.coords("inner_rectangle_right_2", (left_section_width, + border_width + inner_corner_radius, + width - border_width, + height - inner_corner_radius - border_width)) + + if requires_recoloring: # new parts were added -> manage z-order + self._canvas.tag_lower("inner_parts") + self._canvas.tag_lower("border_parts") + self._canvas.tag_lower("background_parts") + + return requires_recoloring + + def draw_rounded_progress_bar_with_border(self, width: Union[float, int], height: Union[float, int], corner_radius: Union[float, int], + border_width: Union[float, int], progress_value_1: float, progress_value_2: float, orientation: str) -> bool: + """ Draws a rounded bar on the canvas, and onntop sits a progress bar from value 1 to value 2 (range 0-1, left to right, bottom to top). + The border elements get the 'border_parts' tag", the main elements get the 'inner_parts' tag and + the progress elements get the 'progress_parts' tag. The 'orientation' argument defines from which direction the progress starts (n, w, s, e). + + returns bool if recoloring is necessary """ + + if self._round_width_to_even_numbers: + width = math.floor(width / 2) * 2 # round _current_width and _current_height and restrict them to even values only + if self._round_height_to_even_numbers: + height = math.floor(height / 2) * 2 + + if corner_radius > width / 2 or corner_radius > height / 2: # restrict corner_radius if it's too larger + corner_radius = min(width / 2, height / 2) + + border_width = round(border_width) + corner_radius = self.__calc_optimal_corner_radius(corner_radius) # optimize corner_radius for different drawing methods (different rounding) + + if corner_radius >= border_width: + inner_corner_radius = corner_radius - border_width + else: + inner_corner_radius = 0 + + if self.preferred_drawing_method == "polygon_shapes" or self.preferred_drawing_method == "circle_shapes": + return self.__draw_rounded_progress_bar_with_border_polygon_shapes(width, height, corner_radius, border_width, inner_corner_radius, + progress_value_1, progress_value_2, orientation) + elif self.preferred_drawing_method == "font_shapes": + return self.__draw_rounded_progress_bar_with_border_font_shapes(width, height, corner_radius, border_width, inner_corner_radius, + progress_value_1, progress_value_2, orientation) + + def __draw_rounded_progress_bar_with_border_polygon_shapes(self, width: int, height: int, corner_radius: int, border_width: int, inner_corner_radius: int, + progress_value_1: float, progress_value_2: float, orientation: str) -> bool: + + requires_recoloring = self.__draw_rounded_rect_with_border_polygon_shapes(width, height, corner_radius, border_width, inner_corner_radius) + + if corner_radius <= border_width: + bottom_right_shift = 0 # weird canvas rendering inaccuracy that has to be corrected in some cases + else: + bottom_right_shift = 0 + + # create progress parts + if not self._canvas.find_withtag("progress_parts"): + self._canvas.create_polygon((0, 0, 0, 0), tags=("progress_line_1", "progress_parts"), joinstyle=tkinter.ROUND) + self._canvas.tag_raise("progress_parts", "inner_parts") + requires_recoloring = True + + if orientation == "w": + self._canvas.coords("progress_line_1", + border_width + inner_corner_radius + (width - 2 * border_width - 2 * inner_corner_radius) * progress_value_1, + border_width + inner_corner_radius, + border_width + inner_corner_radius + (width - 2 * border_width - 2 * inner_corner_radius) * progress_value_2, + border_width + inner_corner_radius, + border_width + inner_corner_radius + (width - 2 * border_width - 2 * inner_corner_radius) * progress_value_2, + height - (border_width + inner_corner_radius) + bottom_right_shift, + border_width + inner_corner_radius + (width - 2 * border_width - 2 * inner_corner_radius) * progress_value_1, + height - (border_width + inner_corner_radius) + bottom_right_shift) + + elif orientation == "s": + self._canvas.coords("progress_line_1", + border_width + inner_corner_radius, + border_width + inner_corner_radius + (height - 2 * border_width - 2 * inner_corner_radius) * (1 - progress_value_2), + width - (border_width + inner_corner_radius), + border_width + inner_corner_radius + (height - 2 * border_width - 2 * inner_corner_radius) * (1 - progress_value_2), + width - (border_width + inner_corner_radius), + border_width + inner_corner_radius + (height - 2 * border_width - 2 * inner_corner_radius) * (1 - progress_value_1), + border_width + inner_corner_radius, + border_width + inner_corner_radius + (height - 2 * border_width - 2 * inner_corner_radius) * (1 - progress_value_1)) + + self._canvas.itemconfig("progress_line_1", width=inner_corner_radius * 2) + + return requires_recoloring + + def __draw_rounded_progress_bar_with_border_font_shapes(self, width: int, height: int, corner_radius: int, border_width: int, inner_corner_radius: int, + progress_value_1: float, progress_value_2: float, orientation: str) -> bool: + + requires_recoloring, requires_recoloring_2 = False, False + + if inner_corner_radius > 0: + # create canvas border corner parts if not already created + if not self._canvas.find_withtag("progress_oval_1_a"): + self._canvas.create_aa_circle(0, 0, 0, tags=("progress_oval_1_a", "progress_corner_part", "progress_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("progress_oval_1_b", "progress_corner_part", "progress_parts"), anchor=tkinter.CENTER, angle=180) + self._canvas.create_aa_circle(0, 0, 0, tags=("progress_oval_2_a", "progress_corner_part", "progress_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("progress_oval_2_b", "progress_corner_part", "progress_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + + if not self._canvas.find_withtag("progress_oval_3_a") and round(inner_corner_radius) * 2 < height - 2 * border_width: + self._canvas.create_aa_circle(0, 0, 0, tags=("progress_oval_3_a", "progress_corner_part", "progress_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("progress_oval_3_b", "progress_corner_part", "progress_parts"), anchor=tkinter.CENTER, angle=180) + self._canvas.create_aa_circle(0, 0, 0, tags=("progress_oval_4_a", "progress_corner_part", "progress_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("progress_oval_4_b", "progress_corner_part", "progress_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("progress_oval_3_a") and not round(inner_corner_radius) * 2 < height - 2 * border_width: + self._canvas.delete("progress_oval_3_a", "progress_oval_3_b", "progress_oval_4_a", "progress_oval_4_b") + + if not self._canvas.find_withtag("progress_rectangle_1"): + self._canvas.create_rectangle(0, 0, 0, 0, tags=("progress_rectangle_1", "progress_rectangle_part", "progress_parts"), width=0) + requires_recoloring = True + + if not self._canvas.find_withtag("progress_rectangle_2") and inner_corner_radius * 2 < height - (border_width * 2): + self._canvas.create_rectangle(0, 0, 0, 0, tags=("progress_rectangle_2", "progress_rectangle_part", "progress_parts"), width=0) + requires_recoloring = True + elif self._canvas.find_withtag("progress_rectangle_2") and not inner_corner_radius * 2 < height - (border_width * 2): + self._canvas.delete("progress_rectangle_2") + + # horizontal orientation from the bottom + if orientation == "w": + requires_recoloring_2 = self.__draw_rounded_rect_with_border_font_shapes(width, height, corner_radius, border_width, inner_corner_radius, + ()) + + # set positions of progress corner parts + self._canvas.coords("progress_oval_1_a", border_width + inner_corner_radius + (width - 2 * border_width - 2 * inner_corner_radius) * progress_value_1, + border_width + inner_corner_radius, inner_corner_radius) + self._canvas.coords("progress_oval_1_b", border_width + inner_corner_radius + (width - 2 * border_width - 2 * inner_corner_radius) * progress_value_1, + border_width + inner_corner_radius, inner_corner_radius) + self._canvas.coords("progress_oval_2_a", border_width + inner_corner_radius + (width - 2 * border_width - 2 * inner_corner_radius) * progress_value_2, + border_width + inner_corner_radius, inner_corner_radius) + self._canvas.coords("progress_oval_2_b", border_width + inner_corner_radius + (width - 2 * border_width - 2 * inner_corner_radius) * progress_value_2, + border_width + inner_corner_radius, inner_corner_radius) + self._canvas.coords("progress_oval_3_a", border_width + inner_corner_radius + (width - 2 * border_width - 2 * inner_corner_radius) * progress_value_2, + height - border_width - inner_corner_radius, inner_corner_radius) + self._canvas.coords("progress_oval_3_b", border_width + inner_corner_radius + (width - 2 * border_width - 2 * inner_corner_radius) * progress_value_2, + height - border_width - inner_corner_radius, inner_corner_radius) + self._canvas.coords("progress_oval_4_a", border_width + inner_corner_radius + (width - 2 * border_width - 2 * inner_corner_radius) * progress_value_1, + height - border_width - inner_corner_radius, inner_corner_radius) + self._canvas.coords("progress_oval_4_b", border_width + inner_corner_radius + (width - 2 * border_width - 2 * inner_corner_radius) * progress_value_1, + height - border_width - inner_corner_radius, inner_corner_radius) + + # set positions of progress rect parts + self._canvas.coords("progress_rectangle_1", + border_width + inner_corner_radius + (width - 2 * border_width - 2 * inner_corner_radius) * progress_value_1, + border_width, + border_width + inner_corner_radius + (width - 2 * border_width - 2 * inner_corner_radius) * progress_value_2, + height - border_width) + self._canvas.coords("progress_rectangle_2", + border_width + 2 * inner_corner_radius + (width - 2 * inner_corner_radius - 2 * border_width) * progress_value_1, + border_width + inner_corner_radius, + border_width + 2 * inner_corner_radius + (width - 2 * inner_corner_radius - 2 * border_width) * progress_value_2, + height - inner_corner_radius - border_width) + + # vertical orientation from the bottom + if orientation == "s": + requires_recoloring_2 = self.__draw_rounded_rect_with_border_font_shapes(width, height, corner_radius, border_width, inner_corner_radius, + ()) + + # set positions of progress corner parts + self._canvas.coords("progress_oval_1_a", border_width + inner_corner_radius, + border_width + inner_corner_radius + (height - 2 * border_width - 2 * inner_corner_radius) * (1 - progress_value_2), inner_corner_radius) + self._canvas.coords("progress_oval_1_b", border_width + inner_corner_radius, + border_width + inner_corner_radius + (height - 2 * border_width - 2 * inner_corner_radius) * (1 - progress_value_2), inner_corner_radius) + self._canvas.coords("progress_oval_2_a", width - border_width - inner_corner_radius, + border_width + inner_corner_radius + (height - 2 * border_width - 2 * inner_corner_radius) * (1 - progress_value_2), inner_corner_radius) + self._canvas.coords("progress_oval_2_b", width - border_width - inner_corner_radius, + border_width + inner_corner_radius + (height - 2 * border_width - 2 * inner_corner_radius) * (1 - progress_value_2), inner_corner_radius) + self._canvas.coords("progress_oval_3_a", width - border_width - inner_corner_radius, + border_width + inner_corner_radius + (height - 2 * border_width - 2 * inner_corner_radius) * (1 - progress_value_1), inner_corner_radius) + self._canvas.coords("progress_oval_3_b", width - border_width - inner_corner_radius, + border_width + inner_corner_radius + (height - 2 * border_width - 2 * inner_corner_radius) * (1 - progress_value_1), inner_corner_radius) + self._canvas.coords("progress_oval_4_a", border_width + inner_corner_radius, + border_width + inner_corner_radius + (height - 2 * border_width - 2 * inner_corner_radius) * (1 - progress_value_1), inner_corner_radius) + self._canvas.coords("progress_oval_4_b", border_width + inner_corner_radius, + border_width + inner_corner_radius + (height - 2 * border_width - 2 * inner_corner_radius) * (1 - progress_value_1), inner_corner_radius) + + # set positions of progress rect parts + self._canvas.coords("progress_rectangle_1", + border_width + inner_corner_radius, + border_width + (height - 2 * border_width - 2 * inner_corner_radius) * (1 - progress_value_2), + width - border_width - inner_corner_radius, + border_width + (height - 2 * border_width - 2 * inner_corner_radius) * (1 - progress_value_1)) + self._canvas.coords("progress_rectangle_2", + border_width, + border_width + inner_corner_radius + (height - 2 * border_width - 2 * inner_corner_radius) * (1 - progress_value_2), + width - border_width, + border_width + inner_corner_radius + (height - 2 * border_width - 2 * inner_corner_radius) * (1 - progress_value_1)) + + return requires_recoloring or requires_recoloring_2 + + def draw_rounded_slider_with_border_and_button(self, width: Union[float, int], height: Union[float, int], corner_radius: Union[float, int], + border_width: Union[float, int], button_length: Union[float, int], button_corner_radius: Union[float, int], + slider_value: float, orientation: str) -> bool: + + if self._round_width_to_even_numbers: + width = math.floor(width / 2) * 2 # round _current_width and _current_height and restrict them to even values only + if self._round_height_to_even_numbers: + height = math.floor(height / 2) * 2 + + if corner_radius > width / 2 or corner_radius > height / 2: # restrict corner_radius if it's too larger + corner_radius = min(width / 2, height / 2) + + if button_corner_radius > width / 2 or button_corner_radius > height / 2: # restrict button_corner_radius if it's too larger + button_corner_radius = min(width / 2, height / 2) + + button_length = round(button_length) + border_width = round(border_width) + button_corner_radius = round(button_corner_radius) + corner_radius = self.__calc_optimal_corner_radius(corner_radius) # optimize corner_radius for different drawing methods (different rounding) + + if corner_radius >= border_width: + inner_corner_radius = corner_radius - border_width + else: + inner_corner_radius = 0 + + if self.preferred_drawing_method == "polygon_shapes" or self.preferred_drawing_method == "circle_shapes": + return self.__draw_rounded_slider_with_border_and_button_polygon_shapes(width, height, corner_radius, border_width, inner_corner_radius, + button_length, button_corner_radius, slider_value, orientation) + elif self.preferred_drawing_method == "font_shapes": + return self.__draw_rounded_slider_with_border_and_button_font_shapes(width, height, corner_radius, border_width, inner_corner_radius, + button_length, button_corner_radius, slider_value, orientation) + + def __draw_rounded_slider_with_border_and_button_polygon_shapes(self, width: int, height: int, corner_radius: int, border_width: int, inner_corner_radius: int, + button_length: int, button_corner_radius: int, slider_value: float, orientation: str) -> bool: + + # draw normal progressbar + requires_recoloring = self.__draw_rounded_progress_bar_with_border_polygon_shapes(width, height, corner_radius, border_width, inner_corner_radius, + 0, slider_value, orientation) + + # create slider button part + if not self._canvas.find_withtag("slider_parts"): + self._canvas.create_polygon((0, 0, 0, 0), tags=("slider_line_1", "slider_parts"), joinstyle=tkinter.ROUND) + self._canvas.tag_raise("slider_parts") # manage z-order + requires_recoloring = True + + if corner_radius <= border_width: + bottom_right_shift = -1 # weird canvas rendering inaccuracy that has to be corrected in some cases + else: + bottom_right_shift = 0 + + if orientation == "w": + slider_x_position = corner_radius + (button_length / 2) + (width - 2 * corner_radius - button_length) * slider_value + self._canvas.coords("slider_line_1", + slider_x_position - (button_length / 2), button_corner_radius, + slider_x_position + (button_length / 2), button_corner_radius, + slider_x_position + (button_length / 2), height - button_corner_radius, + slider_x_position - (button_length / 2), height - button_corner_radius) + self._canvas.itemconfig("slider_line_1", + width=button_corner_radius * 2) + elif orientation == "s": + slider_y_position = corner_radius + (button_length / 2) + (height - 2 * corner_radius - button_length) * (1 - slider_value) + self._canvas.coords("slider_line_1", + button_corner_radius, slider_y_position - (button_length / 2), + button_corner_radius, slider_y_position + (button_length / 2), + width - button_corner_radius, slider_y_position + (button_length / 2), + width - button_corner_radius, slider_y_position - (button_length / 2)) + self._canvas.itemconfig("slider_line_1", + width=button_corner_radius * 2) + + return requires_recoloring + + def __draw_rounded_slider_with_border_and_button_font_shapes(self, width: int, height: int, corner_radius: int, border_width: int, inner_corner_radius: int, + button_length: int, button_corner_radius: int, slider_value: float, orientation: str) -> bool: + + # draw normal progressbar + requires_recoloring = self.__draw_rounded_progress_bar_with_border_font_shapes(width, height, corner_radius, border_width, inner_corner_radius, + 0, slider_value, orientation) + + # create 4 circles (if not needed, then less) + if not self._canvas.find_withtag("slider_oval_1_a"): + self._canvas.create_aa_circle(0, 0, 0, tags=("slider_oval_1_a", "slider_corner_part", "slider_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("slider_oval_1_b", "slider_corner_part", "slider_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + + if not self._canvas.find_withtag("slider_oval_2_a") and button_length > 0: + self._canvas.create_aa_circle(0, 0, 0, tags=("slider_oval_2_a", "slider_corner_part", "slider_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("slider_oval_2_b", "slider_corner_part", "slider_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("slider_oval_2_a") and not button_length > 0: + self._canvas.delete("slider_oval_2_a", "slider_oval_2_b") + + if not self._canvas.find_withtag("slider_oval_4_a") and height > 2 * button_corner_radius: + self._canvas.create_aa_circle(0, 0, 0, tags=("slider_oval_4_a", "slider_corner_part", "slider_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("slider_oval_4_b", "slider_corner_part", "slider_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("slider_oval_4_a") and not height > 2 * button_corner_radius: + self._canvas.delete("slider_oval_4_a", "slider_oval_4_b") + + if not self._canvas.find_withtag("slider_oval_3_a") and button_length > 0 and height > 2 * button_corner_radius: + self._canvas.create_aa_circle(0, 0, 0, tags=("slider_oval_3_a", "slider_corner_part", "slider_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("slider_oval_3_b", "slider_corner_part", "slider_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("border_oval_3_a") and not (button_length > 0 and height > 2 * button_corner_radius): + self._canvas.delete("slider_oval_3_a", "slider_oval_3_b") + + # create the 2 rectangles (if needed) + if not self._canvas.find_withtag("slider_rectangle_1") and button_length > 0: + self._canvas.create_rectangle(0, 0, 0, 0, tags=("slider_rectangle_1", "slider_rectangle_part", "slider_parts"), width=0) + requires_recoloring = True + elif self._canvas.find_withtag("slider_rectangle_1") and not button_length > 0: + self._canvas.delete("slider_rectangle_1") + + if not self._canvas.find_withtag("slider_rectangle_2") and height > 2 * button_corner_radius: + self._canvas.create_rectangle(0, 0, 0, 0, tags=("slider_rectangle_2", "slider_rectangle_part", "slider_parts"), width=0) + requires_recoloring = True + elif self._canvas.find_withtag("slider_rectangle_2") and not height > 2 * button_corner_radius: + self._canvas.delete("slider_rectangle_2") + + # set positions of circles and rectangles + if orientation == "w": + slider_x_position = corner_radius + (button_length / 2) + (width - 2 * corner_radius - button_length) * slider_value + self._canvas.coords("slider_oval_1_a", slider_x_position - (button_length / 2), button_corner_radius, button_corner_radius) + self._canvas.coords("slider_oval_1_b", slider_x_position - (button_length / 2), button_corner_radius, button_corner_radius) + self._canvas.coords("slider_oval_2_a", slider_x_position + (button_length / 2), button_corner_radius, button_corner_radius) + self._canvas.coords("slider_oval_2_b", slider_x_position + (button_length / 2), button_corner_radius, button_corner_radius) + self._canvas.coords("slider_oval_3_a", slider_x_position + (button_length / 2), height - button_corner_radius, button_corner_radius) + self._canvas.coords("slider_oval_3_b", slider_x_position + (button_length / 2), height - button_corner_radius, button_corner_radius) + self._canvas.coords("slider_oval_4_a", slider_x_position - (button_length / 2), height - button_corner_radius, button_corner_radius) + self._canvas.coords("slider_oval_4_b", slider_x_position - (button_length / 2), height - button_corner_radius, button_corner_radius) + + self._canvas.coords("slider_rectangle_1", + slider_x_position - (button_length / 2), 0, + slider_x_position + (button_length / 2), height) + self._canvas.coords("slider_rectangle_2", + slider_x_position - (button_length / 2) - button_corner_radius, button_corner_radius, + slider_x_position + (button_length / 2) + button_corner_radius, height - button_corner_radius) + + elif orientation == "s": + slider_y_position = corner_radius + (button_length / 2) + (height - 2 * corner_radius - button_length) * (1 - slider_value) + self._canvas.coords("slider_oval_1_a", button_corner_radius, slider_y_position - (button_length / 2), button_corner_radius) + self._canvas.coords("slider_oval_1_b", button_corner_radius, slider_y_position - (button_length / 2), button_corner_radius) + self._canvas.coords("slider_oval_2_a", button_corner_radius, slider_y_position + (button_length / 2), button_corner_radius) + self._canvas.coords("slider_oval_2_b", button_corner_radius, slider_y_position + (button_length / 2), button_corner_radius) + self._canvas.coords("slider_oval_3_a", width - button_corner_radius, slider_y_position + (button_length / 2), button_corner_radius) + self._canvas.coords("slider_oval_3_b", width - button_corner_radius, slider_y_position + (button_length / 2), button_corner_radius) + self._canvas.coords("slider_oval_4_a", width - button_corner_radius, slider_y_position - (button_length / 2), button_corner_radius) + self._canvas.coords("slider_oval_4_b", width - button_corner_radius, slider_y_position - (button_length / 2), button_corner_radius) + + self._canvas.coords("slider_rectangle_1", + 0, slider_y_position - (button_length / 2), + width, slider_y_position + (button_length / 2)) + self._canvas.coords("slider_rectangle_2", + button_corner_radius, slider_y_position - (button_length / 2) - button_corner_radius, + width - button_corner_radius, slider_y_position + (button_length / 2) + button_corner_radius) + + if requires_recoloring: # new parts were added -> manage z-order + self._canvas.tag_raise("slider_parts") + + return requires_recoloring + + def draw_rounded_scrollbar(self, width: Union[float, int], height: Union[float, int], corner_radius: Union[float, int], + border_spacing: Union[float, int], start_value: float, end_value: float, orientation: str) -> bool: + + if self._round_width_to_even_numbers: + width = math.floor(width / 2) * 2 # round _current_width and _current_height and restrict them to even values only + if self._round_height_to_even_numbers: + height = math.floor(height / 2) * 2 + + if corner_radius > width / 2 or corner_radius > height / 2: # restrict corner_radius if it's too larger + corner_radius = min(width / 2, height / 2) + + border_spacing = round(border_spacing) + corner_radius = self.__calc_optimal_corner_radius(corner_radius) # optimize corner_radius for different drawing methods (different rounding) + + if corner_radius >= border_spacing: + inner_corner_radius = corner_radius - border_spacing + else: + inner_corner_radius = 0 + + if self.preferred_drawing_method == "polygon_shapes" or self.preferred_drawing_method == "circle_shapes": + return self.__draw_rounded_scrollbar_polygon_shapes(width, height, corner_radius, inner_corner_radius, + start_value, end_value, orientation) + elif self.preferred_drawing_method == "font_shapes": + return self.__draw_rounded_scrollbar_font_shapes(width, height, corner_radius, inner_corner_radius, + start_value, end_value, orientation) + + def __draw_rounded_scrollbar_polygon_shapes(self, width: int, height: int, corner_radius: int, inner_corner_radius: int, + start_value: float, end_value: float, orientation: str) -> bool: + requires_recoloring = False + + if not self._canvas.find_withtag("border_parts"): + self._canvas.create_rectangle(0, 0, 0, 0, tags=("border_rectangle_1", "border_parts"), width=0) + requires_recoloring = True + self._canvas.coords("border_rectangle_1", 0, 0, width, height) + + if not self._canvas.find_withtag("scrollbar_parts"): + self._canvas.create_polygon((0, 0, 0, 0), tags=("scrollbar_polygon_1", "scrollbar_parts"), joinstyle=tkinter.ROUND) + self._canvas.tag_raise("scrollbar_parts", "border_parts") + requires_recoloring = True + + if orientation == "vertical": + self._canvas.coords("scrollbar_polygon_1", + corner_radius, corner_radius + (height - 2 * corner_radius) * start_value, + width - corner_radius, corner_radius + (height - 2 * corner_radius) * start_value, + width - corner_radius, corner_radius + (height - 2 * corner_radius) * end_value, + corner_radius, corner_radius + (height - 2 * corner_radius) * end_value) + elif orientation == "horizontal": + self._canvas.coords("scrollbar_polygon_1", + corner_radius + (width - 2 * corner_radius) * start_value, corner_radius, + corner_radius + (width - 2 * corner_radius) * end_value, corner_radius, + corner_radius + (width - 2 * corner_radius) * end_value, height - corner_radius, + corner_radius + (width - 2 * corner_radius) * start_value, height - corner_radius,) + + self._canvas.itemconfig("scrollbar_polygon_1", width=inner_corner_radius * 2) + + return requires_recoloring + + def __draw_rounded_scrollbar_font_shapes(self, width: int, height: int, corner_radius: int, inner_corner_radius: int, + start_value: float, end_value: float, orientation: str) -> bool: + requires_recoloring = False + + if not self._canvas.find_withtag("border_parts"): + self._canvas.create_rectangle(0, 0, 0, 0, tags=("border_rectangle_1", "border_parts"), width=0) + requires_recoloring = True + self._canvas.coords("border_rectangle_1", 0, 0, width, height) + + if inner_corner_radius > 0: + if not self._canvas.find_withtag("scrollbar_oval_1_a"): + self._canvas.create_aa_circle(0, 0, 0, tags=("scrollbar_oval_1_a", "scrollbar_corner_part", "scrollbar_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("scrollbar_oval_1_b", "scrollbar_corner_part", "scrollbar_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + + if not self._canvas.find_withtag("scrollbar_oval_2_a") and width > 2 * corner_radius: + self._canvas.create_aa_circle(0, 0, 0, tags=("scrollbar_oval_2_a", "scrollbar_corner_part", "scrollbar_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("scrollbar_oval_2_b", "scrollbar_corner_part", "scrollbar_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("scrollbar_oval_2_a") and not width > 2 * corner_radius: + self._canvas.delete("scrollbar_oval_2_a", "scrollbar_oval_2_b") + + if not self._canvas.find_withtag("scrollbar_oval_3_a") and height > 2 * corner_radius and width > 2 * corner_radius: + self._canvas.create_aa_circle(0, 0, 0, tags=("scrollbar_oval_3_a", "scrollbar_corner_part", "scrollbar_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("scrollbar_oval_3_b", "scrollbar_corner_part", "scrollbar_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("scrollbar_oval_3_a") and not (height > 2 * corner_radius and width > 2 * corner_radius): + self._canvas.delete("scrollbar_oval_3_a", "scrollbar_oval_3_b") + + if not self._canvas.find_withtag("scrollbar_oval_4_a") and height > 2 * corner_radius: + self._canvas.create_aa_circle(0, 0, 0, tags=("scrollbar_oval_4_a", "scrollbar_corner_part", "scrollbar_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("scrollbar_oval_4_b", "scrollbar_corner_part", "scrollbar_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("scrollbar_oval_4_a") and not height > 2 * corner_radius: + self._canvas.delete("scrollbar_oval_4_a", "scrollbar_oval_4_b") + else: + self._canvas.delete("scrollbar_corner_part") + + if not self._canvas.find_withtag("scrollbar_rectangle_1") and height > 2 * corner_radius: + self._canvas.create_rectangle(0, 0, 0, 0, tags=("scrollbar_rectangle_1", "scrollbar_rectangle_part", "scrollbar_parts"), width=0) + requires_recoloring = True + elif self._canvas.find_withtag("scrollbar_rectangle_1") and not height > 2 * corner_radius: + self._canvas.delete("scrollbar_rectangle_1") + + if not self._canvas.find_withtag("scrollbar_rectangle_2") and width > 2 * corner_radius: + self._canvas.create_rectangle(0, 0, 0, 0, tags=("scrollbar_rectangle_2", "scrollbar_rectangle_part", "scrollbar_parts"), width=0) + requires_recoloring = True + elif self._canvas.find_withtag("scrollbar_rectangle_2") and not width > 2 * corner_radius: + self._canvas.delete("scrollbar_rectangle_2") + + if orientation == "vertical": + self._canvas.coords("scrollbar_rectangle_1", + corner_radius - inner_corner_radius, corner_radius + (height - 2 * corner_radius) * start_value, + width - (corner_radius - inner_corner_radius), corner_radius + (height - 2 * corner_radius) * end_value) + self._canvas.coords("scrollbar_rectangle_2", + corner_radius, corner_radius - inner_corner_radius + (height - 2 * corner_radius) * start_value, + width - (corner_radius), corner_radius + inner_corner_radius + (height - 2 * corner_radius) * end_value) + + self._canvas.coords("scrollbar_oval_1_a", corner_radius, corner_radius + (height - 2 * corner_radius) * start_value, inner_corner_radius) + self._canvas.coords("scrollbar_oval_1_b", corner_radius, corner_radius + (height - 2 * corner_radius) * start_value, inner_corner_radius) + self._canvas.coords("scrollbar_oval_2_a", width - corner_radius, corner_radius + (height - 2 * corner_radius) * start_value, inner_corner_radius) + self._canvas.coords("scrollbar_oval_2_b", width - corner_radius, corner_radius + (height - 2 * corner_radius) * start_value, inner_corner_radius) + self._canvas.coords("scrollbar_oval_3_a", width - corner_radius, corner_radius + (height - 2 * corner_radius) * end_value, inner_corner_radius) + self._canvas.coords("scrollbar_oval_3_b", width - corner_radius, corner_radius + (height - 2 * corner_radius) * end_value, inner_corner_radius) + self._canvas.coords("scrollbar_oval_4_a", corner_radius, corner_radius + (height - 2 * corner_radius) * end_value, inner_corner_radius) + self._canvas.coords("scrollbar_oval_4_b", corner_radius, corner_radius + (height - 2 * corner_radius) * end_value, inner_corner_radius) + + if orientation == "horizontal": + self._canvas.coords("scrollbar_rectangle_1", + corner_radius - inner_corner_radius + (width - 2 * corner_radius) * start_value, corner_radius, + corner_radius + inner_corner_radius + (width - 2 * corner_radius) * end_value, height - corner_radius) + self._canvas.coords("scrollbar_rectangle_2", + corner_radius + (width - 2 * corner_radius) * start_value, corner_radius - inner_corner_radius, + corner_radius + (width - 2 * corner_radius) * end_value, height - (corner_radius - inner_corner_radius)) + + self._canvas.coords("scrollbar_oval_1_a", corner_radius + (width - 2 * corner_radius) * start_value, corner_radius, inner_corner_radius) + self._canvas.coords("scrollbar_oval_1_b", corner_radius + (width - 2 * corner_radius) * start_value, corner_radius, inner_corner_radius) + self._canvas.coords("scrollbar_oval_2_a", corner_radius + (width - 2 * corner_radius) * end_value, corner_radius, inner_corner_radius) + self._canvas.coords("scrollbar_oval_2_b", corner_radius + (width - 2 * corner_radius) * end_value, corner_radius, inner_corner_radius) + self._canvas.coords("scrollbar_oval_3_a", corner_radius + (width - 2 * corner_radius) * end_value, height - corner_radius, inner_corner_radius) + self._canvas.coords("scrollbar_oval_3_b", corner_radius + (width - 2 * corner_radius) * end_value, height - corner_radius, inner_corner_radius) + self._canvas.coords("scrollbar_oval_4_a", corner_radius + (width - 2 * corner_radius) * start_value, height - corner_radius, inner_corner_radius) + self._canvas.coords("scrollbar_oval_4_b", corner_radius + (width - 2 * corner_radius) * start_value, height - corner_radius, inner_corner_radius) + + return requires_recoloring + + def draw_checkmark(self, width: Union[float, int], height: Union[float, int], size: Union[int, float]) -> bool: + """ Draws a rounded rectangle with a corner_radius and border_width on the canvas. The border elements have a 'border_parts' tag, + the main foreground elements have an 'inner_parts' tag to color the elements accordingly. + + returns bool if recoloring is necessary """ + + size = round(size) + requires_recoloring = False + + if self.preferred_drawing_method == "polygon_shapes" or self.preferred_drawing_method == "circle_shapes": + x, y, radius = width / 2, height / 2, size / 2.8 + if not self._canvas.find_withtag("checkmark"): + self._canvas.create_line(0, 0, 0, 0, tags=("checkmark", "create_line"), width=round(height / 8), joinstyle=tkinter.MITER, capstyle=tkinter.ROUND) + self._canvas.tag_raise("checkmark") + requires_recoloring = True + + self._canvas.coords("checkmark", + x + radius, y - radius, + x - radius / 4, y + radius * 0.8, + x - radius, y + radius / 6) + elif self.preferred_drawing_method == "font_shapes": + if not self._canvas.find_withtag("checkmark"): + self._canvas.create_text(0, 0, text="Z", font=("CustomTkinter_shapes_font", -size), tags=("checkmark", "create_text"), anchor=tkinter.CENTER) + self._canvas.tag_raise("checkmark") + requires_recoloring = True + + self._canvas.coords("checkmark", round(width / 2), round(height / 2)) + + return requires_recoloring + + def draw_dropdown_arrow(self, x_position: Union[int, float], y_position: Union[int, float], size: Union[int, float]) -> bool: + """ Draws a dropdown bottom facing arrow at (x_position, y_position) in a given size + + returns bool if recoloring is necessary """ + + x_position, y_position, size = round(x_position), round(y_position), round(size) + requires_recoloring = False + + if self.preferred_drawing_method == "polygon_shapes" or self.preferred_drawing_method == "circle_shapes": + if not self._canvas.find_withtag("dropdown_arrow"): + self._canvas.create_line(0, 0, 0, 0, tags="dropdown_arrow", width=round(size / 3), joinstyle=tkinter.ROUND, capstyle=tkinter.ROUND) + self._canvas.tag_raise("dropdown_arrow") + requires_recoloring = True + + self._canvas.coords("dropdown_arrow", + x_position - (size / 2), + y_position - (size / 5), + x_position, + y_position + (size / 5), + x_position + (size / 2), + y_position - (size / 5)) + + elif self.preferred_drawing_method == "font_shapes": + if not self._canvas.find_withtag("dropdown_arrow"): + self._canvas.create_text(0, 0, text="Y", font=("CustomTkinter_shapes_font", -size), tags="dropdown_arrow", anchor=tkinter.CENTER) + self._canvas.tag_raise("dropdown_arrow") + requires_recoloring = True + + self._canvas.itemconfigure("dropdown_arrow", font=("CustomTkinter_shapes_font", -size)) + self._canvas.coords("dropdown_arrow", x_position, y_position) + + return requires_recoloring diff --git a/customtkinter/windows/widgets/core_widget_classes/__init__.py b/customtkinter/windows/widgets/core_widget_classes/__init__.py new file mode 100644 index 0000000..75e2d84 --- /dev/null +++ b/customtkinter/windows/widgets/core_widget_classes/__init__.py @@ -0,0 +1,2 @@ +from .dropdown_menu import DropdownMenu +from .ctk_base_class import CTkBaseClass diff --git a/customtkinter/windows/widgets/core_widget_classes/__pycache__/__init__.cpython-310.pyc b/customtkinter/windows/widgets/core_widget_classes/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..3550f89 Binary files /dev/null and b/customtkinter/windows/widgets/core_widget_classes/__pycache__/__init__.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/core_widget_classes/__pycache__/ctk_base_class.cpython-310.pyc b/customtkinter/windows/widgets/core_widget_classes/__pycache__/ctk_base_class.cpython-310.pyc new file mode 100644 index 0000000..a056c52 Binary files /dev/null and b/customtkinter/windows/widgets/core_widget_classes/__pycache__/ctk_base_class.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/core_widget_classes/__pycache__/dropdown_menu.cpython-310.pyc b/customtkinter/windows/widgets/core_widget_classes/__pycache__/dropdown_menu.cpython-310.pyc new file mode 100644 index 0000000..c3ad3ea Binary files /dev/null and b/customtkinter/windows/widgets/core_widget_classes/__pycache__/dropdown_menu.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/core_widget_classes/ctk_base_class.py b/customtkinter/windows/widgets/core_widget_classes/ctk_base_class.py new file mode 100644 index 0000000..a75ab01 --- /dev/null +++ b/customtkinter/windows/widgets/core_widget_classes/ctk_base_class.py @@ -0,0 +1,325 @@ +import sys +import warnings +import tkinter +import tkinter.ttk as ttk +from typing import Union, Callable, Tuple + +try: + from typing import TypedDict +except ImportError: + from typing_extensions import TypedDict + +# removed due to circular import +# from ...ctk_tk import CTk +# from ...ctk_toplevel import CTkToplevel +from .... import windows # import windows for isinstance checks + +from ..theme import ThemeManager +from ..font import CTkFont +from ..image import CTkImage +from ..appearance_mode import CTkAppearanceModeBaseClass +from ..scaling import CTkScalingBaseClass + +from ..utility import pop_from_dict_by_set, check_kwargs_empty + + +class CTkBaseClass(tkinter.Frame, CTkAppearanceModeBaseClass, CTkScalingBaseClass): + """ Base class of every CTk widget, handles the dimensions, bg_color, + appearance_mode changes, scaling, bg changes of master if master is not a CTk widget """ + + # attributes that are passed to and managed by the tkinter frame only: + _valid_tk_frame_attributes: set = {"cursor"} + + _cursor_manipulation_enabled: bool = True + + def __init__(self, + master: any, + width: int = 0, + height: int = 0, + + bg_color: Union[str, Tuple[str, str]] = "transparent", + **kwargs): + + # call init methods of super classes + tkinter.Frame.__init__(self, master=master, width=width, height=height, **pop_from_dict_by_set(kwargs, self._valid_tk_frame_attributes)) + CTkAppearanceModeBaseClass.__init__(self) + CTkScalingBaseClass.__init__(self, scaling_type="widget") + + # check if kwargs is empty, if not raise error for unsupported arguments + check_kwargs_empty(kwargs, raise_error=True) + + # dimensions independent of scaling + self._current_width = width # _current_width and _current_height in pixel, represent current size of the widget + self._current_height = height # _current_width and _current_height are independent of the scale + self._desired_width = width # _desired_width and _desired_height, represent desired size set by width and height + self._desired_height = height + + # set width and height of tkinter.Frame + super().configure(width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height)) + + # save latest geometry function and kwargs + class GeometryCallDict(TypedDict): + function: Callable + kwargs: dict + self._last_geometry_manager_call: Union[GeometryCallDict, None] = None + + # background color + self._bg_color: Union[str, Tuple[str, str]] = self._detect_color_of_master() if bg_color == "transparent" else self._check_color_type(bg_color, transparency=True) + + # set bg color of tkinter.Frame + super().configure(bg=self._apply_appearance_mode(self._bg_color)) + + # add configure callback to tkinter.Frame + super().bind('', self._update_dimensions_event) + + # overwrite configure methods of master when master is tkinter widget, so that bg changes get applied on child CTk widget as well + if isinstance(self.master, (tkinter.Tk, tkinter.Toplevel, tkinter.Frame, tkinter.LabelFrame, ttk.Frame, ttk.LabelFrame, ttk.Notebook)) and not isinstance(self.master, CTkBaseClass): + master_old_configure = self.master.config + + def new_configure(*args, **kwargs): + if "bg" in kwargs: + self.configure(bg_color=kwargs["bg"]) + elif "background" in kwargs: + self.configure(bg_color=kwargs["background"]) + + # args[0] is dict when attribute gets changed by widget[] syntax + elif len(args) > 0 and type(args[0]) == dict: + if "bg" in args[0]: + self.configure(bg_color=args[0]["bg"]) + elif "background" in args[0]: + self.configure(bg_color=args[0]["background"]) + master_old_configure(*args, **kwargs) + + self.master.config = new_configure + self.master.configure = new_configure + + def destroy(self): + """ Destroy this and all descendants widgets. """ + + # call destroy methods of super classes + tkinter.Frame.destroy(self) + CTkAppearanceModeBaseClass.destroy(self) + CTkScalingBaseClass.destroy(self) + + def _draw(self, no_color_updates: bool = False): + """ can be overridden but super method must be called """ + if no_color_updates is False: + # Configuring color of tkinter.Frame not necessary at the moment? + # Causes flickering on Windows and Linux for segmented button for some reason! + # super().configure(bg=self._apply_appearance_mode(self._bg_color)) + pass + + def config(self, *args, **kwargs): + raise AttributeError("'config' is not implemented for CTk widgets. For consistency, always use 'configure' instead.") + + def configure(self, require_redraw=False, **kwargs): + """ basic configure with bg_color, width, height support, calls configure of tkinter.Frame, updates in the end """ + + if "width" in kwargs: + self._set_dimensions(width=kwargs.pop("width")) + + if "height" in kwargs: + self._set_dimensions(height=kwargs.pop("height")) + + if "bg_color" in kwargs: + new_bg_color = self._check_color_type(kwargs.pop("bg_color"), transparency=True) + if new_bg_color == "transparent": + self._bg_color = self._detect_color_of_master() + else: + self._bg_color = self._check_color_type(new_bg_color) + require_redraw = True + + super().configure(**pop_from_dict_by_set(kwargs, self._valid_tk_frame_attributes)) # configure tkinter.Frame + + # if there are still items in the kwargs dict, raise ValueError + check_kwargs_empty(kwargs, raise_error=True) + + if require_redraw: + self._draw() + + def cget(self, attribute_name: str): + """ basic cget with bg_color, width, height support, calls cget of tkinter.Frame """ + + if attribute_name == "bg_color": + return self._bg_color + elif attribute_name == "width": + return self._desired_width + elif attribute_name == "height": + return self._desired_height + + elif attribute_name in self._valid_tk_frame_attributes: + return super().cget(attribute_name) # cget of tkinter.Frame + else: + raise ValueError(f"'{attribute_name}' is not a supported argument. Look at the documentation for supported arguments.") + + def _check_font_type(self, font: any): + """ check font type when passed to widget """ + if isinstance(font, CTkFont): + return font + + elif type(font) == tuple and len(font) == 1: + warnings.warn(f"{type(self).__name__} Warning: font {font} given without size, will be extended with default text size of current theme\n") + return font[0], ThemeManager.theme["text"]["size"] + + elif type(font) == tuple and 2 <= len(font) <= 6: + return font + + else: + raise ValueError(f"Wrong font type {type(font)}\n" + + f"For consistency, Customtkinter requires the font argument to be a tuple of len 2 to 6 or an instance of CTkFont.\n" + + f"\nUsage example:\n" + + f"font=customtkinter.CTkFont(family='', size=)\n" + + f"font=('', )\n") + + def _check_image_type(self, image: any): + """ check image type when passed to widget """ + if image is None: + return image + elif isinstance(image, CTkImage): + return image + else: + warnings.warn(f"{type(self).__name__} Warning: Given image is not CTkImage but {type(image)}. " + + f"Image can not be scaled on HighDPI displays, use CTkImage instead.\n") + return image + + def _update_dimensions_event(self, event): + # only redraw if dimensions changed (for performance), independent of scaling + if round(self._current_width) != round(self._reverse_widget_scaling(event.width)) or round(self._current_height) != round(self._reverse_widget_scaling(event.height)): + self._current_width = self._reverse_widget_scaling(event.width) # adjust current size according to new size given by event + self._current_height = self._reverse_widget_scaling(event.height) # _current_width and _current_height are independent of the scale + + self._draw(no_color_updates=True) # faster drawing without color changes + + def _detect_color_of_master(self, master_widget=None) -> Union[str, Tuple[str, str]]: + """ detect foreground color of master widget for bg_color and transparent color """ + + if master_widget is None: + master_widget = self.master + + if isinstance(master_widget, (windows.widgets.core_widget_classes.CTkBaseClass, windows.CTk, windows.CTkToplevel)): + if master_widget.cget("fg_color") is not None and master_widget.cget("fg_color") != "transparent": + return master_widget.cget("fg_color") + + # if fg_color of master is None, try to retrieve fg_color from master of master + elif hasattr(master_widget.master, "master"): + return self._detect_color_of_master(master_widget.master) + + elif isinstance(master_widget, (ttk.Frame, ttk.LabelFrame, ttk.Notebook, ttk.Label)): # master is ttk widget + try: + ttk_style = ttk.Style() + return ttk_style.lookup(master_widget.winfo_class(), 'background') + except Exception: + return "#FFFFFF", "#000000" + + else: # master is normal tkinter widget + try: + return master_widget.cget("bg") # try to get bg color by .cget() method + except Exception: + return "#FFFFFF", "#000000" + + def _set_appearance_mode(self, mode_string): + super()._set_appearance_mode(mode_string) + self._draw() + super().update_idletasks() + + def _set_scaling(self, new_widget_scaling, new_window_scaling): + super()._set_scaling(new_widget_scaling, new_window_scaling) + + super().configure(width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height)) + + if self._last_geometry_manager_call is not None: + self._last_geometry_manager_call["function"](**self._apply_argument_scaling(self._last_geometry_manager_call["kwargs"])) + + def _set_dimensions(self, width=None, height=None): + if width is not None: + self._desired_width = width + if height is not None: + self._desired_height = height + + super().configure(width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height)) + + def bind(self, sequence=None, command=None, add=None): + raise NotImplementedError + + def unbind(self, sequence=None, funcid=None): + raise NotImplementedError + + def unbind_all(self, sequence): + raise AttributeError("'unbind_all' is not allowed, because it would delete necessary internal callbacks for all widgets") + + def bind_all(self, sequence=None, func=None, add=None): + raise AttributeError("'bind_all' is not allowed, could result in undefined behavior") + + def place(self, **kwargs): + """ + Place a widget in the parent widget. Use as options: + in=master - master relative to which the widget is placed + in_=master - see 'in' option description + x=amount - locate anchor of this widget at position x of master + y=amount - locate anchor of this widget at position y of master + relx=amount - locate anchor of this widget between 0.0 and 1.0 relative to width of master (1.0 is right edge) + rely=amount - locate anchor of this widget between 0.0 and 1.0 relative to height of master (1.0 is bottom edge) + anchor=NSEW (or subset) - position anchor according to given direction + width=amount - width of this widget in pixel + height=amount - height of this widget in pixel + relwidth=amount - width of this widget between 0.0 and 1.0 relative to width of master (1.0 is the same width as the master) + relheight=amount - height of this widget between 0.0 and 1.0 relative to height of master (1.0 is the same height as the master) + bordermode="inside" or "outside" - whether to take border width of master widget into account + """ + self._last_geometry_manager_call = {"function": super().place, "kwargs": kwargs} + return super().place(**self._apply_argument_scaling(kwargs)) + + def place_forget(self): + """ Unmap this widget. """ + self._last_geometry_manager_call = None + return super().place_forget() + + def pack(self, **kwargs): + """ + Pack a widget in the parent widget. Use as options: + after=widget - pack it after you have packed widget + anchor=NSEW (or subset) - position widget according to given direction + before=widget - pack it before you will pack widget + expand=bool - expand widget if parent size grows + fill=NONE or X or Y or BOTH - fill widget if widget grows + in=master - use master to contain this widget + in_=master - see 'in' option description + ipadx=amount - add internal padding in x direction + ipady=amount - add internal padding in y direction + padx=amount - add padding in x direction + pady=amount - add padding in y direction + side=TOP or BOTTOM or LEFT or RIGHT - where to add this widget. + """ + self._last_geometry_manager_call = {"function": super().pack, "kwargs": kwargs} + return super().pack(**self._apply_argument_scaling(kwargs)) + + def pack_forget(self): + """ Unmap this widget and do not use it for the packing order. """ + self._last_geometry_manager_call = None + return super().pack_forget() + + def grid(self, **kwargs): + """ + Position a widget in the parent widget in a grid. Use as options: + column=number - use cell identified with given column (starting with 0) + columnspan=number - this widget will span several columns + in=master - use master to contain this widget + in_=master - see 'in' option description + ipadx=amount - add internal padding in x direction + ipady=amount - add internal padding in y direction + padx=amount - add padding in x direction + pady=amount - add padding in y direction + row=number - use cell identified with given row (starting with 0) + rowspan=number - this widget will span several rows + sticky=NSEW - if cell is larger on which sides will this widget stick to the cell boundary + """ + self._last_geometry_manager_call = {"function": super().grid, "kwargs": kwargs} + return super().grid(**self._apply_argument_scaling(kwargs)) + + def grid_forget(self): + """ Unmap this widget. """ + self._last_geometry_manager_call = None + return super().grid_forget() diff --git a/customtkinter/windows/widgets/core_widget_classes/dropdown_menu.py b/customtkinter/windows/widgets/core_widget_classes/dropdown_menu.py new file mode 100644 index 0000000..a6b8186 --- /dev/null +++ b/customtkinter/windows/widgets/core_widget_classes/dropdown_menu.py @@ -0,0 +1,198 @@ +import tkinter +import sys +from typing import Union, Tuple, Callable, List, Optional + +from ..theme import ThemeManager +from ..font import CTkFont +from ..appearance_mode import CTkAppearanceModeBaseClass +from ..scaling import CTkScalingBaseClass + + +class DropdownMenu(tkinter.Menu, CTkAppearanceModeBaseClass, CTkScalingBaseClass): + def __init__(self, *args, + min_character_width: int = 18, + + fg_color: Optional[Union[str, Tuple[str, str]]] = None, + hover_color: Optional[Union[str, Tuple[str, str]]] = None, + text_color: Optional[Union[str, Tuple[str, str]]] = None, + + font: Optional[Union[tuple, CTkFont]] = None, + command: Union[Callable, None] = None, + values: Optional[List[str]] = None, + **kwargs): + + # call init methods of super classes + tkinter.Menu.__init__(self, *args, **kwargs) + CTkAppearanceModeBaseClass.__init__(self) + CTkScalingBaseClass.__init__(self, scaling_type="widget") + + self._min_character_width = min_character_width + self._fg_color = ThemeManager.theme["DropdownMenu"]["fg_color"] if fg_color is None else self._check_color_type(fg_color) + self._hover_color = ThemeManager.theme["DropdownMenu"]["hover_color"] if hover_color is None else self._check_color_type(hover_color) + self._text_color = ThemeManager.theme["DropdownMenu"]["text_color"] if text_color is None else self._check_color_type(text_color) + + # font + self._font = CTkFont() if font is None else self._check_font_type(font) + if isinstance(self._font, CTkFont): + self._font.add_size_configure_callback(self._update_font) + + self._configure_menu_for_platforms() + + self._values = values + self._command = command + + self._add_menu_commands() + + def destroy(self): + if isinstance(self._font, CTkFont): + self._font.remove_size_configure_callback(self._update_font) + + # call destroy methods of super classes + tkinter.Menu.destroy(self) + CTkAppearanceModeBaseClass.destroy(self) + + def _update_font(self): + """ pass font to tkinter widgets with applied font scaling """ + super().configure(font=self._apply_font_scaling(self._font)) + + def _configure_menu_for_platforms(self): + """ apply platform specific appearance attributes, configure all colors """ + + if sys.platform == "darwin": + super().configure(tearoff=False, + font=self._apply_font_scaling(self._font)) + + elif sys.platform.startswith("win"): + super().configure(tearoff=False, + relief="flat", + activebackground=self._apply_appearance_mode(self._hover_color), + borderwidth=self._apply_widget_scaling(4), + activeborderwidth=self._apply_widget_scaling(4), + bg=self._apply_appearance_mode(self._fg_color), + fg=self._apply_appearance_mode(self._text_color), + activeforeground=self._apply_appearance_mode(self._text_color), + font=self._apply_font_scaling(self._font), + cursor="hand2") + + else: + super().configure(tearoff=False, + relief="flat", + activebackground=self._apply_appearance_mode(self._hover_color), + borderwidth=0, + activeborderwidth=0, + bg=self._apply_appearance_mode(self._fg_color), + fg=self._apply_appearance_mode(self._text_color), + activeforeground=self._apply_appearance_mode(self._text_color), + font=self._apply_font_scaling(self._font)) + + def _add_menu_commands(self): + """ delete existing menu labels and createe new labels with command according to values list """ + + self.delete(0, "end") # delete all old commands + + if sys.platform.startswith("linux"): + for value in self._values: + self.add_command(label=" " + value.ljust(self._min_character_width) + " ", + command=lambda v=value: self._button_callback(v), + compound="left") + else: + for value in self._values: + self.add_command(label=value.ljust(self._min_character_width), + command=lambda v=value: self._button_callback(v), + compound="left") + + def _button_callback(self, value): + if self._command is not None: + self._command(value) + + def open(self, x: Union[int, float], y: Union[int, float]): + + if sys.platform == "darwin": + y += self._apply_widget_scaling(8) + else: + y += self._apply_widget_scaling(3) + + if sys.platform == "darwin" or sys.platform.startswith("win"): + self.post(int(x), int(y)) + else: # Linux + self.tk_popup(int(x), int(y)) + + def configure(self, **kwargs): + if "fg_color" in kwargs: + self._fg_color = self._check_color_type(kwargs.pop("fg_color")) + super().configure(bg=self._apply_appearance_mode(self._fg_color)) + + if "hover_color" in kwargs: + self._hover_color = self._check_color_type(kwargs.pop("hover_color")) + super().configure(activebackground=self._apply_appearance_mode(self._hover_color)) + + if "text_color" in kwargs: + self._text_color = self._check_color_type(kwargs.pop("text_color")) + super().configure(fg=self._apply_appearance_mode(self._text_color)) + + if "font" in kwargs: + if isinstance(self._font, CTkFont): + self._font.remove_size_configure_callback(self._update_font) + self._font = self._check_font_type(kwargs.pop("font")) + if isinstance(self._font, CTkFont): + self._font.add_size_configure_callback(self._update_font) + + self._update_font() + + if "command" in kwargs: + self._command = kwargs.pop("command") + + if "values" in kwargs: + self._values = kwargs.pop("values") + self._add_menu_commands() + + super().configure(**kwargs) + + def cget(self, attribute_name: str) -> any: + if attribute_name == "min_character_width": + return self._min_character_width + + elif attribute_name == "fg_color": + return self._fg_color + elif attribute_name == "hover_color": + return self._hover_color + elif attribute_name == "text_color": + return self._text_color + + elif attribute_name == "font": + return self._font + elif attribute_name == "command": + return self._command + elif attribute_name == "values": + return self._values + + else: + return super().cget(attribute_name) + + @staticmethod + def _check_font_type(font: any): + if isinstance(font, CTkFont): + return font + + elif type(font) == tuple and len(font) == 1: + sys.stderr.write(f"Warning: font {font} given without size, will be extended with default text size of current theme\n") + return font[0], ThemeManager.theme["text"]["size"] + + elif type(font) == tuple and 2 <= len(font) <= 3: + return font + + else: + raise ValueError(f"Wrong font type {type(font)} for font '{font}'\n" + + f"For consistency, Customtkinter requires the font argument to be a tuple of len 2 or 3 or an instance of CTkFont.\n" + + f"\nUsage example:\n" + + f"font=customtkinter.CTkFont(family='', size=)\n" + + f"font=('', )\n") + + def _set_scaling(self, new_widget_scaling, new_window_scaling): + super()._set_scaling(new_widget_scaling, new_window_scaling) + self._configure_menu_for_platforms() + + def _set_appearance_mode(self, mode_string): + """ colors won't update on appearance mode change when dropdown is open, because it's not necessary """ + super()._set_appearance_mode(mode_string) + self._configure_menu_for_platforms() diff --git a/customtkinter/windows/widgets/ctk_button.py b/customtkinter/windows/widgets/ctk_button.py new file mode 100644 index 0000000..45ed025 --- /dev/null +++ b/customtkinter/windows/widgets/ctk_button.py @@ -0,0 +1,589 @@ +import tkinter +import sys +from typing import Union, Tuple, Callable, Optional + +from .core_rendering import CTkCanvas +from .theme import ThemeManager +from .core_rendering import DrawEngine +from .core_widget_classes import CTkBaseClass +from .font import CTkFont +from .image import CTkImage + + +class CTkButton(CTkBaseClass): + """ + Button with rounded corners, border, hover effect, image support, click command and textvariable. + For detailed information check out the documentation. + """ + + _image_label_spacing: int = 6 + + def __init__(self, + master: any, + width: int = 140, + height: int = 28, + 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, 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) + + # shape + self._corner_radius: int = ThemeManager.theme["CTkButton"]["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["CTkButton"]["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["CTkButton"]["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["CTkButton"]["hover_color"] if hover_color is None else self._check_color_type(hover_color) + self._border_color: Union[str, Tuple[str, str]] = ThemeManager.theme["CTkButton"]["border_color"] if border_color is None else self._check_color_type(border_color) + self._text_color: Union[str, Tuple[str, str]] = ThemeManager.theme["CTkButton"]["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["CTkButton"]["text_color_disabled"] if text_color_disabled is None else self._check_color_type(text_color_disabled) + + # rendering options + self._background_corner_colors: Union[Tuple[Union[str, Tuple[str, str]]], None] = background_corner_colors # rendering options for DrawEngine + self._round_width_to_even_numbers: bool = round_width_to_even_numbers # rendering options for DrawEngine + self._round_height_to_even_numbers: bool = round_height_to_even_numbers # rendering options for DrawEngine + + # text, font + self._text = text + self._text_label: Union[tkinter.Label, None] = None + self._textvariable: tkinter.Variable = textvariable + self._font: Union[tuple, CTkFont] = CTkFont() if font is None else self._check_font_type(font) + if isinstance(self._font, CTkFont): + self._font.add_size_configure_callback(self._update_font) + + # image + self._image = self._check_image_type(image) + self._image_label: Union[tkinter.Label, None] = None + if isinstance(self._image, CTkImage): + self._image.add_configure_callback(self._update_image) + + # other + self._state: str = state + self._hover: bool = hover + self._command: Callable = command + self._compound: str = compound + self._anchor: str = anchor + self._click_animation_running: bool = False + + # canvas and draw engine + self._canvas = CTkCanvas(master=self, + highlightthickness=0, + width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height)) + self._canvas.grid(row=0, column=0, rowspan=5, columnspan=5, sticky="nsew") + self._draw_engine = DrawEngine(self._canvas) + self._draw_engine.set_round_to_even_numbers(self._round_width_to_even_numbers, self._round_height_to_even_numbers) # rendering options + + # configure cursor and initial draw + self._create_bindings() + self._set_cursor() + self._draw() + + def _create_bindings(self, sequence: Optional[str] = None): + """ set necessary bindings for functionality of widget, will overwrite other bindings """ + + if sequence is None or sequence == "": + self._canvas.bind("", self._on_enter) + + if self._text_label is not None: + self._text_label.bind("", self._on_enter) + if self._image_label is not None: + self._image_label.bind("", self._on_enter) + + if sequence is None or sequence == "": + self._canvas.bind("", self._on_leave) + + if self._text_label is not None: + self._text_label.bind("", self._on_leave) + if self._image_label is not None: + self._image_label.bind("", self._on_leave) + + if sequence is None or sequence == "": + self._canvas.bind("", self._clicked) + + if self._text_label is not None: + self._text_label.bind("", self._clicked) + if self._image_label is not None: + self._image_label.bind("", self._clicked) + + def _set_scaling(self, *args, **kwargs): + super()._set_scaling(*args, **kwargs) + + self._create_grid() + + if self._text_label is not None: + self._text_label.configure(font=self._apply_font_scaling(self._font)) + + self._update_image() + + self._canvas.configure(width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height)) + self._draw(no_color_updates=True) + + def _set_appearance_mode(self, mode_string): + super()._set_appearance_mode(mode_string) + self._update_image() + + def _set_dimensions(self, width: int = None, height: int = None): + super()._set_dimensions(width, height) + + self._canvas.configure(width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height)) + self._draw() + + def _update_font(self): + """ pass font to tkinter widgets with applied font scaling and update grid with workaround """ + if self._text_label is not None: + self._text_label.configure(font=self._apply_font_scaling(self._font)) + + # Workaround to force grid to be resized when text changes size. + # Otherwise grid will lag and only resizes if other mouse action occurs. + self._canvas.grid_forget() + self._canvas.grid(row=0, column=0, rowspan=5, columnspan=5, sticky="nsew") + + def _update_image(self): + if self._image_label is not None: + self._image_label.configure(image=self._image.create_scaled_photo_image(self._get_widget_scaling(), + self._get_appearance_mode())) + + def destroy(self): + if isinstance(self._font, CTkFont): + self._font.remove_size_configure_callback(self._update_font) + super().destroy() + + def _draw(self, no_color_updates=False): + super()._draw(no_color_updates) + + if self._background_corner_colors is not None: + self._draw_engine.draw_background_corners(self._apply_widget_scaling(self._current_width), + self._apply_widget_scaling(self._current_height)) + self._canvas.itemconfig("background_corner_top_left", fill=self._apply_appearance_mode(self._background_corner_colors[0])) + self._canvas.itemconfig("background_corner_top_right", fill=self._apply_appearance_mode(self._background_corner_colors[1])) + self._canvas.itemconfig("background_corner_bottom_right", fill=self._apply_appearance_mode(self._background_corner_colors[2])) + self._canvas.itemconfig("background_corner_bottom_left", fill=self._apply_appearance_mode(self._background_corner_colors[3])) + else: + self._canvas.delete("background_parts") + + requires_recoloring = self._draw_engine.draw_rounded_rect_with_border(self._apply_widget_scaling(self._current_width), + self._apply_widget_scaling(self._current_height), + self._apply_widget_scaling(self._corner_radius), + self._apply_widget_scaling(self._border_width)) + + if no_color_updates is False or requires_recoloring: + + self._canvas.configure(bg=self._apply_appearance_mode(self._bg_color)) + + # set color for the button border parts (outline) + self._canvas.itemconfig("border_parts", + outline=self._apply_appearance_mode(self._border_color), + fill=self._apply_appearance_mode(self._border_color)) + + # set color for inner button parts + if self._fg_color == "transparent": + self._canvas.itemconfig("inner_parts", + outline=self._apply_appearance_mode(self._bg_color), + fill=self._apply_appearance_mode(self._bg_color)) + else: + self._canvas.itemconfig("inner_parts", + outline=self._apply_appearance_mode(self._fg_color), + fill=self._apply_appearance_mode(self._fg_color)) + + # create text label if text given + if self._text is not None and self._text != "": + + if self._text_label is None: + self._text_label = tkinter.Label(master=self, + font=self._apply_font_scaling(self._font), + text=self._text, + padx=0, + pady=0, + borderwidth=1, + textvariable=self._textvariable) + self._create_grid() + + self._text_label.bind("", self._on_enter) + self._text_label.bind("", self._on_leave) + self._text_label.bind("", self._clicked) + self._text_label.bind("", self._clicked) + + if no_color_updates is False: + # set text_label fg color (text color) + self._text_label.configure(fg=self._apply_appearance_mode(self._text_color)) + + if self._state == tkinter.DISABLED: + self._text_label.configure(fg=(self._apply_appearance_mode(self._text_color_disabled))) + else: + self._text_label.configure(fg=self._apply_appearance_mode(self._text_color)) + + if self._apply_appearance_mode(self._fg_color) == "transparent": + self._text_label.configure(bg=self._apply_appearance_mode(self._bg_color)) + else: + self._text_label.configure(bg=self._apply_appearance_mode(self._fg_color)) + + else: + # delete text_label if no text given + if self._text_label is not None: + self._text_label.destroy() + self._text_label = None + self._create_grid() + + # create image label if image given + if self._image is not None: + + if self._image_label is None: + self._image_label = tkinter.Label(master=self) + self._update_image() # set image + self._create_grid() + + self._image_label.bind("", self._on_enter) + self._image_label.bind("", self._on_leave) + self._image_label.bind("", self._clicked) + self._image_label.bind("", self._clicked) + + if no_color_updates is False: + # set image_label bg color (background color of label) + if self._apply_appearance_mode(self._fg_color) == "transparent": + self._image_label.configure(bg=self._apply_appearance_mode(self._bg_color)) + else: + self._image_label.configure(bg=self._apply_appearance_mode(self._fg_color)) + + else: + # delete text_label if no text given + if self._image_label is not None: + self._image_label.destroy() + self._image_label = None + self._create_grid() + + def _create_grid(self): + """ configure grid system (5x5) """ + + # Outer rows and columns have weight of 1000 to overpower the rows and columns of the label and image with weight 1. + # Rows and columns of image and label need weight of 1 to collapse in case of missing space on the button, + # so image and label need sticky option to stick together in the center, and therefore outer rows and columns + # need weight of 100 in case of other anchor than center. + n_padding_weight, s_padding_weight, e_padding_weight, w_padding_weight = 1000, 1000, 1000, 1000 + if self._anchor != "center": + if "n" in self._anchor: + n_padding_weight, s_padding_weight = 0, 1000 + if "s" in self._anchor: + n_padding_weight, s_padding_weight = 1000, 0 + if "e" in self._anchor: + e_padding_weight, w_padding_weight = 1000, 0 + if "w" in self._anchor: + e_padding_weight, w_padding_weight = 0, 1000 + + scaled_minsize_rows = self._apply_widget_scaling(max(self._border_width + 1, self._border_spacing)) + scaled_minsize_columns = self._apply_widget_scaling(max(self._corner_radius, self._border_width + 1, self._border_spacing)) + + self.grid_rowconfigure(0, weight=n_padding_weight, minsize=scaled_minsize_rows) + self.grid_rowconfigure(4, weight=s_padding_weight, minsize=scaled_minsize_rows) + self.grid_columnconfigure(0, weight=e_padding_weight, minsize=scaled_minsize_columns) + self.grid_columnconfigure(4, weight=w_padding_weight, minsize=scaled_minsize_columns) + + if self._compound in ("right", "left"): + self.grid_rowconfigure(2, weight=1) + if self._image_label is not None and self._text_label is not None: + self.grid_columnconfigure(2, weight=0, minsize=self._apply_widget_scaling(self._image_label_spacing)) + else: + self.grid_columnconfigure(2, weight=0) + + self.grid_rowconfigure((1, 3), weight=0) + self.grid_columnconfigure((1, 3), weight=1) + else: + self.grid_columnconfigure(2, weight=1) + if self._image_label is not None and self._text_label is not None: + self.grid_rowconfigure(2, weight=0, minsize=self._apply_widget_scaling(self._image_label_spacing)) + else: + self.grid_rowconfigure(2, weight=0) + + self.grid_columnconfigure((1, 3), weight=0) + self.grid_rowconfigure((1, 3), weight=1) + + if self._compound == "right": + if self._image_label is not None: + self._image_label.grid(row=2, column=3, sticky="w") + if self._text_label is not None: + self._text_label.grid(row=2, column=1, sticky="e") + elif self._compound == "left": + if self._image_label is not None: + self._image_label.grid(row=2, column=1, sticky="e") + if self._text_label is not None: + self._text_label.grid(row=2, column=3, sticky="w") + elif self._compound == "top": + if self._image_label is not None: + self._image_label.grid(row=1, column=2, sticky="s") + if self._text_label is not None: + self._text_label.grid(row=3, column=2, sticky="n") + elif self._compound == "bottom": + if self._image_label is not None: + self._image_label.grid(row=3, column=2, sticky="n") + if self._text_label is not None: + self._text_label.grid(row=1, column=2, sticky="s") + + def configure(self, require_redraw=False, **kwargs): + if "corner_radius" in kwargs: + self._corner_radius = kwargs.pop("corner_radius") + self._create_grid() + require_redraw = True + + if "border_width" in kwargs: + self._border_width = kwargs.pop("border_width") + self._create_grid() + require_redraw = True + + if "border_spacing" in kwargs: + self._border_spacing = kwargs.pop("border_spacing") + self._create_grid() + require_redraw = True + + if "fg_color" in kwargs: + self._fg_color = self._check_color_type(kwargs.pop("fg_color"), transparency=True) + require_redraw = True + + if "hover_color" in kwargs: + self._hover_color = self._check_color_type(kwargs.pop("hover_color")) + require_redraw = True + + if "border_color" in kwargs: + self._border_color = self._check_color_type(kwargs.pop("border_color")) + require_redraw = True + + if "text_color" in kwargs: + self._text_color = self._check_color_type(kwargs.pop("text_color")) + require_redraw = True + + if "text_color_disabled" in kwargs: + self._text_color_disabled = self._check_color_type(kwargs.pop("text_color_disabled")) + require_redraw = True + + if "background_corner_colors" in kwargs: + self._background_corner_colors = kwargs.pop("background_corner_colors") + require_redraw = True + + if "text" in kwargs: + self._text = kwargs.pop("text") + if self._text_label is None: + require_redraw = True # text_label will be created in .draw() + else: + self._text_label.configure(text=self._text) + + if "font" in kwargs: + if isinstance(self._font, CTkFont): + self._font.remove_size_configure_callback(self._update_font) + self._font = self._check_font_type(kwargs.pop("font")) + if isinstance(self._font, CTkFont): + self._font.add_size_configure_callback(self._update_font) + + self._update_font() + + if "textvariable" in kwargs: + self._textvariable = kwargs.pop("textvariable") + if self._text_label is not None: + self._text_label.configure(textvariable=self._textvariable) + + if "image" in kwargs: + if isinstance(self._image, CTkImage): + self._image.remove_configure_callback(self._update_image) + self._image = self._check_image_type(kwargs.pop("image")) + if isinstance(self._image, CTkImage): + self._image.add_configure_callback(self._update_image) + self._update_image() + + if "state" in kwargs: + self._state = kwargs.pop("state") + self._set_cursor() + require_redraw = True + + if "hover" in kwargs: + self._hover = kwargs.pop("hover") + + if "command" in kwargs: + self._command = kwargs.pop("command") + + if "compound" in kwargs: + self._compound = kwargs.pop("compound") + require_redraw = True + + if "anchor" in kwargs: + self._anchor = kwargs.pop("anchor") + require_redraw = True + + super().configure(require_redraw=require_redraw, **kwargs) + + def cget(self, attribute_name: str) -> any: + if attribute_name == "corner_radius": + return self._corner_radius + elif attribute_name == "border_width": + return self._border_width + elif attribute_name == "border_spacing": + return self._border_spacing + + elif attribute_name == "fg_color": + return self._fg_color + elif attribute_name == "hover_color": + return self._hover_color + elif attribute_name == "border_color": + return self._border_color + elif attribute_name == "text_color": + return self._text_color + elif attribute_name == "text_color_disabled": + return self._text_color_disabled + elif attribute_name == "background_corner_colors": + return self._background_corner_colors + + elif attribute_name == "text": + return self._text + elif attribute_name == "font": + return self._font + elif attribute_name == "textvariable": + return self._textvariable + elif attribute_name == "image": + return self._image + elif attribute_name == "state": + return self._state + elif attribute_name == "hover": + return self._hover + elif attribute_name == "command": + return self._command + elif attribute_name == "compound": + return self._compound + elif attribute_name == "anchor": + return self._anchor + else: + return super().cget(attribute_name) + + def _set_cursor(self): + if self._cursor_manipulation_enabled: + if self._state == tkinter.DISABLED: + if sys.platform == "darwin" and self._command is not None: + self.configure(cursor="arrow") + elif sys.platform.startswith("win") and self._command is not None: + self.configure(cursor="arrow") + + elif self._state == tkinter.NORMAL: + if sys.platform == "darwin" and self._command is not None: + self.configure(cursor="pointinghand") + elif sys.platform.startswith("win") and self._command is not None: + self.configure(cursor="hand2") + + def _on_enter(self, event=None): + if self._hover is True and self._state == "normal": + if self._hover_color is None: + inner_parts_color = self._fg_color + else: + inner_parts_color = self._hover_color + + # set color of inner button parts to hover color + self._canvas.itemconfig("inner_parts", + outline=self._apply_appearance_mode(inner_parts_color), + fill=self._apply_appearance_mode(inner_parts_color)) + + # set text_label bg color to button hover color + if self._text_label is not None: + self._text_label.configure(bg=self._apply_appearance_mode(inner_parts_color)) + + # set image_label bg color to button hover color + if self._image_label is not None: + self._image_label.configure(bg=self._apply_appearance_mode(inner_parts_color)) + + def _on_leave(self, event=None): + self._click_animation_running = False + + if self._fg_color == "transparent": + inner_parts_color = self._bg_color + else: + inner_parts_color = self._fg_color + + # set color of inner button parts + self._canvas.itemconfig("inner_parts", + outline=self._apply_appearance_mode(inner_parts_color), + fill=self._apply_appearance_mode(inner_parts_color)) + + # set text_label bg color (label color) + if self._text_label is not None: + self._text_label.configure(bg=self._apply_appearance_mode(inner_parts_color)) + + # set image_label bg color (image bg color) + if self._image_label is not None: + self._image_label.configure(bg=self._apply_appearance_mode(inner_parts_color)) + + def _click_animation(self): + if self._click_animation_running: + self._on_enter() + + def _clicked(self, event=None): + if self._state != tkinter.DISABLED: + + # click animation: change color with .on_leave() and back to normal after 100ms with click_animation() + self._on_leave() + self._click_animation_running = True + self.after(100, self._click_animation) + + if self._command is not None: + self._command() + + def invoke(self): + """ calls command function if button is not disabled """ + if self._state != tkinter.DISABLED: + if self._command is not None: + return self._command() + + def bind(self, sequence: str = None, command: Callable = None, add: Union[str, bool] = True): + """ called on the tkinter.Canvas """ + if not (add == "+" or add is True): + raise ValueError("'add' argument can only be '+' or True to preserve internal callbacks") + self._canvas.bind(sequence, command, add=True) + + if self._text_label is not None: + self._text_label.bind(sequence, command, add=True) + if self._image_label is not None: + self._image_label.bind(sequence, command, add=True) + + def unbind(self, sequence: str = None, funcid: str = None): + """ called on the tkinter.Label and tkinter.Canvas """ + if funcid is not None: + raise ValueError("'funcid' argument can only be None, because there is a bug in" + + " tkinter and its not clear whether the internal callbacks will be unbinded or not") + self._canvas.unbind(sequence, None) + + if self._text_label is not None: + self._text_label.unbind(sequence, None) + if self._image_label is not None: + self._image_label.unbind(sequence, None) + + self._create_bindings(sequence=sequence) # restore internal callbacks for sequence + + def focus(self): + return self._text_label.focus() + + def focus_set(self): + return self._text_label.focus_set() + + def focus_force(self): + return self._text_label.focus_force() diff --git a/customtkinter/windows/widgets/ctk_checkbox.py b/customtkinter/windows/widgets/ctk_checkbox.py new file mode 100644 index 0000000..3d9b496 --- /dev/null +++ b/customtkinter/windows/widgets/ctk_checkbox.py @@ -0,0 +1,461 @@ +import tkinter +import sys +from typing import Union, Tuple, Callable, Optional + +from .core_rendering import CTkCanvas +from .theme import ThemeManager +from .core_rendering import DrawEngine +from .core_widget_classes import CTkBaseClass +from .font import CTkFont + + +class CTkCheckBox(CTkBaseClass): + """ + Checkbox with rounded corners, border, variable support and hover effect. + For detailed information check out the documentation. + """ + + def __init__(self, + master: any, + width: int = 100, + height: int = 24, + checkbox_width: int = 24, + checkbox_height: int = 24, + corner_radius: Optional[int] = None, + border_width: Optional[int] = None, + + 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, + checkmark_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, + + text: str = "CTkCheckBox", + font: Optional[Union[tuple, CTkFont]] = None, + textvariable: Union[tkinter.Variable, None] = None, + state: str = tkinter.NORMAL, + hover: bool = True, + command: Union[Callable[[], None], None] = None, + onvalue: Union[int, str] = 1, + offvalue: Union[int, str] = 0, + variable: Union[tkinter.Variable, None] = None, + **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) + + # dimensions + self._checkbox_width = checkbox_width + self._checkbox_height = checkbox_height + + # color + self._fg_color = ThemeManager.theme["CTkCheckbox"]["fg_color"] if fg_color is None else self._check_color_type(fg_color) + self._hover_color = ThemeManager.theme["CTkCheckbox"]["hover_color"] if hover_color is None else self._check_color_type(hover_color) + self._border_color = ThemeManager.theme["CTkCheckbox"]["border_color"] if border_color is None else self._check_color_type(border_color) + self._checkmark_color = ThemeManager.theme["CTkCheckbox"]["checkmark_color"] if checkmark_color is None else self._check_color_type(checkmark_color) + + # shape + self._corner_radius = ThemeManager.theme["CTkCheckbox"]["corner_radius"] if corner_radius is None else corner_radius + self._border_width = ThemeManager.theme["CTkCheckbox"]["border_width"] if border_width is None else border_width + + # text + self._text = text + self._text_label: Union[tkinter.Label, None] = None + self._text_color = ThemeManager.theme["CTkCheckbox"]["text_color"] if text_color is None else self._check_color_type(text_color) + self._text_color_disabled = ThemeManager.theme["CTkCheckbox"]["text_color_disabled"] if text_color_disabled is None else self._check_color_type(text_color_disabled) + + # font + self._font = CTkFont() if font is None else self._check_font_type(font) + if isinstance(self._font, CTkFont): + self._font.add_size_configure_callback(self._update_font) + + # callback and hover functionality + self._command = command + self._state = state + self._hover = hover + self._check_state = False + + self._onvalue = onvalue + self._offvalue = offvalue + self._variable: tkinter.Variable = variable + self._variable_callback_blocked = False + self._textvariable: tkinter.Variable = textvariable + self._variable_callback_name = None + + # configure grid system (1x3) + self.grid_columnconfigure(0, weight=0) + self.grid_columnconfigure(1, weight=0, minsize=self._apply_widget_scaling(6)) + self.grid_columnconfigure(2, weight=1) + self.grid_rowconfigure(0, weight=1) + + self._bg_canvas = CTkCanvas(master=self, + highlightthickness=0, + width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height)) + self._bg_canvas.grid(row=0, column=0, columnspan=3, sticky="nswe") + + self._canvas = CTkCanvas(master=self, + highlightthickness=0, + width=self._apply_widget_scaling(self._checkbox_width), + height=self._apply_widget_scaling(self._checkbox_height)) + self._canvas.grid(row=0, column=0, sticky="e") + self._draw_engine = DrawEngine(self._canvas) + + self._text_label = tkinter.Label(master=self, + bd=0, + padx=0, + pady=0, + text=self._text, + justify=tkinter.LEFT, + font=self._apply_font_scaling(self._font), + textvariable=self._textvariable) + self._text_label.grid(row=0, column=2, sticky="w") + self._text_label["anchor"] = "w" + + # register variable callback and set state according to variable + if self._variable is not None and self._variable != "": + self._variable_callback_name = self._variable.trace_add("write", self._variable_callback) + self._check_state = True if self._variable.get() == self._onvalue else False + + self._create_bindings() + self._set_cursor() + self._draw() + + def _create_bindings(self, sequence: Optional[str] = None): + """ set necessary bindings for functionality of widget, will overwrite other bindings """ + if sequence is None or sequence == "": + self._canvas.bind("", self._on_enter) + self._text_label.bind("", self._on_enter) + if sequence is None or sequence == "": + self._canvas.bind("", self._on_leave) + self._text_label.bind("", self._on_leave) + if sequence is None or sequence == "": + self._canvas.bind("", self.toggle) + self._text_label.bind("", self.toggle) + + def _set_scaling(self, *args, **kwargs): + super()._set_scaling(*args, **kwargs) + + self.grid_columnconfigure(1, weight=0, minsize=self._apply_widget_scaling(6)) + self._text_label.configure(font=self._apply_font_scaling(self._font)) + + self._canvas.delete("checkmark") + self._bg_canvas.configure(width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height)) + self._canvas.configure(width=self._apply_widget_scaling(self._checkbox_width), + height=self._apply_widget_scaling(self._checkbox_height)) + self._draw(no_color_updates=True) + + def _set_dimensions(self, width: int = None, height: int = None): + super()._set_dimensions(width, height) + + self._bg_canvas.configure(width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height)) + + def _update_font(self): + """ pass font to tkinter widgets with applied font scaling and update grid with workaround """ + if self._text_label is not None: + self._text_label.configure(font=self._apply_font_scaling(self._font)) + + # Workaround to force grid to be resized when text changes size. + # Otherwise grid will lag and only resizes if other mouse action occurs. + self._bg_canvas.grid_forget() + self._bg_canvas.grid(row=0, column=0, columnspan=3, sticky="nswe") + + def destroy(self): + if self._variable is not None: + self._variable.trace_remove("write", self._variable_callback_name) + + if isinstance(self._font, CTkFont): + self._font.remove_size_configure_callback(self._update_font) + + super().destroy() + + def _draw(self, no_color_updates=False): + super()._draw(no_color_updates) + + requires_recoloring_1 = self._draw_engine.draw_rounded_rect_with_border(self._apply_widget_scaling(self._checkbox_width), + self._apply_widget_scaling(self._checkbox_height), + self._apply_widget_scaling(self._corner_radius), + self._apply_widget_scaling(self._border_width)) + + if self._check_state is True: + requires_recoloring_2 = self._draw_engine.draw_checkmark(self._apply_widget_scaling(self._checkbox_width), + self._apply_widget_scaling(self._checkbox_height), + self._apply_widget_scaling(self._checkbox_height * 0.58)) + else: + requires_recoloring_2 = False + self._canvas.delete("checkmark") + + if no_color_updates is False or requires_recoloring_1 or requires_recoloring_2: + self._bg_canvas.configure(bg=self._apply_appearance_mode(self._bg_color)) + self._canvas.configure(bg=self._apply_appearance_mode(self._bg_color)) + + if self._check_state is True: + self._canvas.itemconfig("inner_parts", + outline=self._apply_appearance_mode(self._fg_color), + fill=self._apply_appearance_mode(self._fg_color)) + self._canvas.itemconfig("border_parts", + outline=self._apply_appearance_mode(self._fg_color), + fill=self._apply_appearance_mode(self._fg_color)) + + if "create_line" in self._canvas.gettags("checkmark"): + self._canvas.itemconfig("checkmark", fill=self._apply_appearance_mode(self._checkmark_color)) + else: + self._canvas.itemconfig("checkmark", fill=self._apply_appearance_mode(self._checkmark_color)) + else: + self._canvas.itemconfig("inner_parts", + outline=self._apply_appearance_mode(self._bg_color), + fill=self._apply_appearance_mode(self._bg_color)) + self._canvas.itemconfig("border_parts", + outline=self._apply_appearance_mode(self._border_color), + fill=self._apply_appearance_mode(self._border_color)) + + if self._state == tkinter.DISABLED: + self._text_label.configure(fg=(self._apply_appearance_mode(self._text_color_disabled))) + else: + self._text_label.configure(fg=self._apply_appearance_mode(self._text_color)) + + self._text_label.configure(bg=self._apply_appearance_mode(self._bg_color)) + + def configure(self, require_redraw=False, **kwargs): + if "corner_radius" in kwargs: + self._corner_radius = kwargs.pop("corner_radius") + require_redraw = True + + if "border_width" in kwargs: + self._border_width = kwargs.pop("border_width") + require_redraw = True + + if "checkbox_width" in kwargs: + self._checkbox_width = kwargs.pop("checkbox_width") + self._canvas.configure(width=self._apply_widget_scaling(self._checkbox_width)) + require_redraw = True + + if "checkbox_height" in kwargs: + self._checkbox_height = kwargs.pop("checkbox_height") + self._canvas.configure(height=self._apply_widget_scaling(self._checkbox_height)) + require_redraw = True + + if "text" in kwargs: + self._text = kwargs.pop("text") + self._text_label.configure(text=self._text) + + if "font" in kwargs: + if isinstance(self._font, CTkFont): + self._font.remove_size_configure_callback(self._update_font) + self._font = self._check_font_type(kwargs.pop("font")) + if isinstance(self._font, CTkFont): + self._font.add_size_configure_callback(self._update_font) + + self._update_font() + + if "state" in kwargs: + self._state = kwargs.pop("state") + self._set_cursor() + require_redraw = True + + if "fg_color" in kwargs: + self._fg_color = self._check_color_type(kwargs.pop("fg_color")) + require_redraw = True + + if "hover_color" in kwargs: + self._hover_color = self._check_color_type(kwargs.pop("hover_color")) + require_redraw = True + + if "text_color" in kwargs: + self._text_color = self._check_color_type(kwargs.pop("text_color")) + require_redraw = True + + if "border_color" in kwargs: + self._border_color = self._check_color_type(kwargs.pop("border_color")) + require_redraw = True + + if "hover" in kwargs: + self._hover = kwargs.pop("hover") + + if "command" in kwargs: + self._command = kwargs.pop("command") + + if "textvariable" in kwargs: + self._textvariable = kwargs.pop("textvariable") + self._text_label.configure(textvariable=self._textvariable) + + if "variable" in kwargs: + if self._variable is not None and self._variable != "": + self._variable.trace_remove("write", self._variable_callback_name) # remove old variable callback + + self._variable = kwargs.pop("variable") + + if self._variable is not None and self._variable != "": + self._variable_callback_name = self._variable.trace_add("write", self._variable_callback) + self._check_state = True if self._variable.get() == self._onvalue else False + require_redraw = True + + super().configure(require_redraw=require_redraw, **kwargs) + + def cget(self, attribute_name: str) -> any: + if attribute_name == "corner_radius": + return self._corner_radius + elif attribute_name == "border_width": + return self._border_width + elif attribute_name == "checkbox_width": + return self._checkbox_width + elif attribute_name == "checkbox_height": + return self._checkbox_height + + elif attribute_name == "fg_color": + return self._fg_color + elif attribute_name == "hover_color": + return self._hover_color + elif attribute_name == "border_color": + return self._border_color + elif attribute_name == "checkmark_color": + return self._checkmark_color + elif attribute_name == "text_color": + return self._text_color + elif attribute_name == "text_color_disabled": + return self._text_color_disabled + + elif attribute_name == "text": + return self._text + elif attribute_name == "font": + return self._font + elif attribute_name == "textvariable": + return self._textvariable + elif attribute_name == "state": + return self._state + elif attribute_name == "hover": + return self._hover + elif attribute_name == "onvalue": + return self._onvalue + elif attribute_name == "offvalue": + return self._offvalue + elif attribute_name == "variable": + return self._variable + else: + return super().cget(attribute_name) + + def _set_cursor(self): + if self._cursor_manipulation_enabled: + if self._state == tkinter.DISABLED: + if sys.platform == "darwin": + self._canvas.configure(cursor="arrow") + if self._text_label is not None: + self._text_label.configure(cursor="arrow") + elif sys.platform.startswith("win"): + self._canvas.configure(cursor="arrow") + if self._text_label is not None: + self._text_label.configure(cursor="arrow") + + elif self._state == tkinter.NORMAL: + if sys.platform == "darwin": + self._canvas.configure(cursor="pointinghand") + if self._text_label is not None: + self._text_label.configure(cursor="pointinghand") + elif sys.platform.startswith("win"): + self._canvas.configure(cursor="hand2") + if self._text_label is not None: + self._text_label.configure(cursor="hand2") + + def _on_enter(self, event=0): + if self._hover is True and self._state == tkinter.NORMAL: + if self._check_state is True: + self._canvas.itemconfig("inner_parts", + fill=self._apply_appearance_mode(self._hover_color), + outline=self._apply_appearance_mode(self._hover_color)) + self._canvas.itemconfig("border_parts", + fill=self._apply_appearance_mode(self._hover_color), + outline=self._apply_appearance_mode(self._hover_color)) + else: + self._canvas.itemconfig("inner_parts", + fill=self._apply_appearance_mode(self._hover_color), + outline=self._apply_appearance_mode(self._hover_color)) + + def _on_leave(self, event=0): + if self._check_state is True: + self._canvas.itemconfig("inner_parts", + fill=self._apply_appearance_mode(self._fg_color), + outline=self._apply_appearance_mode(self._fg_color)) + self._canvas.itemconfig("border_parts", + fill=self._apply_appearance_mode(self._fg_color), + outline=self._apply_appearance_mode(self._fg_color)) + else: + self._canvas.itemconfig("inner_parts", + fill=self._apply_appearance_mode(self._bg_color), + outline=self._apply_appearance_mode(self._bg_color)) + self._canvas.itemconfig("border_parts", + fill=self._apply_appearance_mode(self._border_color), + outline=self._apply_appearance_mode(self._border_color)) + + def _variable_callback(self, var_name, index, mode): + if not self._variable_callback_blocked: + if self._variable.get() == self._onvalue: + self.select(from_variable_callback=True) + elif self._variable.get() == self._offvalue: + self.deselect(from_variable_callback=True) + + def toggle(self, event=0): + if self._state == tkinter.NORMAL: + if self._check_state is True: + self._check_state = False + self._draw() + else: + self._check_state = True + self._draw() + + if self._variable is not None: + self._variable_callback_blocked = True + self._variable.set(self._onvalue if self._check_state is True else self._offvalue) + self._variable_callback_blocked = False + + if self._command is not None: + self._command() + + def select(self, from_variable_callback=False): + self._check_state = True + self._draw() + + if self._variable is not None and not from_variable_callback: + self._variable_callback_blocked = True + self._variable.set(self._onvalue) + self._variable_callback_blocked = False + + def deselect(self, from_variable_callback=False): + self._check_state = False + self._draw() + + if self._variable is not None and not from_variable_callback: + self._variable_callback_blocked = True + self._variable.set(self._offvalue) + self._variable_callback_blocked = False + + def get(self) -> Union[int, str]: + return self._onvalue if self._check_state is True else self._offvalue + + def bind(self, sequence: str = None, command: Callable = None, add: Union[str, bool] = True): + """ called on the tkinter.Canvas """ + if not (add == "+" or add is True): + raise ValueError("'add' argument can only be '+' or True to preserve internal callbacks") + self._canvas.bind(sequence, command, add=True) + self._text_label.bind(sequence, command, add=True) + + def unbind(self, sequence: str = None, funcid: str = None): + """ called on the tkinter.Label and tkinter.Canvas """ + if funcid is not None: + raise ValueError("'funcid' argument can only be None, because there is a bug in" + + " tkinter and its not clear whether the internal callbacks will be unbinded or not") + self._canvas.unbind(sequence, None) + self._text_label.unbind(sequence, None) + self._create_bindings(sequence=sequence) # restore internal callbacks for sequence + + def focus(self): + return self._text_label.focus() + + def focus_set(self): + return self._text_label.focus_set() + + def focus_force(self): + return self._text_label.focus_force() diff --git a/customtkinter/windows/widgets/ctk_combobox.py b/customtkinter/windows/widgets/ctk_combobox.py new file mode 100644 index 0000000..aa95337 --- /dev/null +++ b/customtkinter/windows/widgets/ctk_combobox.py @@ -0,0 +1,424 @@ +import tkinter +import sys +import copy +from typing import Union, Tuple, Callable, List, Optional + +from .core_widget_classes import DropdownMenu +from .core_rendering import CTkCanvas +from .theme import ThemeManager +from .core_rendering import DrawEngine +from .core_widget_classes import CTkBaseClass +from .font import CTkFont + + +class CTkComboBox(CTkBaseClass): + """ + Combobox with dropdown menu, rounded corners, border, variable support. + For detailed information check out the documentation. + """ + + def __init__(self, + master: any, + width: int = 140, + height: int = 28, + corner_radius: Optional[int] = None, + border_width: Optional[int] = None, + + bg_color: Union[str, Tuple[str, str]] = "transparent", + fg_color: Optional[Union[str, Tuple[str, str]]] = None, + border_color: Optional[Union[str, Tuple[str, str]]] = None, + button_color: Optional[Union[str, Tuple[str, str]]] = None, + button_hover_color: Optional[Union[str, Tuple[str, str]]] = None, + dropdown_fg_color: Optional[Union[str, Tuple[str, str]]] = None, + dropdown_hover_color: Optional[Union[str, Tuple[str, str]]] = None, + dropdown_text_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, + + font: Optional[Union[tuple, CTkFont]] = None, + dropdown_font: Optional[Union[tuple, CTkFont]] = None, + values: Optional[List[str]] = None, + state: str = tkinter.NORMAL, + hover: bool = True, + variable: Union[tkinter.Variable, None] = None, + command: Union[Callable[[str], None], None] = None, + justify: str = "left", + **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) + + # shape + self._corner_radius = ThemeManager.theme["CTkComboBox"]["corner_radius"] if corner_radius is None else corner_radius + self._border_width = ThemeManager.theme["CTkComboBox"]["border_width"] if border_width is None else border_width + + # color + self._fg_color = ThemeManager.theme["CTkComboBox"]["fg_color"] if fg_color is None else self._check_color_type(fg_color) + self._border_color = ThemeManager.theme["CTkComboBox"]["border_color"] if border_color is None else self._check_color_type(border_color) + self._button_color = ThemeManager.theme["CTkComboBox"]["button_color"] if button_color is None else self._check_color_type(button_color) + self._button_hover_color = ThemeManager.theme["CTkComboBox"]["button_hover_color"] if button_hover_color is None else self._check_color_type(button_hover_color) + self._text_color = ThemeManager.theme["CTkComboBox"]["text_color"] if text_color is None else self._check_color_type(text_color) + self._text_color_disabled = ThemeManager.theme["CTkComboBox"]["text_color_disabled"] if text_color_disabled is None else self._check_color_type(text_color_disabled) + + # font + self._font = CTkFont() if font is None else self._check_font_type(font) + if isinstance(self._font, CTkFont): + self._font.add_size_configure_callback(self._update_font) + + # callback and hover functionality + self._command = command + self._variable = variable + self._state = state + self._hover = hover + + if values is None: + self._values = ["CTkComboBox"] + else: + self._values = values + + self._dropdown_menu = DropdownMenu(master=self, + values=self._values, + command=self._dropdown_callback, + fg_color=dropdown_fg_color, + hover_color=dropdown_hover_color, + text_color=dropdown_text_color, + font=dropdown_font) + + # configure grid system (1x1) + self.grid_rowconfigure(0, weight=1) + self.grid_columnconfigure(0, weight=1) + + self._canvas = CTkCanvas(master=self, + highlightthickness=0, + width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height)) + self.draw_engine = DrawEngine(self._canvas) + + self._entry = tkinter.Entry(master=self, + state=self._state, + width=1, + bd=0, + justify=justify, + highlightthickness=0, + font=self._apply_font_scaling(self._font)) + + self._create_grid() + self._create_bindings() + self._draw() # initial draw + + if self._variable is not None: + self._entry.configure(textvariable=self._variable) + + # insert default value + if self._variable is None: + if len(self._values) > 0: + self._entry.insert(0, self._values[0]) + else: + self._entry.insert(0, "CTkComboBox") + + def _create_bindings(self, sequence: Optional[str] = None): + """ set necessary bindings for functionality of widget, will overwrite other bindings """ + if sequence is None: + self._canvas.tag_bind("right_parts", "", self._on_enter) + self._canvas.tag_bind("dropdown_arrow", "", self._on_enter) + self._canvas.tag_bind("right_parts", "", self._on_leave) + self._canvas.tag_bind("dropdown_arrow", "", self._on_leave) + self._canvas.tag_bind("right_parts", "", self._clicked) + self._canvas.tag_bind("dropdown_arrow", "", self._clicked) + + def _create_grid(self): + self._canvas.grid(row=0, column=0, rowspan=1, columnspan=1, sticky="nsew") + + left_section_width = self._current_width - self._current_height + self._entry.grid(row=0, column=0, rowspan=1, columnspan=1, sticky="ew", + padx=(max(self._apply_widget_scaling(self._corner_radius), self._apply_widget_scaling(3)), + max(self._apply_widget_scaling(self._current_width - left_section_width + 3), self._apply_widget_scaling(3))), + pady=self._apply_widget_scaling(self._border_width)) + + def _set_scaling(self, *args, **kwargs): + super()._set_scaling(*args, **kwargs) + + # change entry font size and grid padding + self._entry.configure(font=self._apply_font_scaling(self._font)) + self._create_grid() + + self._canvas.configure(width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height)) + self._draw(no_color_updates=True) + + def _set_dimensions(self, width: int = None, height: int = None): + super()._set_dimensions(width, height) + + self._canvas.configure(width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height)) + self._draw() + + def _update_font(self): + """ pass font to tkinter widgets with applied font scaling and update grid with workaround """ + self._entry.configure(font=self._apply_font_scaling(self._font)) + + # Workaround to force grid to be resized when text changes size. + # Otherwise grid will lag and only resizes if other mouse action occurs. + self._canvas.grid_forget() + self._canvas.grid(row=0, column=0, rowspan=1, columnspan=1, sticky="nsew") + + def destroy(self): + if isinstance(self._font, CTkFont): + self._font.remove_size_configure_callback(self._update_font) + + super().destroy() + + def _draw(self, no_color_updates=False): + super()._draw(no_color_updates) + + left_section_width = self._current_width - self._current_height + requires_recoloring = self.draw_engine.draw_rounded_rect_with_border_vertical_split(self._apply_widget_scaling(self._current_width), + self._apply_widget_scaling(self._current_height), + self._apply_widget_scaling(self._corner_radius), + self._apply_widget_scaling(self._border_width), + self._apply_widget_scaling(left_section_width)) + + requires_recoloring_2 = self.draw_engine.draw_dropdown_arrow(self._apply_widget_scaling(self._current_width - (self._current_height / 2)), + self._apply_widget_scaling(self._current_height / 2), + self._apply_widget_scaling(self._current_height / 3)) + + if no_color_updates is False or requires_recoloring or requires_recoloring_2: + + self._canvas.configure(bg=self._apply_appearance_mode(self._bg_color)) + + self._canvas.itemconfig("inner_parts_left", + outline=self._apply_appearance_mode(self._fg_color), + fill=self._apply_appearance_mode(self._fg_color)) + self._canvas.itemconfig("border_parts_left", + outline=self._apply_appearance_mode(self._border_color), + fill=self._apply_appearance_mode(self._border_color)) + self._canvas.itemconfig("inner_parts_right", + outline=self._apply_appearance_mode(self._button_color), + fill=self._apply_appearance_mode(self._button_color)) + self._canvas.itemconfig("border_parts_right", + outline=self._apply_appearance_mode(self._button_color), + fill=self._apply_appearance_mode(self._button_color)) + + self._entry.configure(bg=self._apply_appearance_mode(self._fg_color), + fg=self._apply_appearance_mode(self._text_color), + readonlybackground=self._apply_appearance_mode(self._fg_color), + disabledbackground=self._apply_appearance_mode(self._fg_color), + disabledforeground=self._apply_appearance_mode(self._text_color_disabled), + highlightcolor=self._apply_appearance_mode(self._fg_color), + insertbackground=self._apply_appearance_mode(self._text_color)) + + if self._state == tkinter.DISABLED: + self._canvas.itemconfig("dropdown_arrow", + fill=self._apply_appearance_mode(self._text_color_disabled)) + else: + self._canvas.itemconfig("dropdown_arrow", + fill=self._apply_appearance_mode(self._text_color)) + + def _open_dropdown_menu(self): + self._dropdown_menu.open(self.winfo_rootx(), + self.winfo_rooty() + self._apply_widget_scaling(self._current_height + 0)) + + def configure(self, require_redraw=False, **kwargs): + if "corner_radius" in kwargs: + self._corner_radius = kwargs.pop("corner_radius") + require_redraw = True + + if "border_width" in kwargs: + self._border_width = kwargs.pop("border_width") + self._create_grid() + require_redraw = True + + if "fg_color" in kwargs: + self._fg_color = self._check_color_type(kwargs.pop("fg_color")) + require_redraw = True + + if "border_color" in kwargs: + self._border_color = self._check_color_type(kwargs.pop("border_color")) + require_redraw = True + + if "button_color" in kwargs: + self._button_color = self._check_color_type(kwargs.pop("button_color")) + require_redraw = True + + if "button_hover_color" in kwargs: + self._button_hover_color = self._check_color_type(kwargs.pop("button_hover_color")) + require_redraw = True + + if "dropdown_fg_color" in kwargs: + self._dropdown_menu.configure(fg_color=kwargs.pop("dropdown_fg_color")) + + if "dropdown_hover_color" in kwargs: + self._dropdown_menu.configure(hover_color=kwargs.pop("dropdown_hover_color")) + + if "dropdown_text_color" in kwargs: + self._dropdown_menu.configure(text_color=kwargs.pop("dropdown_text_color")) + + if "text_color" in kwargs: + self._text_color = self._check_color_type(kwargs.pop("text_color")) + require_redraw = True + + if "text_color_disabled" in kwargs: + self._text_color_disabled = self._check_color_type(kwargs.pop("text_color_disabled")) + require_redraw = True + + if "font" in kwargs: + if isinstance(self._font, CTkFont): + self._font.remove_size_configure_callback(self._update_font) + self._font = self._check_font_type(kwargs.pop("font")) + if isinstance(self._font, CTkFont): + self._font.add_size_configure_callback(self._update_font) + + self._update_font() + + if "dropdown_font" in kwargs: + self._dropdown_menu.configure(font=kwargs.pop("dropdown_font")) + + if "values" in kwargs: + self._values = kwargs.pop("values") + self._dropdown_menu.configure(values=self._values) + + if "state" in kwargs: + self._state = kwargs.pop("state") + self._entry.configure(state=self._state) + require_redraw = True + + if "hover" in kwargs: + self._hover = kwargs.pop("hover") + + if "variable" in kwargs: + self._variable = kwargs.pop("variable") + self._entry.configure(textvariable=self._variable) + + if "command" in kwargs: + self._command = kwargs.pop("command") + + if "justify" in kwargs: + self._entry.configure(justify=kwargs.pop("justify")) + + super().configure(require_redraw=require_redraw, **kwargs) + + def cget(self, attribute_name: str) -> any: + if attribute_name == "corner_radius": + return self._corner_radius + elif attribute_name == "border_width": + return self._border_width + + elif attribute_name == "fg_color": + return self._fg_color + elif attribute_name == "border_color": + return self._border_color + elif attribute_name == "button_color": + return self._button_color + elif attribute_name == "button_hover_color": + return self._button_hover_color + elif attribute_name == "dropdown_fg_color": + return self._dropdown_menu.cget("fg_color") + elif attribute_name == "dropdown_hover_color": + return self._dropdown_menu.cget("hover_color") + elif attribute_name == "dropdown_text_color": + return self._dropdown_menu.cget("text_color") + elif attribute_name == "text_color": + return self._text_color + elif attribute_name == "text_color_disabled": + return self._text_color_disabled + + elif attribute_name == "font": + return self._font + elif attribute_name == "dropdown_font": + return self._dropdown_menu.cget("font") + elif attribute_name == "values": + return copy.copy(self._values) + elif attribute_name == "state": + return self._state + elif attribute_name == "hover": + return self._hover + elif attribute_name == "variable": + return self._variable + elif attribute_name == "command": + return self._command + elif attribute_name == "justify": + return self._entry.cget("justify") + else: + return super().cget(attribute_name) + + def _on_enter(self, event=0): + if self._hover is True and self._state == tkinter.NORMAL and len(self._values) > 0: + if sys.platform == "darwin" and len(self._values) > 0 and self._cursor_manipulation_enabled: + self._canvas.configure(cursor="pointinghand") + elif sys.platform.startswith("win") and len(self._values) > 0 and self._cursor_manipulation_enabled: + self._canvas.configure(cursor="hand2") + + # set color of inner button parts to hover color + self._canvas.itemconfig("inner_parts_right", + outline=self._apply_appearance_mode(self._button_hover_color), + fill=self._apply_appearance_mode(self._button_hover_color)) + self._canvas.itemconfig("border_parts_right", + outline=self._apply_appearance_mode(self._button_hover_color), + fill=self._apply_appearance_mode(self._button_hover_color)) + + def _on_leave(self, event=0): + if sys.platform == "darwin" and len(self._values) > 0 and self._cursor_manipulation_enabled: + self._canvas.configure(cursor="arrow") + elif sys.platform.startswith("win") and len(self._values) > 0 and self._cursor_manipulation_enabled: + self._canvas.configure(cursor="arrow") + + # set color of inner button parts + self._canvas.itemconfig("inner_parts_right", + outline=self._apply_appearance_mode(self._button_color), + fill=self._apply_appearance_mode(self._button_color)) + self._canvas.itemconfig("border_parts_right", + outline=self._apply_appearance_mode(self._button_color), + fill=self._apply_appearance_mode(self._button_color)) + + def _dropdown_callback(self, value: str): + if self._state == "readonly": + self._entry.configure(state="normal") + self._entry.delete(0, tkinter.END) + self._entry.insert(0, value) + self._entry.configure(state="readonly") + else: + self._entry.delete(0, tkinter.END) + self._entry.insert(0, value) + + if self._command is not None: + self._command(value) + + def set(self, value: str): + if self._state == "readonly": + self._entry.configure(state="normal") + self._entry.delete(0, tkinter.END) + self._entry.insert(0, value) + self._entry.configure(state="readonly") + else: + self._entry.delete(0, tkinter.END) + self._entry.insert(0, value) + + def get(self) -> str: + return self._entry.get() + + def _clicked(self, event=None): + if self._state is not tkinter.DISABLED and len(self._values) > 0: + self._open_dropdown_menu() + + def bind(self, sequence=None, command=None, add=True): + """ called on the tkinter.Entry """ + if not (add == "+" or add is True): + raise ValueError("'add' argument can only be '+' or True to preserve internal callbacks") + self._entry.bind(sequence, command, add=True) + + def unbind(self, sequence=None, funcid=None): + """ called on the tkinter.Entry """ + if funcid is not None: + raise ValueError("'funcid' argument can only be None, because there is a bug in" + + " tkinter and its not clear whether the internal callbacks will be unbinded or not") + self._entry.unbind(sequence, None) # unbind all callbacks for sequence + self._create_bindings(sequence=sequence) # restore internal callbacks for sequence + + def focus(self): + return self._entry.focus() + + def focus_set(self): + return self._entry.focus_set() + + def focus_force(self): + return self._entry.focus_force() diff --git a/customtkinter/windows/widgets/ctk_entry.py b/customtkinter/windows/widgets/ctk_entry.py new file mode 100644 index 0000000..6a2560b --- /dev/null +++ b/customtkinter/windows/widgets/ctk_entry.py @@ -0,0 +1,384 @@ +import tkinter +from typing import Union, Tuple, Optional + +from .core_rendering import CTkCanvas +from .theme import ThemeManager +from .core_rendering import DrawEngine +from .core_widget_classes import CTkBaseClass +from .font import CTkFont +from .utility import pop_from_dict_by_set, check_kwargs_empty + + +class CTkEntry(CTkBaseClass): + """ + Entry with rounded corners, border, textvariable support, focus and placeholder. + For detailed information check out the documentation. + """ + + _minimum_x_padding = 6 # minimum padding between tkinter entry and frame border + + # attributes that are passed to and managed by the tkinter entry only: + _valid_tk_entry_attributes = {"exportselection", "insertborderwidth", "insertofftime", + "insertontime", "insertwidth", "justify", "selectborderwidth", + "show", "takefocus", "validate", "validatecommand", "xscrollcommand"} + + def __init__(self, + master: any, + width: int = 140, + height: int = 28, + corner_radius: Optional[int] = None, + border_width: Optional[int] = None, + + bg_color: Union[str, Tuple[str, str]] = "transparent", + fg_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, + placeholder_text_color: Optional[Union[str, Tuple[str, str]]] = None, + + textvariable: Union[tkinter.Variable, None] = None, + placeholder_text: Union[str, None] = None, + font: Optional[Union[tuple, CTkFont]] = None, + state: str = tkinter.NORMAL, + **kwargs): + + # transfer basic functionality (bg_color, size, appearance_mode, scaling) to CTkBaseClass + super().__init__(master=master, bg_color=bg_color, width=width, height=height) + + # configure grid system (1x1) + self.grid_rowconfigure(0, weight=1) + self.grid_columnconfigure(0, weight=1) + + # color + self._fg_color = ThemeManager.theme["CTkEntry"]["fg_color"] if fg_color is None else self._check_color_type(fg_color, transparency=True) + self._text_color = ThemeManager.theme["CTkEntry"]["text_color"] if text_color is None else self._check_color_type(text_color) + self._placeholder_text_color = ThemeManager.theme["CTkEntry"]["placeholder_text_color"] if placeholder_text_color is None else self._check_color_type(placeholder_text_color) + self._border_color = ThemeManager.theme["CTkEntry"]["border_color"] if border_color is None else self._check_color_type(border_color) + + # shape + self._corner_radius = ThemeManager.theme["CTkEntry"]["corner_radius"] if corner_radius is None else corner_radius + self._border_width = ThemeManager.theme["CTkEntry"]["border_width"] if border_width is None else border_width + + # text and state + self._is_focused: bool = True + self._placeholder_text = placeholder_text + self._placeholder_text_active = False + self._pre_placeholder_arguments = {} # some set arguments of the entry will be changed for placeholder and then set back + self._textvariable = textvariable + self._state = state + self._textvariable_callback_name: str = "" + + # font + self._font = CTkFont() if font is None else self._check_font_type(font) + if isinstance(self._font, CTkFont): + self._font.add_size_configure_callback(self._update_font) + + if not (self._textvariable is None or self._textvariable == ""): + self._textvariable_callback_name = self._textvariable.trace_add("write", self._textvariable_callback) + + self._canvas = CTkCanvas(master=self, + highlightthickness=0, + width=self._apply_widget_scaling(self._current_width), + height=self._apply_widget_scaling(self._current_height)) + self._draw_engine = DrawEngine(self._canvas) + + self._entry = tkinter.Entry(master=self, + bd=0, + width=1, + highlightthickness=0, + font=self._apply_font_scaling(self._font), + state=self._state, + textvariable=self._textvariable, + **pop_from_dict_by_set(kwargs, self._valid_tk_entry_attributes)) + + check_kwargs_empty(kwargs, raise_error=True) + + self._create_grid() + self._activate_placeholder() + self._create_bindings() + self._draw() + + def _create_bindings(self, sequence: Optional[str] = None): + """ set necessary bindings for functionality of widget, will overwrite other bindings """ + if sequence is None or sequence == "": + self._entry.bind("", self._entry_focus_in) + if sequence is None or sequence == "": + self._entry.bind("", self._entry_focus_out) + + def _create_grid(self): + self._canvas.grid(column=0, row=0, sticky="nswe") + + if self._corner_radius >= self._minimum_x_padding: + self._entry.grid(column=0, row=0, sticky="nswe", + padx=min(self._apply_widget_scaling(self._corner_radius), round(self._apply_widget_scaling(self._current_height/2))), + pady=(self._apply_widget_scaling(self._border_width), self._apply_widget_scaling(self._border_width + 1))) + else: + self._entry.grid(column=0, row=0, sticky="nswe", + padx=self._apply_widget_scaling(self._minimum_x_padding), + pady=(self._apply_widget_scaling(self._border_width), self._apply_widget_scaling(self._border_width + 1))) + + def _textvariable_callback(self, var_name, index, mode): + if self._textvariable.get() == "": + self._activate_placeholder() + + def _set_scaling(self, *args, **kwargs): + super()._set_scaling(*args, **kwargs) + + self._entry.configure(font=self._apply_font_scaling(self._font)) + self._canvas.configure(width=self._apply_widget_scaling(self._desired_width), height=self._apply_widget_scaling(self._desired_height)) + self._create_grid() + self._draw(no_color_updates=True) + + def _set_dimensions(self, width=None, height=None): + super()._set_dimensions(width, height) + + self._canvas.configure(width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height)) + self._draw() + + def _update_font(self): + """ pass font to tkinter widgets with applied font scaling and update grid with workaround """ + self._entry.configure(font=self._apply_font_scaling(self._font)) + + # Workaround to force grid to be resized when text changes size. + # Otherwise grid will lag and only resizes if other mouse action occurs. + self._canvas.grid_forget() + self._canvas.grid(column=0, row=0, sticky="nswe") + + def destroy(self): + if isinstance(self._font, CTkFont): + self._font.remove_size_configure_callback(self._update_font) + + super().destroy() + + def _draw(self, no_color_updates=False): + super()._draw(no_color_updates) + + self._canvas.configure(bg=self._apply_appearance_mode(self._bg_color)) + + requires_recoloring = self._draw_engine.draw_rounded_rect_with_border(self._apply_widget_scaling(self._current_width), + self._apply_widget_scaling(self._current_height), + self._apply_widget_scaling(self._corner_radius), + self._apply_widget_scaling(self._border_width)) + + if requires_recoloring or no_color_updates is False: + if self._apply_appearance_mode(self._fg_color) == "transparent": + self._canvas.itemconfig("inner_parts", + fill=self._apply_appearance_mode(self._bg_color), + outline=self._apply_appearance_mode(self._bg_color)) + self._entry.configure(bg=self._apply_appearance_mode(self._bg_color), + disabledbackground=self._apply_appearance_mode(self._bg_color), + readonlybackground=self._apply_appearance_mode(self._bg_color), + highlightcolor=self._apply_appearance_mode(self._bg_color)) + else: + self._canvas.itemconfig("inner_parts", + fill=self._apply_appearance_mode(self._fg_color), + outline=self._apply_appearance_mode(self._fg_color)) + self._entry.configure(bg=self._apply_appearance_mode(self._fg_color), + disabledbackground=self._apply_appearance_mode(self._fg_color), + readonlybackground=self._apply_appearance_mode(self._fg_color), + highlightcolor=self._apply_appearance_mode(self._fg_color)) + + self._canvas.itemconfig("border_parts", + fill=self._apply_appearance_mode(self._border_color), + outline=self._apply_appearance_mode(self._border_color)) + + if self._placeholder_text_active: + self._entry.config(fg=self._apply_appearance_mode(self._placeholder_text_color), + disabledforeground=self._apply_appearance_mode(self._placeholder_text_color), + insertbackground=self._apply_appearance_mode(self._placeholder_text_color)) + else: + self._entry.config(fg=self._apply_appearance_mode(self._text_color), + disabledforeground=self._apply_appearance_mode(self._text_color), + insertbackground=self._apply_appearance_mode(self._text_color)) + + def configure(self, require_redraw=False, **kwargs): + if "state" in kwargs: + self._state = kwargs.pop("state") + self._entry.configure(state=self._state) + + if "fg_color" in kwargs: + self._fg_color = self._check_color_type(kwargs.pop("fg_color")) + require_redraw = True + + if "text_color" in kwargs: + self._text_color = self._check_color_type(kwargs.pop("text_color")) + require_redraw = True + + if "placeholder_text_color" in kwargs: + self._placeholder_text_color = self._check_color_type(kwargs.pop("placeholder_text_color")) + require_redraw = True + + if "border_color" in kwargs: + self._border_color = self._check_color_type(kwargs.pop("border_color")) + require_redraw = True + + if "border_width" in kwargs: + self._border_width = kwargs.pop("border_width") + self._create_grid() + require_redraw = True + + if "corner_radius" in kwargs: + self._corner_radius = kwargs.pop("corner_radius") + self._create_grid() + require_redraw = True + + if "placeholder_text" in kwargs: + self._placeholder_text = kwargs.pop("placeholder_text") + if self._placeholder_text_active: + self._entry.delete(0, tkinter.END) + self._entry.insert(0, self._placeholder_text) + else: + self._activate_placeholder() + + if "textvariable" in kwargs: + self._textvariable = kwargs.pop("textvariable") + self._entry.configure(textvariable=self._textvariable) + + if "font" in kwargs: + if isinstance(self._font, CTkFont): + self._font.remove_size_configure_callback(self._update_font) + self._font = self._check_font_type(kwargs.pop("font")) + if isinstance(self._font, CTkFont): + self._font.add_size_configure_callback(self._update_font) + + self._update_font() + + if "show" in kwargs: + if self._placeholder_text_active: + self._pre_placeholder_arguments["show"] = kwargs.pop("show") # remember show argument for when placeholder gets deactivated + else: + self._entry.configure(show=kwargs.pop("show")) + + self._entry.configure(**pop_from_dict_by_set(kwargs, self._valid_tk_entry_attributes)) # configure Tkinter.Entry + super().configure(require_redraw=require_redraw, **kwargs) # configure CTkBaseClass + + def cget(self, attribute_name: str) -> any: + if attribute_name == "corner_radius": + return self._corner_radius + elif attribute_name == "border_width": + return self._border_width + + elif attribute_name == "fg_color": + return self._fg_color + elif attribute_name == "border_color": + return self._border_color + elif attribute_name == "text_color": + return self._text_color + elif attribute_name == "placeholder_text_color": + return self._placeholder_text_color + + elif attribute_name == "textvariable": + return self._textvariable + elif attribute_name == "placeholder_text": + return self._placeholder_text + elif attribute_name == "font": + return self._font + elif attribute_name == "state": + return self._state + + elif attribute_name in self._valid_tk_entry_attributes: + return self._entry.cget(attribute_name) # cget of tkinter.Entry + else: + return super().cget(attribute_name) # cget of CTkBaseClass + + def bind(self, sequence=None, command=None, add=True): + """ called on the tkinter.Entry """ + if not (add == "+" or add is True): + raise ValueError("'add' argument can only be '+' or True to preserve internal callbacks") + self._entry.bind(sequence, command, add=True) + + def unbind(self, sequence=None, funcid=None): + """ called on the tkinter.Entry """ + if funcid is not None: + raise ValueError("'funcid' argument can only be None, because there is a bug in" + + " tkinter and its not clear whether the internal callbacks will be unbinded or not") + self._entry.unbind(sequence, None) # unbind all callbacks for sequence + self._create_bindings(sequence=sequence) # restore internal callbacks for sequence + + def _activate_placeholder(self): + if self._entry.get() == "" and self._placeholder_text is not None and (self._textvariable is None or self._textvariable == ""): + self._placeholder_text_active = True + + self._pre_placeholder_arguments = {"show": self._entry.cget("show")} + self._entry.config(fg=self._apply_appearance_mode(self._placeholder_text_color), + disabledforeground=self._apply_appearance_mode(self._placeholder_text_color), + show="") + self._entry.delete(0, tkinter.END) + self._entry.insert(0, self._placeholder_text) + + def _deactivate_placeholder(self): + if self._placeholder_text_active and self._entry.cget("state") != "readonly": + self._placeholder_text_active = False + + self._entry.config(fg=self._apply_appearance_mode(self._text_color), + disabledforeground=self._apply_appearance_mode(self._text_color),) + self._entry.delete(0, tkinter.END) + for argument, value in self._pre_placeholder_arguments.items(): + self._entry[argument] = value + + def _entry_focus_out(self, event=None): + self._activate_placeholder() + self._is_focused = False + + def _entry_focus_in(self, event=None): + self._deactivate_placeholder() + self._is_focused = True + + def delete(self, first_index, last_index=None): + self._entry.delete(first_index, last_index) + + if not self._is_focused and self._entry.get() == "": + self._activate_placeholder() + + def insert(self, index, string): + self._deactivate_placeholder() + + return self._entry.insert(index, string) + + def get(self): + if self._placeholder_text_active: + return "" + else: + return self._entry.get() + + def focus(self): + return self._entry.focus() + + def focus_set(self): + return self._entry.focus_set() + + def focus_force(self): + return self._entry.focus_force() + + def index(self, index): + return self._entry.index(index) + + def icursor(self, index): + return self._entry.icursor(index) + + def select_adjust(self, index): + return self._entry.select_adjust(index) + + def select_from(self, index): + return self._entry.icursor(index) + + def select_clear(self): + return self._entry.select_clear() + + def select_present(self): + return self._entry.select_present() + + def select_range(self, start_index, end_index): + return self._entry.select_range(start_index, end_index) + + def select_to(self, index): + return self._entry.select_to(index) + + def xview(self, index): + return self._entry.xview(index) + + def xview_moveto(self, f): + return self._entry.xview_moveto(f) + + def xview_scroll(self, number, what): + return self._entry.xview_scroll(number, what) diff --git a/customtkinter/windows/widgets/ctk_frame.py b/customtkinter/windows/widgets/ctk_frame.py new file mode 100644 index 0000000..fe9e226 --- /dev/null +++ b/customtkinter/windows/widgets/ctk_frame.py @@ -0,0 +1,196 @@ +from typing import Union, Tuple, List, Optional + +from .core_rendering import CTkCanvas +from .theme import ThemeManager +from .core_rendering import DrawEngine +from .core_widget_classes import CTkBaseClass + + +class CTkFrame(CTkBaseClass): + """ + Frame with rounded corners and border. + Default foreground colors are set according to theme. + To make the frame transparent set fg_color=None. + For detailed information check out the documentation. + """ + + def __init__(self, + master: any, + width: int = 200, + height: int = 200, + corner_radius: Optional[Union[int, str]] = None, + border_width: Optional[Union[int, str]] = None, + + bg_color: Union[str, Tuple[str, str]] = "transparent", + fg_color: Optional[Union[str, Tuple[str, str]]] = None, + border_color: Optional[Union[str, Tuple[str, str]]] = None, + background_corner_colors: Union[Tuple[Union[str, Tuple[str, str]]], None] = None, + + overwrite_preferred_drawing_method: Union[str, None] = None, + **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) + + # color + self._border_color = ThemeManager.theme["CTkFrame"]["border_color"] if border_color is None else self._check_color_type(border_color) + + # determine fg_color of frame + if fg_color is None: + if isinstance(self.master, CTkFrame): + if self.master._fg_color == ThemeManager.theme["CTkFrame"]["fg_color"]: + self._fg_color = ThemeManager.theme["CTkFrame"]["top_fg_color"] + else: + self._fg_color = ThemeManager.theme["CTkFrame"]["fg_color"] + else: + self._fg_color = ThemeManager.theme["CTkFrame"]["fg_color"] + else: + self._fg_color = self._check_color_type(fg_color, transparency=True) + + self._background_corner_colors = background_corner_colors # rendering options for DrawEngine + + # shape + self._corner_radius = ThemeManager.theme["CTkFrame"]["corner_radius"] if corner_radius is None else corner_radius + self._border_width = ThemeManager.theme["CTkFrame"]["border_width"] if border_width is None else border_width + + self._canvas = CTkCanvas(master=self, + highlightthickness=0, + width=self._apply_widget_scaling(self._current_width), + height=self._apply_widget_scaling(self._current_height)) + self._canvas.place(x=0, y=0, relwidth=1, relheight=1) + self._canvas.configure(bg=self._apply_appearance_mode(self._bg_color)) + self._draw_engine = DrawEngine(self._canvas) + self._overwrite_preferred_drawing_method = overwrite_preferred_drawing_method + + self._draw(no_color_updates=True) + + def winfo_children(self) -> List[any]: + """ + winfo_children of CTkFrame without self.canvas widget, + because it's not a child but part of the CTkFrame itself + """ + + child_widgets = super().winfo_children() + try: + child_widgets.remove(self._canvas) + return child_widgets + except ValueError: + return child_widgets + + def _set_scaling(self, *args, **kwargs): + super()._set_scaling(*args, **kwargs) + + self._canvas.configure(width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height)) + self._draw() + + def _set_dimensions(self, width=None, height=None): + super()._set_dimensions(width, height) + + self._canvas.configure(width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height)) + self._draw() + + def _draw(self, no_color_updates=False): + super()._draw(no_color_updates) + + if not self._canvas.winfo_exists(): + return + + if self._background_corner_colors is not None: + self._draw_engine.draw_background_corners(self._apply_widget_scaling(self._current_width), + self._apply_widget_scaling(self._current_height)) + self._canvas.itemconfig("background_corner_top_left", fill=self._apply_appearance_mode(self._background_corner_colors[0])) + self._canvas.itemconfig("background_corner_top_right", fill=self._apply_appearance_mode(self._background_corner_colors[1])) + self._canvas.itemconfig("background_corner_bottom_right", fill=self._apply_appearance_mode(self._background_corner_colors[2])) + self._canvas.itemconfig("background_corner_bottom_left", fill=self._apply_appearance_mode(self._background_corner_colors[3])) + else: + self._canvas.delete("background_parts") + + requires_recoloring = self._draw_engine.draw_rounded_rect_with_border(self._apply_widget_scaling(self._current_width), + self._apply_widget_scaling(self._current_height), + self._apply_widget_scaling(self._corner_radius), + self._apply_widget_scaling(self._border_width), + overwrite_preferred_drawing_method=self._overwrite_preferred_drawing_method) + + if no_color_updates is False or requires_recoloring: + if self._fg_color == "transparent": + self._canvas.itemconfig("inner_parts", + fill=self._apply_appearance_mode(self._bg_color), + outline=self._apply_appearance_mode(self._bg_color)) + else: + self._canvas.itemconfig("inner_parts", + fill=self._apply_appearance_mode(self._fg_color), + outline=self._apply_appearance_mode(self._fg_color)) + + self._canvas.itemconfig("border_parts", + fill=self._apply_appearance_mode(self._border_color), + outline=self._apply_appearance_mode(self._border_color)) + self._canvas.configure(bg=self._apply_appearance_mode(self._bg_color)) + + # self._canvas.tag_lower("inner_parts") # maybe unnecessary, I don't know ??? + # self._canvas.tag_lower("border_parts") + + def configure(self, require_redraw=False, **kwargs): + if "fg_color" in kwargs: + self._fg_color = self._check_color_type(kwargs.pop("fg_color"), transparency=True) + require_redraw = True + + # check if CTk widgets are children of the frame and change their bg_color to new frame fg_color + for child in self.winfo_children(): + if isinstance(child, CTkBaseClass): + child.configure(bg_color=self._fg_color) + + if "bg_color" in kwargs: + # pass bg_color change to children if fg_color is "transparent" + if self._fg_color == "transparent": + for child in self.winfo_children(): + if isinstance(child, CTkBaseClass): + child.configure(bg_color=self._fg_color) + + if "border_color" in kwargs: + self._border_color = self._check_color_type(kwargs.pop("border_color")) + require_redraw = True + + if "background_corner_colors" in kwargs: + self._background_corner_colors = kwargs.pop("background_corner_colors") + require_redraw = True + + if "corner_radius" in kwargs: + self._corner_radius = kwargs.pop("corner_radius") + require_redraw = True + + if "border_width" in kwargs: + self._border_width = kwargs.pop("border_width") + require_redraw = True + + super().configure(require_redraw=require_redraw, **kwargs) + + def cget(self, attribute_name: str) -> any: + if attribute_name == "corner_radius": + return self._corner_radius + elif attribute_name == "border_width": + return self._border_width + + elif attribute_name == "fg_color": + return self._fg_color + elif attribute_name == "border_color": + return self._border_color + elif attribute_name == "background_corner_colors": + return self._background_corner_colors + + else: + return super().cget(attribute_name) + + def bind(self, sequence=None, command=None, add=True): + """ called on the tkinter.Canvas """ + if not (add == "+" or add is True): + raise ValueError("'add' argument can only be '+' or True to preserve internal callbacks") + self._canvas.bind(sequence, command, add=True) + + def unbind(self, sequence=None, funcid=None): + """ called on the tkinter.Canvas """ + if funcid is not None: + raise ValueError("'funcid' argument can only be None, because there is a bug in" + + " tkinter and its not clear whether the internal callbacks will be unbinded or not") + self._canvas.unbind(sequence, None) diff --git a/customtkinter/windows/widgets/ctk_label.py b/customtkinter/windows/widgets/ctk_label.py new file mode 100644 index 0000000..800c58d --- /dev/null +++ b/customtkinter/windows/widgets/ctk_label.py @@ -0,0 +1,272 @@ +import tkinter +from typing import Union, Tuple, Callable, Optional + +from .core_rendering import CTkCanvas +from .theme import ThemeManager +from .core_rendering import DrawEngine +from .core_widget_classes import CTkBaseClass +from .font import CTkFont +from .image import CTkImage +from .utility import pop_from_dict_by_set, check_kwargs_empty + + +class CTkLabel(CTkBaseClass): + """ + Label with rounded corners. Default is fg_color=None (transparent fg_color). + For detailed information check out the documentation. + """ + + # attributes that are passed to and managed by the tkinter entry only: + _valid_tk_label_attributes = {"cursor", "justify", "padx", "pady", + "textvariable", "state", "takefocus", "underline"} + + def __init__(self, + master: any, + width: int = 0, + height: int = 28, + corner_radius: Optional[int] = None, + + bg_color: Union[str, Tuple[str, str]] = "transparent", + fg_color: Optional[Union[str, Tuple[str, str]]] = None, + text_color: Optional[Union[str, Tuple[str, str]]] = None, + + text: str = "CTkLabel", + font: Optional[Union[tuple, CTkFont]] = None, + image: Union[CTkImage, None] = None, + compound: str = "center", + anchor: str = "center", # label anchor: center, n, e, s, w + wraplength: int = 0, + **kwargs): + + # transfer basic functionality (_bg_color, size, __appearance_mode, scaling) to CTkBaseClass + super().__init__(master=master, bg_color=bg_color, width=width, height=height) + + # color + self._fg_color = ThemeManager.theme["CTkLabel"]["fg_color"] if fg_color is None else self._check_color_type(fg_color, transparency=True) + self._text_color = ThemeManager.theme["CTkLabel"]["text_color"] if text_color is None else self._check_color_type(text_color) + + # shape + self._corner_radius = ThemeManager.theme["CTkLabel"]["corner_radius"] if corner_radius is None else corner_radius + + # text + self._anchor = anchor + self._text = text + self._wraplength = wraplength + + # image + self._image = self._check_image_type(image) + self._compound = compound + if isinstance(self._image, CTkImage): + self._image.add_configure_callback(self._update_image) + + # font + self._font = CTkFont() if font is None else self._check_font_type(font) + if isinstance(self._font, CTkFont): + self._font.add_size_configure_callback(self._update_font) + + # configure grid system (1x1) + self.grid_rowconfigure(0, weight=1) + self.grid_columnconfigure(0, weight=1) + + self._canvas = CTkCanvas(master=self, + highlightthickness=0, + width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height)) + self._canvas.grid(row=0, column=0, sticky="nswe") + self._draw_engine = DrawEngine(self._canvas) + + self._label = tkinter.Label(master=self, + highlightthickness=0, + padx=0, + pady=0, + borderwidth=0, + anchor=self._anchor, + compound=self._compound, + wraplength=self._apply_widget_scaling(self._wraplength), + text=self._text, + font=self._apply_font_scaling(self._font)) + self._label.configure(**pop_from_dict_by_set(kwargs, self._valid_tk_label_attributes)) + + check_kwargs_empty(kwargs, raise_error=True) + + self._create_grid() + self._update_image() + self._draw() + + def _set_scaling(self, *args, **kwargs): + super()._set_scaling(*args, **kwargs) + + self._canvas.configure(width=self._apply_widget_scaling(self._desired_width), height=self._apply_widget_scaling(self._desired_height)) + self._label.configure(font=self._apply_font_scaling(self._font)) + self._label.configure(wraplength=self._apply_widget_scaling(self._wraplength)) + + self._create_grid() + self._update_image() + self._draw(no_color_updates=True) + + def _set_appearance_mode(self, mode_string): + super()._set_appearance_mode(mode_string) + self._update_image() + + def _set_dimensions(self, width=None, height=None): + super()._set_dimensions(width, height) + + self._canvas.configure(width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height)) + self._create_grid() + self._draw() + + def _update_font(self): + """ pass font to tkinter widgets with applied font scaling and update grid with workaround """ + self._label.configure(font=self._apply_font_scaling(self._font)) + + # Workaround to force grid to be resized when text changes size. + # Otherwise grid will lag and only resizes if other mouse action occurs. + self._canvas.grid_forget() + self._canvas.grid(row=0, column=0, sticky="nswe") + + def _update_image(self): + if isinstance(self._image, CTkImage): + self._label.configure(image=self._image.create_scaled_photo_image(self._get_widget_scaling(), + self._get_appearance_mode())) + elif self._image is not None: + self._label.configure(image=self._image) + + def destroy(self): + if isinstance(self._font, CTkFont): + self._font.remove_size_configure_callback(self._update_font) + super().destroy() + + def _create_grid(self): + """ configure grid system (1x1) """ + + text_label_grid_sticky = self._anchor if self._anchor != "center" else "" + self._label.grid(row=0, column=0, sticky=text_label_grid_sticky, + padx=self._apply_widget_scaling(min(self._corner_radius, round(self._current_height / 2)))) + + def _draw(self, no_color_updates=False): + super()._draw(no_color_updates) + + requires_recoloring = self._draw_engine.draw_rounded_rect_with_border(self._apply_widget_scaling(self._current_width), + self._apply_widget_scaling(self._current_height), + self._apply_widget_scaling(self._corner_radius), + 0) + + if no_color_updates is False or requires_recoloring: + if self._apply_appearance_mode(self._fg_color) == "transparent": + self._canvas.itemconfig("inner_parts", + fill=self._apply_appearance_mode(self._bg_color), + outline=self._apply_appearance_mode(self._bg_color)) + + self._label.configure(fg=self._apply_appearance_mode(self._text_color), + bg=self._apply_appearance_mode(self._bg_color)) + else: + self._canvas.itemconfig("inner_parts", + fill=self._apply_appearance_mode(self._fg_color), + outline=self._apply_appearance_mode(self._fg_color)) + + self._label.configure(fg=self._apply_appearance_mode(self._text_color), + bg=self._apply_appearance_mode(self._fg_color)) + + self._canvas.configure(bg=self._apply_appearance_mode(self._bg_color)) + + def configure(self, require_redraw=False, **kwargs): + if "corner_radius" in kwargs: + self._corner_radius = kwargs.pop("corner_radius") + self._create_grid() + require_redraw = True + + if "fg_color" in kwargs: + self._fg_color = self._check_color_type(kwargs.pop("fg_color"), transparency=True) + require_redraw = True + + if "text_color" in kwargs: + self._text_color = self._check_color_type(kwargs.pop("text_color")) + require_redraw = True + + if "text" in kwargs: + self._text = kwargs.pop("text") + self._label.configure(text=self._text) + + if "font" in kwargs: + if isinstance(self._font, CTkFont): + self._font.remove_size_configure_callback(self._update_font) + self._font = self._check_font_type(kwargs.pop("font")) + if isinstance(self._font, CTkFont): + self._font.add_size_configure_callback(self._update_font) + self._update_font() + + if "image" in kwargs: + if isinstance(self._image, CTkImage): + self._image.remove_configure_callback(self._update_image) + self._image = self._check_image_type(kwargs.pop("image")) + if isinstance(self._image, CTkImage): + self._image.add_configure_callback(self._update_image) + self._update_image() + + if "compound" in kwargs: + self._compound = kwargs.pop("compound") + self._label.configure(compound=self._compound) + + if "anchor" in kwargs: + self._anchor = kwargs.pop("anchor") + self._label.configure(anchor=self._anchor) + self._create_grid() + + if "wraplength" in kwargs: + self._wraplength = kwargs.pop("wraplength") + self._label.configure(wraplength=self._apply_widget_scaling(self._wraplength)) + + self._label.configure(**pop_from_dict_by_set(kwargs, self._valid_tk_label_attributes)) # configure tkinter.Label + super().configure(require_redraw=require_redraw, **kwargs) # configure CTkBaseClass + + def cget(self, attribute_name: str) -> any: + if attribute_name == "corner_radius": + return self._corner_radius + + elif attribute_name == "fg_color": + return self._fg_color + elif attribute_name == "text_color": + return self._text_color + + elif attribute_name == "text": + return self._text + elif attribute_name == "font": + return self._font + elif attribute_name == "image": + return self._image + elif attribute_name == "compound": + return self._compound + elif attribute_name == "anchor": + return self._anchor + elif attribute_name == "wraplength": + return self._wraplength + + elif attribute_name in self._valid_tk_label_attributes: + return self._label.cget(attribute_name) # cget of tkinter.Label + else: + return super().cget(attribute_name) # cget of CTkBaseClass + + def bind(self, sequence: str = None, command: Callable = None, add: str = True): + """ called on the tkinter.Label and tkinter.Canvas """ + if not (add == "+" or add is True): + raise ValueError("'add' argument can only be '+' or True to preserve internal callbacks") + self._canvas.bind(sequence, command, add=True) + self._label.bind(sequence, command, add=True) + + def unbind(self, sequence: str = None, funcid: Optional[str] = None): + """ called on the tkinter.Label and tkinter.Canvas """ + if funcid is not None: + raise ValueError("'funcid' argument can only be None, because there is a bug in" + + " tkinter and its not clear whether the internal callbacks will be unbinded or not") + self._canvas.unbind(sequence, None) + self._label.unbind(sequence, None) + + def focus(self): + return self._label.focus() + + def focus_set(self): + return self._label.focus_set() + + def focus_force(self): + return self._label.focus_force() diff --git a/customtkinter/windows/widgets/ctk_optionmenu.py b/customtkinter/windows/widgets/ctk_optionmenu.py new file mode 100644 index 0000000..e9fa96c --- /dev/null +++ b/customtkinter/windows/widgets/ctk_optionmenu.py @@ -0,0 +1,422 @@ +import tkinter +import copy +import sys +from typing import Union, Tuple, Callable, Optional + +from .core_rendering import CTkCanvas +from .theme import ThemeManager +from .core_rendering import DrawEngine +from .core_widget_classes import CTkBaseClass +from .core_widget_classes import DropdownMenu +from .font import CTkFont + + +class CTkOptionMenu(CTkBaseClass): + """ + Optionmenu with rounded corners, dropdown menu, variable support, command. + For detailed information check out the documentation. + """ + + def __init__(self, + master: any, + width: int = 140, + height: int = 28, + corner_radius: Optional[Union[int]] = None, + + bg_color: Union[str, Tuple[str, str]] = "transparent", + fg_color: Optional[Union[str, Tuple[str, str]]] = None, + button_color: Optional[Union[str, Tuple[str, str]]] = None, + button_hover_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, + dropdown_fg_color: Optional[Union[str, Tuple[str, str]]] = None, + dropdown_hover_color: Optional[Union[str, Tuple[str, str]]] = None, + dropdown_text_color: Optional[Union[str, Tuple[str, str]]] = None, + + font: Optional[Union[tuple, CTkFont]] = None, + dropdown_font: Optional[Union[tuple, CTkFont]] = None, + values: Optional[list] = None, + variable: Union[tkinter.Variable, None] = None, + state: str = tkinter.NORMAL, + hover: bool = True, + command: Union[Callable[[str], None], None] = None, + dynamic_resizing: bool = True, + anchor: str = "w", + **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) + + # color variables + self._fg_color = ThemeManager.theme["CTkOptionMenu"]["fg_color"] if fg_color is None else self._check_color_type(fg_color) + self._button_color = ThemeManager.theme["CTkOptionMenu"]["button_color"] if button_color is None else self._check_color_type(button_color) + self._button_hover_color = ThemeManager.theme["CTkOptionMenu"]["button_hover_color"] if button_hover_color is None else self._check_color_type(button_hover_color) + + # shape + self._corner_radius = ThemeManager.theme["CTkOptionMenu"]["corner_radius"] if corner_radius is None else corner_radius + + # text and font + self._text_color = ThemeManager.theme["CTkOptionMenu"]["text_color"] if text_color is None else self._check_color_type(text_color) + self._text_color_disabled = ThemeManager.theme["CTkOptionMenu"]["text_color_disabled"] if text_color_disabled is None else self._check_color_type(text_color_disabled) + + # font + self._font = CTkFont() if font is None else self._check_font_type(font) + if isinstance(self._font, CTkFont): + self._font.add_size_configure_callback(self._update_font) + + # callback and hover functionality + self._command = command + self._variable = variable + self._variable_callback_blocked: bool = False + self._variable_callback_name: Union[str, None] = None + self._state = state + self._hover = hover + self._dynamic_resizing = dynamic_resizing + + if values is None: + self._values = ["CTkOptionMenu"] + else: + self._values = values + + if len(self._values) > 0: + self._current_value = self._values[0] + else: + self._current_value = "CTkOptionMenu" + + self._dropdown_menu = DropdownMenu(master=self, + values=self._values, + command=self._dropdown_callback, + fg_color=dropdown_fg_color, + hover_color=dropdown_hover_color, + text_color=dropdown_text_color, + font=dropdown_font) + + # configure grid system (1x1) + self.grid_rowconfigure(0, weight=1) + self.grid_columnconfigure(0, weight=1) + + self._canvas = CTkCanvas(master=self, + highlightthickness=0, + width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height)) + self._draw_engine = DrawEngine(self._canvas) + + self._text_label = tkinter.Label(master=self, + font=self._apply_font_scaling(self._font), + anchor=anchor, + padx=0, + pady=0, + borderwidth=1, + text=self._current_value) + + if self._cursor_manipulation_enabled: + if sys.platform == "darwin": + self.configure(cursor="pointinghand") + elif sys.platform.startswith("win"): + self.configure(cursor="hand2") + + self._create_grid() + if not self._dynamic_resizing: + self.grid_propagate(0) + + self._create_bindings() + self._draw() # initial draw + + if self._variable is not None: + self._variable_callback_name = self._variable.trace_add("write", self._variable_callback) + self._current_value = self._variable.get() + self._text_label.configure(text=self._current_value) + + def _create_bindings(self, sequence: Optional[str] = None): + """ set necessary bindings for functionality of widget, will overwrite other bindings """ + if sequence is None or sequence == "": + self._canvas.bind("", self._on_enter) + self._text_label.bind("", self._on_enter) + if sequence is None or sequence == "": + self._canvas.bind("", self._on_leave) + self._text_label.bind("", self._on_leave) + if sequence is None or sequence == "": + self._canvas.bind("", self._clicked) + self._text_label.bind("", self._clicked) + + def _create_grid(self): + self._canvas.grid(row=0, column=0, sticky="nsew") + + left_section_width = self._current_width - self._current_height + self._text_label.grid(row=0, column=0, sticky="ew", + padx=(max(self._apply_widget_scaling(self._corner_radius), self._apply_widget_scaling(3)), + max(self._apply_widget_scaling(self._current_width - left_section_width + 3), self._apply_widget_scaling(3)))) + + def _set_scaling(self, *args, **kwargs): + super()._set_scaling(*args, **kwargs) + + # change label font size and grid padding + self._text_label.configure(font=self._apply_font_scaling(self._font)) + self._canvas.configure(width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height)) + self._create_grid() + self._draw(no_color_updates=True) + + def _set_dimensions(self, width: int = None, height: int = None): + super()._set_dimensions(width, height) + + self._canvas.configure(width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height)) + self._draw() + + def _update_font(self): + """ pass font to tkinter widgets with applied font scaling and update grid with workaround """ + self._text_label.configure(font=self._apply_font_scaling(self._font)) + + # Workaround to force grid to be resized when text changes size. + # Otherwise grid will lag and only resizes if other mouse action occurs. + self._canvas.grid_forget() + self._canvas.grid(row=0, column=0, sticky="nsew") + + def destroy(self): + if self._variable is not None: # remove old callback + self._variable.trace_remove("write", self._variable_callback_name) + + if isinstance(self._font, CTkFont): + self._font.remove_size_configure_callback(self._update_font) + + super().destroy() + + def _draw(self, no_color_updates=False): + super()._draw(no_color_updates) + + left_section_width = self._current_width - self._current_height + requires_recoloring = self._draw_engine.draw_rounded_rect_with_border_vertical_split(self._apply_widget_scaling(self._current_width), + self._apply_widget_scaling(self._current_height), + self._apply_widget_scaling(self._corner_radius), + 0, + self._apply_widget_scaling(left_section_width)) + + requires_recoloring_2 = self._draw_engine.draw_dropdown_arrow(self._apply_widget_scaling(self._current_width - (self._current_height / 2)), + self._apply_widget_scaling(self._current_height / 2), + self._apply_widget_scaling(self._current_height / 3)) + + if no_color_updates is False or requires_recoloring or requires_recoloring_2: + self._canvas.configure(bg=self._apply_appearance_mode(self._bg_color)) + + self._canvas.itemconfig("inner_parts_left", + outline=self._apply_appearance_mode(self._fg_color), + fill=self._apply_appearance_mode(self._fg_color)) + self._canvas.itemconfig("inner_parts_right", + outline=self._apply_appearance_mode(self._button_color), + fill=self._apply_appearance_mode(self._button_color)) + + self._text_label.configure(fg=self._apply_appearance_mode(self._text_color)) + + if self._state == tkinter.DISABLED: + self._text_label.configure(fg=(self._apply_appearance_mode(self._text_color_disabled))) + self._canvas.itemconfig("dropdown_arrow", + fill=self._apply_appearance_mode(self._text_color_disabled)) + else: + self._text_label.configure(fg=self._apply_appearance_mode(self._text_color)) + self._canvas.itemconfig("dropdown_arrow", + fill=self._apply_appearance_mode(self._text_color)) + + self._text_label.configure(bg=self._apply_appearance_mode(self._fg_color)) + + self._canvas.update_idletasks() + + def configure(self, require_redraw=False, **kwargs): + if "corner_radius" in kwargs: + self._corner_radius = kwargs.pop("corner_radius") + self._create_grid() + require_redraw = True + + if "fg_color" in kwargs: + self._fg_color = self._check_color_type(kwargs.pop("fg_color")) + require_redraw = True + + if "button_color" in kwargs: + self._button_color = self._check_color_type(kwargs.pop("button_color")) + require_redraw = True + + if "button_hover_color" in kwargs: + self._button_hover_color = self._check_color_type(kwargs.pop("button_hover_color")) + require_redraw = True + + if "text_color" in kwargs: + self._text_color = self._check_color_type(kwargs.pop("text_color")) + require_redraw = True + + if "dropdown_fg_color" in kwargs: + self._dropdown_menu.configure(fg_color=kwargs.pop("dropdown_fg_color")) + + if "dropdown_hover_color" in kwargs: + self._dropdown_menu.configure(hover_color=kwargs.pop("dropdown_hover_color")) + + if "dropdown_text_color" in kwargs: + self._dropdown_menu.configure(text_color=kwargs.pop("dropdown_text_color")) + + if "font" in kwargs: + if isinstance(self._font, CTkFont): + self._font.remove_size_configure_callback(self._update_font) + self._font = self._check_font_type(kwargs.pop("font")) + if isinstance(self._font, CTkFont): + self._font.add_size_configure_callback(self._update_font) + + self._update_font() + + if "command" in kwargs: + self._command = kwargs.pop("command") + + if "variable" in kwargs: + if self._variable is not None: # remove old callback + self._variable.trace_remove("write", self._variable_callback_name) + + self._variable = kwargs.pop("variable") + + if self._variable is not None and self._variable != "": + self._variable_callback_name = self._variable.trace_add("write", self._variable_callback) + self._current_value = self._variable.get() + self._text_label.configure(text=self._current_value) + else: + self._variable = None + + if "values" in kwargs: + self._values = kwargs.pop("values") + self._dropdown_menu.configure(values=self._values) + + if "dropdown_font" in kwargs: + self._dropdown_menu.configure(font=kwargs.pop("dropdown_font")) + + if "hover" in kwargs: + self._hover = kwargs.pop("hover") + + if "state" in kwargs: + self._state = kwargs.pop("state") + require_redraw = True + + if "dynamic_resizing" in kwargs: + self._dynamic_resizing = kwargs.pop("dynamic_resizing") + if not self._dynamic_resizing: + self.grid_propagate(0) + else: + self.grid_propagate(1) + + if "anchor" in kwargs: + self._text_label.configure(anchor=kwargs.pop("anchor")) + + super().configure(require_redraw=require_redraw, **kwargs) + + def cget(self, attribute_name: str) -> any: + if attribute_name == "corner_radius": + return self._corner_radius + + elif attribute_name == "fg_color": + return self._fg_color + elif attribute_name == "button_color": + return self._button_color + elif attribute_name == "button_hover_color": + return self._button_hover_color + elif attribute_name == "text_color": + return self._text_color + elif attribute_name == "text_color_disabled": + return self._text_color_disabled + elif attribute_name == "dropdown_fg_color": + return self._dropdown_menu.cget("fg_color") + elif attribute_name == "dropdown_hover_color": + return self._dropdown_menu.cget("hover_color") + elif attribute_name == "dropdown_text_color": + return self._dropdown_menu.cget("text_color") + + elif attribute_name == "font": + return self._font + elif attribute_name == "dropdown_font": + return self._dropdown_menu.cget("font") + elif attribute_name == "values": + return copy.copy(self._values) + elif attribute_name == "variable": + return self._variable + elif attribute_name == "state": + return self._state + elif attribute_name == "hover": + return self._hover + elif attribute_name == "command": + return self._command + elif attribute_name == "dynamic_resizing": + return self._dynamic_resizing + elif attribute_name == "anchor": + return self._text_label.cget("anchor") + + else: + return super().cget(attribute_name) + + def _open_dropdown_menu(self): + self._dropdown_menu.open(self.winfo_rootx(), + self.winfo_rooty() + self._apply_widget_scaling(self._current_height + 0)) + + def _on_enter(self, event=0): + if self._hover is True and self._state == tkinter.NORMAL and len(self._values) > 0: + # set color of inner button parts to hover color + self._canvas.itemconfig("inner_parts_right", + outline=self._apply_appearance_mode(self._button_hover_color), + fill=self._apply_appearance_mode(self._button_hover_color)) + + def _on_leave(self, event=0): + # set color of inner button parts + self._canvas.itemconfig("inner_parts_right", + outline=self._apply_appearance_mode(self._button_color), + fill=self._apply_appearance_mode(self._button_color)) + + def _variable_callback(self, var_name, index, mode): + if not self._variable_callback_blocked: + self._current_value = self._variable.get() + self._text_label.configure(text=self._current_value) + + def _dropdown_callback(self, value: str): + self._current_value = value + self._text_label.configure(text=self._current_value) + + if self._variable is not None: + self._variable_callback_blocked = True + self._variable.set(self._current_value) + self._variable_callback_blocked = False + + if self._command is not None: + self._command(self._current_value) + + def set(self, value: str): + self._current_value = value + self._text_label.configure(text=self._current_value) + + if self._variable is not None: + self._variable_callback_blocked = True + self._variable.set(self._current_value) + self._variable_callback_blocked = False + + def get(self) -> str: + return self._current_value + + def _clicked(self, event=0): + if self._state is not tkinter.DISABLED and len(self._values) > 0: + self._open_dropdown_menu() + + def bind(self, sequence: str = None, command: Callable = None, add: Union[str, bool] = True): + """ called on the tkinter.Canvas """ + if not (add == "+" or add is True): + raise ValueError("'add' argument can only be '+' or True to preserve internal callbacks") + self._canvas.bind(sequence, command, add=True) + self._text_label.bind(sequence, command, add=True) + + def unbind(self, sequence: str = None, funcid: str = None): + """ called on the tkinter.Label and tkinter.Canvas """ + if funcid is not None: + raise ValueError("'funcid' argument can only be None, because there is a bug in" + + " tkinter and its not clear whether the internal callbacks will be unbinded or not") + self._canvas.unbind(sequence, None) + self._text_label.unbind(sequence, None) + self._create_bindings(sequence=sequence) # restore internal callbacks for sequence + + def focus(self): + return self._text_label.focus() + + def focus_set(self): + return self._text_label.focus_set() + + def focus_force(self): + return self._text_label.focus_force() diff --git a/customtkinter/windows/widgets/ctk_progressbar.py b/customtkinter/windows/widgets/ctk_progressbar.py new file mode 100644 index 0000000..084354d --- /dev/null +++ b/customtkinter/windows/widgets/ctk_progressbar.py @@ -0,0 +1,312 @@ +import tkinter +import math +from typing import Union, Tuple, Optional, Callable +try: + from typing import Literal +except ImportError: + from typing_extensions import Literal + +from .core_rendering import CTkCanvas +from .theme import ThemeManager +from .core_rendering import DrawEngine +from .core_widget_classes import CTkBaseClass + + +class CTkProgressBar(CTkBaseClass): + """ + Progressbar with rounded corners, border, variable support, + indeterminate mode, vertical orientation. + For detailed information check out the documentation. + """ + + def __init__(self, + master: any, + width: Optional[int] = None, + height: Optional[int] = None, + corner_radius: Optional[int] = None, + border_width: Optional[int] = None, + + bg_color: Union[str, Tuple[str, str]] = "transparent", + fg_color: Optional[Union[str, Tuple[str, str]]] = None, + border_color: Optional[Union[str, Tuple[str, str]]] = None, + progress_color: Optional[Union[str, Tuple[str, str]]] = None, + + variable: Union[tkinter.Variable, None] = None, + orientation: str = "horizontal", + mode: Literal["determinate", "indeterminate"] = "determinate", + determinate_speed: float = 1, + indeterminate_speed: float = 1, + **kwargs): + + # set default dimensions according to orientation + if width is None: + if orientation.lower() == "vertical": + width = 8 + else: + width = 200 + if height is None: + if orientation.lower() == "vertical": + height = 200 + else: + height = 8 + + # transfer basic functionality (_bg_color, size, __appearance_mode, scaling) to CTkBaseClass + super().__init__(master=master, bg_color=bg_color, width=width, height=height, **kwargs) + + # color + self._border_color = ThemeManager.theme["CTkProgressBar"]["border_color"] if border_color is None else self._check_color_type(border_color) + self._fg_color = ThemeManager.theme["CTkProgressBar"]["fg_color"] if fg_color is None else self._check_color_type(fg_color) + self._progress_color = ThemeManager.theme["CTkProgressBar"]["progress_color"] if progress_color is None else self._check_color_type(progress_color) + + # control variable + self._variable = variable + self._variable_callback_blocked = False + self._variable_callback_name = None + self._loop_after_id = None + + # shape + self._corner_radius = ThemeManager.theme["CTkProgressBar"]["corner_radius"] if corner_radius is None else corner_radius + self._border_width = ThemeManager.theme["CTkProgressBar"]["border_width"] if border_width is None else border_width + self._determinate_value: float = 0.5 # range 0-1 + self._determinate_speed = determinate_speed # range 0-1 + self._indeterminate_value: float = 0 # range 0-inf + self._indeterminate_width: float = 0.4 # range 0-1 + self._indeterminate_speed = indeterminate_speed # range 0-1 to travel in 50ms + self._loop_running: bool = False + self._orientation = orientation + self._mode = mode # "determinate" or "indeterminate" + + self.grid_rowconfigure(0, weight=1) + self.grid_columnconfigure(0, weight=1) + + self._canvas = CTkCanvas(master=self, + highlightthickness=0, + width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height)) + self._canvas.grid(row=0, column=0, rowspan=1, columnspan=1, sticky="nswe") + self._draw_engine = DrawEngine(self._canvas) + + self._draw() # initial draw + + if self._variable is not None: + self._variable_callback_name = self._variable.trace_add("write", self._variable_callback) + self._variable_callback_blocked = True + self.set(self._variable.get(), from_variable_callback=True) + self._variable_callback_blocked = False + + def _set_scaling(self, *args, **kwargs): + super()._set_scaling(*args, **kwargs) + + self._canvas.configure(width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height)) + self._draw(no_color_updates=True) + + def _set_dimensions(self, width=None, height=None): + super()._set_dimensions(width, height) + + self._canvas.configure(width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height)) + self._draw() + + def destroy(self): + if self._variable is not None: + self._variable.trace_remove("write", self._variable_callback_name) + + super().destroy() + + def _draw(self, no_color_updates=False): + super()._draw(no_color_updates) + + if self._orientation.lower() == "horizontal": + orientation = "w" + elif self._orientation.lower() == "vertical": + orientation = "s" + else: + orientation = "w" + + if self._mode == "determinate": + requires_recoloring = self._draw_engine.draw_rounded_progress_bar_with_border(self._apply_widget_scaling(self._current_width), + self._apply_widget_scaling(self._current_height), + self._apply_widget_scaling(self._corner_radius), + self._apply_widget_scaling(self._border_width), + 0, + self._determinate_value, + orientation) + else: # indeterminate mode + progress_value = (math.sin(self._indeterminate_value * math.pi / 40) + 1) / 2 + progress_value_1 = min(1.0, progress_value + (self._indeterminate_width / 2)) + progress_value_2 = max(0.0, progress_value - (self._indeterminate_width / 2)) + + requires_recoloring = self._draw_engine.draw_rounded_progress_bar_with_border(self._apply_widget_scaling(self._current_width), + self._apply_widget_scaling(self._current_height), + self._apply_widget_scaling(self._corner_radius), + self._apply_widget_scaling(self._border_width), + progress_value_1, + progress_value_2, + orientation) + + if no_color_updates is False or requires_recoloring: + self._canvas.configure(bg=self._apply_appearance_mode(self._bg_color)) + self._canvas.itemconfig("border_parts", + fill=self._apply_appearance_mode(self._border_color), + outline=self._apply_appearance_mode(self._border_color)) + self._canvas.itemconfig("inner_parts", + fill=self._apply_appearance_mode(self._fg_color), + outline=self._apply_appearance_mode(self._fg_color)) + self._canvas.itemconfig("progress_parts", + fill=self._apply_appearance_mode(self._progress_color), + outline=self._apply_appearance_mode(self._progress_color)) + + def configure(self, require_redraw=False, **kwargs): + if "corner_radius" in kwargs: + self._corner_radius = kwargs.pop("corner_radius") + require_redraw = True + + if "border_width" in kwargs: + self._border_width = kwargs.pop("border_width") + require_redraw = True + + if "fg_color" in kwargs: + self._fg_color = self._check_color_type(kwargs.pop("fg_color")) + require_redraw = True + + if "border_color" in kwargs: + self._border_color = self._check_color_type(kwargs.pop("border_color")) + require_redraw = True + + if "progress_color" in kwargs: + self._progress_color = self._check_color_type(kwargs.pop("progress_color")) + require_redraw = True + + if "variable" in kwargs: + if self._variable is not None: + self._variable.trace_remove("write", self._variable_callback_name) + + self._variable = kwargs.pop("variable") + + if self._variable is not None and self._variable != "": + self._variable_callback_name = self._variable.trace_add("write", self._variable_callback) + self.set(self._variable.get(), from_variable_callback=True) + else: + self._variable = None + + if "mode" in kwargs: + self._mode = kwargs.pop("mode") + require_redraw = True + + if "determinate_speed" in kwargs: + self._determinate_speed = kwargs.pop("determinate_speed") + + if "indeterminate_speed" in kwargs: + self._indeterminate_speed = kwargs.pop("indeterminate_speed") + + super().configure(require_redraw=require_redraw, **kwargs) + + def cget(self, attribute_name: str) -> any: + if attribute_name == "corner_radius": + return self._corner_radius + elif attribute_name == "border_width": + return self._border_width + + elif attribute_name == "fg_color": + return self._fg_color + elif attribute_name == "border_color": + return self._border_color + elif attribute_name == "progress_color": + return self._progress_color + + elif attribute_name == "variable": + return self._variable + elif attribute_name == "orientation": + return self._orientation + elif attribute_name == "mode": + return self._mode + elif attribute_name == "determinate_speed": + return self._determinate_speed + elif attribute_name == "indeterminate_speed": + return self._indeterminate_speed + + else: + return super().cget(attribute_name) + + def _variable_callback(self, var_name, index, mode): + if not self._variable_callback_blocked: + self.set(self._variable.get(), from_variable_callback=True) + + def set(self, value, from_variable_callback=False): + """ set determinate value """ + self._determinate_value = value + + if self._determinate_value > 1: + self._determinate_value = 1 + elif self._determinate_value < 0: + self._determinate_value = 0 + + self._draw(no_color_updates=True) + + if self._variable is not None and not from_variable_callback: + self._variable_callback_blocked = True + self._variable.set(round(self._determinate_value) if isinstance(self._variable, tkinter.IntVar) else self._determinate_value) + self._variable_callback_blocked = False + + def get(self) -> float: + """ get determinate value """ + return self._determinate_value + + def start(self): + """ start automatic mode """ + if not self._loop_running: + self._loop_running = True + self._internal_loop() + + def stop(self): + """ stop automatic mode """ + if self._loop_after_id is not None: + self.after_cancel(self._loop_after_id) + self._loop_running = False + + def _internal_loop(self): + if self._loop_running: + if self._mode == "determinate": + self._determinate_value += self._determinate_speed / 50 + if self._determinate_value > 1: + self._determinate_value -= 1 + self._draw() + self._loop_after_id = self.after(20, self._internal_loop) + else: + self._indeterminate_value += self._indeterminate_speed + self._draw() + self._loop_after_id = self.after(20, self._internal_loop) + + def step(self): + """ increase progress """ + if self._mode == "determinate": + self._determinate_value += self._determinate_speed / 50 + if self._determinate_value > 1: + self._determinate_value -= 1 + self._draw() + else: + self._indeterminate_value += self._indeterminate_speed + self._draw() + + def bind(self, sequence: str = None, command: Callable = None, add: Union[str, bool] = True): + """ called on the tkinter.Canvas """ + if not (add == "+" or add is True): + raise ValueError("'add' argument can only be '+' or True to preserve internal callbacks") + self._canvas.bind(sequence, command, add=True) + + def unbind(self, sequence: str = None, funcid: str = None): + """ called on the tkinter.Label and tkinter.Canvas """ + if funcid is not None: + raise ValueError("'funcid' argument can only be None, because there is a bug in" + + " tkinter and its not clear whether the internal callbacks will be unbinded or not") + self._canvas.unbind(sequence, None) + + def focus(self): + return self._canvas.focus() + + def focus_set(self): + return self._canvas.focus_set() + + def focus_force(self): + return self._canvas.focus_force() diff --git a/customtkinter/windows/widgets/ctk_radiobutton.py b/customtkinter/windows/widgets/ctk_radiobutton.py new file mode 100644 index 0000000..d797be9 --- /dev/null +++ b/customtkinter/windows/widgets/ctk_radiobutton.py @@ -0,0 +1,430 @@ +import tkinter +import sys +from typing import Union, Tuple, Callable, Optional + +from .core_rendering import CTkCanvas +from .theme import ThemeManager +from .core_rendering import DrawEngine +from .core_widget_classes import CTkBaseClass +from .font import CTkFont + + +class CTkRadioButton(CTkBaseClass): + """ + Radiobutton with rounded corners, border, label, variable support, command. + For detailed information check out the documentation. + """ + + def __init__(self, + master: any, + width: int = 100, + height: int = 22, + radiobutton_width: int = 22, + radiobutton_height: int = 22, + corner_radius: Optional[int] = None, + border_width_unchecked: Optional[int] = None, + border_width_checked: Optional[int] = None, + + 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, + + text: str = "CTkRadioButton", + font: Optional[Union[tuple, CTkFont]] = None, + textvariable: Union[tkinter.Variable, None] = None, + variable: Union[tkinter.Variable, None] = None, + value: Union[int, str] = 0, + state: str = tkinter.NORMAL, + hover: bool = True, + command: Union[Callable, None] = None, + **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) + + # dimensions + self._radiobutton_width = radiobutton_width + self._radiobutton_height = radiobutton_height + + # color + self._fg_color = ThemeManager.theme["CTkRadiobutton"]["fg_color"] if fg_color is None else self._check_color_type(fg_color) + self._hover_color = ThemeManager.theme["CTkRadiobutton"]["hover_color"] if hover_color is None else self._check_color_type(hover_color) + self._border_color = ThemeManager.theme["CTkRadiobutton"]["border_color"] if border_color is None else self._check_color_type(border_color) + + # shape + self._corner_radius = ThemeManager.theme["CTkRadiobutton"]["corner_radius"] if corner_radius is None else corner_radius + self._border_width_unchecked = ThemeManager.theme["CTkRadiobutton"]["border_width_unchecked"] if border_width_unchecked is None else border_width_unchecked + self._border_width_checked = ThemeManager.theme["CTkRadiobutton"]["border_width_checked"] if border_width_checked is None else border_width_checked + + # text + self._text = text + self._text_label: Union[tkinter.Label, None] = None + self._text_color = ThemeManager.theme["CTkRadiobutton"]["text_color"] if text_color is None else self._check_color_type(text_color) + self._text_color_disabled = ThemeManager.theme["CTkRadiobutton"]["text_color_disabled"] if text_color_disabled is None else self._check_color_type(text_color_disabled) + + # font + self._font = CTkFont() if font is None else self._check_font_type(font) + if isinstance(self._font, CTkFont): + self._font.add_size_configure_callback(self._update_font) + + # callback and control variables + self._command = command + self._state = state + self._hover = hover + self._check_state: bool = False + self._value = value + self._variable: tkinter.Variable = variable + self._variable_callback_blocked: bool = False + self._textvariable = textvariable + self._variable_callback_name: Union[str, None] = None + + # configure grid system (3x1) + self.grid_columnconfigure(0, weight=0) + self.grid_columnconfigure(1, weight=0, minsize=self._apply_widget_scaling(6)) + self.grid_columnconfigure(2, weight=1) + self.grid_rowconfigure(0, weight=1) + + self._bg_canvas = CTkCanvas(master=self, + highlightthickness=0, + width=self._apply_widget_scaling(self._current_width), + height=self._apply_widget_scaling(self._current_height)) + self._bg_canvas.grid(row=0, column=0, columnspan=3, sticky="nswe") + + self._canvas = CTkCanvas(master=self, + highlightthickness=0, + width=self._apply_widget_scaling(self._radiobutton_width), + height=self._apply_widget_scaling(self._radiobutton_height)) + self._canvas.grid(row=0, column=0) + self._draw_engine = DrawEngine(self._canvas) + + self._text_label = tkinter.Label(master=self, + bd=0, + padx=0, + pady=0, + text=self._text, + justify=tkinter.LEFT, + font=self._apply_font_scaling(self._font), + textvariable=self._textvariable) + self._text_label.grid(row=0, column=2, sticky="w") + self._text_label["anchor"] = "w" + + if self._variable is not None: + self._variable_callback_name = self._variable.trace_add("write", self._variable_callback) + self._check_state = True if self._variable.get() == self._value else False + + self._create_bindings() + self._set_cursor() + self._draw() + + def _create_bindings(self, sequence: Optional[str] = None): + """ set necessary bindings for functionality of widget, will overwrite other bindings """ + if sequence is None or sequence == "": + self._canvas.bind("", self._on_enter) + self._text_label.bind("", self._on_enter) + if sequence is None or sequence == "": + self._canvas.bind("", self._on_leave) + self._text_label.bind("", self._on_leave) + if sequence is None or sequence == "": + self._canvas.bind("", self.invoke) + self._text_label.bind("", self.invoke) + + def _set_scaling(self, *args, **kwargs): + super()._set_scaling(*args, **kwargs) + + self.grid_columnconfigure(1, weight=0, minsize=self._apply_widget_scaling(6)) + self._text_label.configure(font=self._apply_font_scaling(self._font)) + + self._bg_canvas.configure(width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height)) + self._canvas.configure(width=self._apply_widget_scaling(self._radiobutton_width), + height=self._apply_widget_scaling(self._radiobutton_height)) + self._draw(no_color_updates=True) + + def _set_dimensions(self, width: int = None, height: int = None): + super()._set_dimensions(width, height) + + self._bg_canvas.configure(width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height)) + + def _update_font(self): + """ pass font to tkinter widgets with applied font scaling and update grid with workaround """ + self._text_label.configure(font=self._apply_font_scaling(self._font)) + + # Workaround to force grid to be resized when text changes size. + # Otherwise grid will lag and only resizes if other mouse action occurs. + self._bg_canvas.grid_forget() + self._bg_canvas.grid(row=0, column=0, columnspan=3, sticky="nswe") + + def destroy(self): + if self._variable is not None: + self._variable.trace_remove("write", self._variable_callback_name) + + if isinstance(self._font, CTkFont): + self._font.remove_size_configure_callback(self._update_font) + + super().destroy() + + def _draw(self, no_color_updates=False): + super()._draw(no_color_updates) + + if self._check_state is True: + requires_recoloring = self._draw_engine.draw_rounded_rect_with_border(self._apply_widget_scaling(self._radiobutton_width), + self._apply_widget_scaling(self._radiobutton_height), + self._apply_widget_scaling(self._corner_radius), + self._apply_widget_scaling(self._border_width_checked)) + else: + requires_recoloring = self._draw_engine.draw_rounded_rect_with_border(self._apply_widget_scaling(self._radiobutton_width), + self._apply_widget_scaling(self._radiobutton_height), + self._apply_widget_scaling(self._corner_radius), + self._apply_widget_scaling(self._border_width_unchecked)) + + if no_color_updates is False or requires_recoloring: + self._bg_canvas.configure(bg=self._apply_appearance_mode(self._bg_color)) + self._canvas.configure(bg=self._apply_appearance_mode(self._bg_color)) + + if self._check_state is False: + self._canvas.itemconfig("border_parts", + outline=self._apply_appearance_mode(self._border_color), + fill=self._apply_appearance_mode(self._border_color)) + else: + self._canvas.itemconfig("border_parts", + outline=self._apply_appearance_mode(self._fg_color), + fill=self._apply_appearance_mode(self._fg_color)) + + self._canvas.itemconfig("inner_parts", + outline=self._apply_appearance_mode(self._bg_color), + fill=self._apply_appearance_mode(self._bg_color)) + + if self._state == tkinter.DISABLED: + self._text_label.configure(fg=self._apply_appearance_mode(self._text_color_disabled)) + else: + self._text_label.configure(fg=self._apply_appearance_mode(self._text_color)) + + self._text_label.configure(bg=self._apply_appearance_mode(self._bg_color)) + + def configure(self, require_redraw=False, **kwargs): + if "corner_radius" in kwargs: + self._corner_radius = kwargs.pop("corner_radius") + require_redraw = True + + if "border_width_unchecked" in kwargs: + self._border_width_unchecked = kwargs.pop("border_width_unchecked") + require_redraw = True + + if "border_width_checked" in kwargs: + self._border_width_checked = kwargs.pop("border_width_checked") + require_redraw = True + + if "radiobutton_width" in kwargs: + self._radiobutton_width = kwargs.pop("radiobutton_width") + self._canvas.configure(width=self._apply_widget_scaling(self._radiobutton_width)) + require_redraw = True + + if "radiobutton_height" in kwargs: + self._radiobutton_height = kwargs.pop("radiobutton_height") + self._canvas.configure(height=self._apply_widget_scaling(self._radiobutton_height)) + require_redraw = True + + if "text" in kwargs: + self._text = kwargs.pop("text") + self._text_label.configure(text=self._text) + + if "font" in kwargs: + if isinstance(self._font, CTkFont): + self._font.remove_size_configure_callback(self._update_font) + self._font = self._check_font_type(kwargs.pop("font")) + if isinstance(self._font, CTkFont): + self._font.add_size_configure_callback(self._update_font) + + self._update_font() + + if "state" in kwargs: + self._state = kwargs.pop("state") + self._set_cursor() + require_redraw = True + + if "fg_color" in kwargs: + self._fg_color = self._check_color_type(kwargs.pop("fg_color")) + require_redraw = True + + if "hover_color" in kwargs: + self._hover_color = self._check_color_type(kwargs.pop("hover_color")) + require_redraw = True + + if "text_color" in kwargs: + self._text_color = self._check_color_type(kwargs.pop("text_color")) + require_redraw = True + + if "text_color_disabled" in kwargs: + self._text_color_disabled = self._check_color_type(kwargs.pop("text_color_disabled")) + require_redraw = True + + if "border_color" in kwargs: + self._border_color = self._check_color_type(kwargs.pop("border_color")) + require_redraw = True + + if "hover" in kwargs: + self._hover = kwargs.pop("hover") + + if "command" in kwargs: + self._command = kwargs.pop("command") + + if "textvariable" in kwargs: + self._textvariable = kwargs.pop("textvariable") + self._text_label.configure(textvariable=self._textvariable) + + if "variable" in kwargs: + if self._variable is not None: + self._variable.trace_remove("write", self._variable_callback_name) + + self._variable = kwargs.pop("variable") + + if self._variable is not None and self._variable != "": + self._variable_callback_name = self._variable.trace_add("write", self._variable_callback) + self._check_state = True if self._variable.get() == self._value else False + require_redraw = True + + super().configure(require_redraw=require_redraw, **kwargs) + + def cget(self, attribute_name: str) -> any: + if attribute_name == "corner_radius": + return self._corner_radius + elif attribute_name == "border_width_unchecked": + return self._border_width_unchecked + elif attribute_name == "border_width_checked": + return self._border_width_checked + elif attribute_name == "radiobutton_width": + return self._radiobutton_width + elif attribute_name == "radiobutton_height": + return self._radiobutton_height + + elif attribute_name == "fg_color": + return self._fg_color + elif attribute_name == "hover_color": + return self._hover_color + elif attribute_name == "border_color": + return self._border_color + elif attribute_name == "text_color": + return self._text_color + elif attribute_name == "text_color_disabled": + return self._text_color_disabled + + elif attribute_name == "text": + return self._text + elif attribute_name == "font": + return self._font + elif attribute_name == "textvariable": + return self._textvariable + elif attribute_name == "variable": + return self._variable + elif attribute_name == "value": + return self._value + elif attribute_name == "state": + return self._state + elif attribute_name == "hover": + return self._hover + elif attribute_name == "command": + return self._command + + else: + return super().cget(attribute_name) + + def _set_cursor(self): + if self._cursor_manipulation_enabled: + if self._state == tkinter.DISABLED: + if sys.platform == "darwin": + self._canvas.configure(cursor="arrow") + if self._text_label is not None: + self._text_label.configure(cursor="arrow") + elif sys.platform.startswith("win"): + self._canvas.configure(cursor="arrow") + if self._text_label is not None: + self._text_label.configure(cursor="arrow") + + elif self._state == tkinter.NORMAL: + if sys.platform == "darwin": + self._canvas.configure(cursor="pointinghand") + if self._text_label is not None: + self._text_label.configure(cursor="pointinghand") + elif sys.platform.startswith("win"): + self._canvas.configure(cursor="hand2") + if self._text_label is not None: + self._text_label.configure(cursor="hand2") + + def _on_enter(self, event=0): + if self._hover is True and self._state == tkinter.NORMAL: + self._canvas.itemconfig("border_parts", + fill=self._apply_appearance_mode(self._hover_color), + outline=self._apply_appearance_mode(self._hover_color)) + + def _on_leave(self, event=0): + if self._check_state is True: + self._canvas.itemconfig("border_parts", + fill=self._apply_appearance_mode(self._fg_color), + outline=self._apply_appearance_mode(self._fg_color)) + else: + self._canvas.itemconfig("border_parts", + fill=self._apply_appearance_mode(self._border_color), + outline=self._apply_appearance_mode(self._border_color)) + + def _variable_callback(self, var_name, index, mode): + if not self._variable_callback_blocked: + if self._variable.get() == self._value: + self.select(from_variable_callback=True) + else: + self.deselect(from_variable_callback=True) + + def invoke(self, event=0): + if self._state == tkinter.NORMAL: + if self._check_state is False: + self._check_state = True + self.select() + + if self._command is not None: + self._command() + + def select(self, from_variable_callback=False): + self._check_state = True + self._draw() + + if self._variable is not None and not from_variable_callback: + self._variable_callback_blocked = True + self._variable.set(self._value) + self._variable_callback_blocked = False + + def deselect(self, from_variable_callback=False): + self._check_state = False + self._draw() + + if self._variable is not None and not from_variable_callback: + self._variable_callback_blocked = True + self._variable.set("") + self._variable_callback_blocked = False + + def bind(self, sequence: str = None, command: Callable = None, add: Union[str, bool] = True): + """ called on the tkinter.Canvas """ + if not (add == "+" or add is True): + raise ValueError("'add' argument can only be '+' or True to preserve internal callbacks") + self._canvas.bind(sequence, command, add=True) + self._text_label.bind(sequence, command, add=True) + + def unbind(self, sequence: str = None, funcid: str = None): + """ called on the tkinter.Label and tkinter.Canvas """ + if funcid is not None: + raise ValueError("'funcid' argument can only be None, because there is a bug in" + + " tkinter and its not clear whether the internal callbacks will be unbinded or not") + self._canvas.unbind(sequence, None) + self._text_label.unbind(sequence, None) + self._create_bindings(sequence=sequence) # restore internal callbacks for sequence + + def focus(self): + return self._text_label.focus() + + def focus_set(self): + return self._text_label.focus_set() + + def focus_force(self): + return self._text_label.focus_force() diff --git a/customtkinter/windows/widgets/ctk_scrollbar.py b/customtkinter/windows/widgets/ctk_scrollbar.py new file mode 100644 index 0000000..1038282 --- /dev/null +++ b/customtkinter/windows/widgets/ctk_scrollbar.py @@ -0,0 +1,281 @@ +import sys +from typing import Union, Tuple, Callable, Optional + +from .core_rendering import CTkCanvas +from .theme import ThemeManager +from .core_rendering import DrawEngine +from .core_widget_classes import CTkBaseClass + + +class CTkScrollbar(CTkBaseClass): + """ + Scrollbar with rounded corners, configurable spacing. + Connect to scrollable widget by passing .set() method and set command attribute. + For detailed information check out the documentation. + """ + + def __init__(self, + master: any, + width: Optional[Union[int, str]] = None, + height: Optional[Union[int, str]] = None, + corner_radius: Optional[int] = None, + border_spacing: Optional[int] = None, + minimum_pixel_length: int = 20, + + bg_color: Union[str, Tuple[str, str]] = "transparent", + fg_color: Optional[Union[str, Tuple[str, str]]] = None, + button_color: Optional[Union[str, Tuple[str, str]]] = None, + button_hover_color: Optional[Union[str, Tuple[str, str]]] = None, + + hover: bool = True, + command: Union[Callable, None] = None, + orientation: str = "vertical", + **kwargs): + + # set default dimensions according to orientation + if width is None: + if orientation.lower() == "vertical": + width = 16 + else: + width = 200 + if height is None: + if orientation.lower() == "horizontal": + height = 16 + else: + height = 200 + + # transfer basic functionality (_bg_color, size, __appearance_mode, scaling) to CTkBaseClass + super().__init__(master=master, bg_color=bg_color, width=width, height=height, **kwargs) + + # color + self._fg_color = ThemeManager.theme["CTkScrollbar"]["fg_color"] if fg_color is None else self._check_color_type(fg_color, transparency=True) + self._button_color = ThemeManager.theme["CTkScrollbar"]["button_color"] if button_color is None else self._check_color_type(button_color) + self._button_hover_color = ThemeManager.theme["CTkScrollbar"]["button_hover_color"] if button_hover_color is None else self._check_color_type(button_hover_color) + + # shape + self._corner_radius = ThemeManager.theme["CTkScrollbar"]["corner_radius"] if corner_radius is None else corner_radius + self._border_spacing = ThemeManager.theme["CTkScrollbar"]["border_spacing"] if border_spacing is None else border_spacing + + self._hover = hover + self._hover_state: bool = False + self._command = command + self._orientation = orientation + self._start_value: float = 0 # 0 to 1 + self._end_value: float = 1 # 0 to 1 + self._minimum_pixel_length = minimum_pixel_length + + self._canvas = CTkCanvas(master=self, + highlightthickness=0, + width=self._apply_widget_scaling(self._current_width), + height=self._apply_widget_scaling(self._current_height)) + self._canvas.place(x=0, y=0, relwidth=1, relheight=1) + self._draw_engine = DrawEngine(self._canvas) + + self._create_bindings() + self._draw() + + def _create_bindings(self, sequence: Optional[str] = None): + """ set necessary bindings for functionality of widget, will overwrite other bindings """ + if sequence is None: + self._canvas.tag_bind("border_parts", "", self._clicked) + if sequence is None or sequence == "": + self._canvas.bind("", self._on_enter) + if sequence is None or sequence == "": + self._canvas.bind("", self._on_leave) + if sequence is None or sequence == "": + self._canvas.bind("", self._clicked) + if sequence is None or sequence == "": + self._canvas.bind("", self._mouse_scroll_event) + + def _set_scaling(self, *args, **kwargs): + super()._set_scaling(*args, **kwargs) + + self._canvas.configure(width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height)) + self._draw(no_color_updates=True) + + def _set_dimensions(self, width=None, height=None): + super()._set_dimensions(width, height) + + self._canvas.configure(width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height)) + self._draw(no_color_updates=True) + + def _get_scrollbar_values_for_minimum_pixel_size(self): + # correct scrollbar float values if scrollbar is too small + if self._orientation == "vertical": + scrollbar_pixel_length = (self._end_value - self._start_value) * self._current_height + if scrollbar_pixel_length < self._minimum_pixel_length and -scrollbar_pixel_length + self._current_height != 0: + # calculate how much to increase the float interval values so that the scrollbar width is self.minimum_pixel_length + interval_extend_factor = (-scrollbar_pixel_length + self._minimum_pixel_length) / (-scrollbar_pixel_length + self._current_height) + corrected_end_value = self._end_value + (1 - self._end_value) * interval_extend_factor + corrected_start_value = self._start_value - self._start_value * interval_extend_factor + return corrected_start_value, corrected_end_value + else: + return self._start_value, self._end_value + + else: + scrollbar_pixel_length = (self._end_value - self._start_value) * self._current_width + if scrollbar_pixel_length < self._minimum_pixel_length and -scrollbar_pixel_length + self._current_width != 0: + # calculate how much to increase the float interval values so that the scrollbar width is self.minimum_pixel_length + interval_extend_factor = (-scrollbar_pixel_length + self._minimum_pixel_length) / (-scrollbar_pixel_length + self._current_width) + corrected_end_value = self._end_value + (1 - self._end_value) * interval_extend_factor + corrected_start_value = self._start_value - self._start_value * interval_extend_factor + return corrected_start_value, corrected_end_value + else: + return self._start_value, self._end_value + + def _draw(self, no_color_updates=False): + super()._draw(no_color_updates) + + corrected_start_value, corrected_end_value = self._get_scrollbar_values_for_minimum_pixel_size() + requires_recoloring = self._draw_engine.draw_rounded_scrollbar(self._apply_widget_scaling(self._current_width), + self._apply_widget_scaling(self._current_height), + self._apply_widget_scaling(self._corner_radius), + self._apply_widget_scaling(self._border_spacing), + corrected_start_value, + corrected_end_value, + self._orientation) + + if no_color_updates is False or requires_recoloring: + if self._hover_state is True: + self._canvas.itemconfig("scrollbar_parts", + fill=self._apply_appearance_mode(self._button_hover_color), + outline=self._apply_appearance_mode(self._button_hover_color)) + else: + self._canvas.itemconfig("scrollbar_parts", + fill=self._apply_appearance_mode(self._button_color), + outline=self._apply_appearance_mode(self._button_color)) + + if self._fg_color == "transparent": + self._canvas.configure(bg=self._apply_appearance_mode(self._bg_color)) + self._canvas.itemconfig("border_parts", + fill=self._apply_appearance_mode(self._bg_color), + outline=self._apply_appearance_mode(self._bg_color)) + else: + self._canvas.configure(bg=self._apply_appearance_mode(self._fg_color)) + self._canvas.itemconfig("border_parts", + fill=self._apply_appearance_mode(self._fg_color), + outline=self._apply_appearance_mode(self._fg_color)) + + self._canvas.update_idletasks() + + def configure(self, require_redraw=False, **kwargs): + if "fg_color" in kwargs: + self._fg_color = self._check_color_type(kwargs.pop("fg_color"), transparency=True) + require_redraw = True + + if "button_color" in kwargs: + self._button_color = self._check_color_type(kwargs.pop("button_color")) + require_redraw = True + + if "button_hover_color" in kwargs: + self._button_hover_color = self._check_color_type(kwargs.pop("button_hover_color")) + require_redraw = True + + if "hover" in kwargs: + self._hover = kwargs.pop("hover") + + if "command" in kwargs: + self._command = kwargs.pop("command") + + if "corner_radius" in kwargs: + self._corner_radius = kwargs.pop("corner_radius") + require_redraw = True + + if "border_spacing" in kwargs: + self._border_spacing = kwargs.pop("border_spacing") + require_redraw = True + + super().configure(require_redraw=require_redraw, **kwargs) + + def cget(self, attribute_name: str) -> any: + if attribute_name == "corner_radius": + return self._corner_radius + elif attribute_name == "border_spacing": + return self._border_spacing + elif attribute_name == "minimum_pixel_length": + return self._minimum_pixel_length + + elif attribute_name == "fg_color": + return self._fg_color + elif attribute_name == "scrollbar_color": + return self._button_color + elif attribute_name == "scrollbar_hover_color": + return self._button_hover_color + + elif attribute_name == "hover": + return self._hover + elif attribute_name == "command": + return self._command + elif attribute_name == "orientation": + return self._orientation + + else: + return super().cget(attribute_name) + + def _on_enter(self, event=0): + if self._hover is True: + self._hover_state = True + self._canvas.itemconfig("scrollbar_parts", + outline=self._apply_appearance_mode(self._button_hover_color), + fill=self._apply_appearance_mode(self._button_hover_color)) + + def _on_leave(self, event=0): + self._hover_state = False + self._canvas.itemconfig("scrollbar_parts", + outline=self._apply_appearance_mode(self._button_color), + fill=self._apply_appearance_mode(self._button_color)) + + def _clicked(self, event): + if self._orientation == "vertical": + value = self._reverse_widget_scaling(((event.y - self._border_spacing) / (self._current_height - 2 * self._border_spacing))) + else: + value = self._reverse_widget_scaling(((event.x - self._border_spacing) / (self._current_width - 2 * self._border_spacing))) + + current_scrollbar_length = self._end_value - self._start_value + value = max(current_scrollbar_length / 2, min(value, 1 - (current_scrollbar_length / 2))) + self._start_value = value - (current_scrollbar_length / 2) + self._end_value = value + (current_scrollbar_length / 2) + self._draw() + + if self._command is not None: + self._command('moveto', self._start_value) + + def _mouse_scroll_event(self, event=None): + if self._command is not None: + if sys.platform.startswith("win"): + self._command('scroll', -int(event.delta/40), 'units') + else: + self._command('scroll', -event.delta, 'units') + + def set(self, start_value: float, end_value: float): + self._start_value = float(start_value) + self._end_value = float(end_value) + self._draw() + + def get(self): + return self._start_value, self._end_value + + def bind(self, sequence=None, command=None, add=True): + """ called on the tkinter.Canvas """ + if not (add == "+" or add is True): + raise ValueError("'add' argument can only be '+' or True to preserve internal callbacks") + self._canvas.bind(sequence, command, add=True) + + def unbind(self, sequence=None, funcid=None): + """ called on the tkinter.Canvas, restores internal callbacks """ + if funcid is not None: + raise ValueError("'funcid' argument can only be None, because there is a bug in" + + " tkinter and its not clear whether the internal callbacks will be unbinded or not") + self._canvas.unbind(sequence, None) # unbind all callbacks for sequence + self._create_bindings(sequence=sequence) # restore internal callbacks for sequence + + def focus(self): + return self._canvas.focus() + + def focus_set(self): + return self._canvas.focus_set() + + def focus_force(self): + return self._canvas.focus_force() diff --git a/customtkinter/windows/widgets/ctk_segmented_button.py b/customtkinter/windows/widgets/ctk_segmented_button.py new file mode 100644 index 0000000..5ea46ca --- /dev/null +++ b/customtkinter/windows/widgets/ctk_segmented_button.py @@ -0,0 +1,421 @@ +import tkinter +import copy +from typing import Union, Tuple, List, Dict, Callable, Optional +try: + from typing import Literal +except ImportError: + from typing_extensions import Literal + +from .theme import ThemeManager +from .font import CTkFont +from .ctk_button import CTkButton +from .ctk_frame import CTkFrame + + +class CTkSegmentedButton(CTkFrame): + """ + Segmented button with corner radius, border width, variable support. + For detailed information check out the documentation. + """ + + def __init__(self, + master: any, + width: int = 140, + height: int = 28, + corner_radius: Optional[int] = None, + border_width: int = 3, + + bg_color: Union[str, Tuple[str, str]] = "transparent", + fg_color: Optional[Union[str, Tuple[str, str]]] = None, + selected_color: Optional[Union[str, Tuple[str, str]]] = None, + selected_hover_color: Optional[Union[str, Tuple[str, str]]] = None, + unselected_color: Optional[Union[str, Tuple[str, str]]] = None, + unselected_hover_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, + + font: Optional[Union[tuple, CTkFont]] = None, + values: Optional[list] = None, + variable: Union[tkinter.Variable, None] = None, + dynamic_resizing: bool = True, + command: Union[Callable[[str], None], None] = None, + state: str = "normal", + **kwargs): + + super().__init__(master=master, bg_color=bg_color, width=width, height=height, **kwargs) + + self._sb_fg_color = ThemeManager.theme["CTkSegmentedButton"]["fg_color"] if fg_color is None else self._check_color_type(fg_color) + + self._sb_selected_color = ThemeManager.theme["CTkSegmentedButton"]["selected_color"] if selected_color is None else self._check_color_type(selected_color) + self._sb_selected_hover_color = ThemeManager.theme["CTkSegmentedButton"]["selected_hover_color"] if selected_hover_color is None else self._check_color_type(selected_hover_color) + + self._sb_unselected_color = ThemeManager.theme["CTkSegmentedButton"]["unselected_color"] if unselected_color is None else self._check_color_type(unselected_color) + self._sb_unselected_hover_color = ThemeManager.theme["CTkSegmentedButton"]["unselected_hover_color"] if unselected_hover_color is None else self._check_color_type(unselected_hover_color) + + self._sb_text_color = ThemeManager.theme["CTkSegmentedButton"]["text_color"] if text_color is None else self._check_color_type(text_color) + self._sb_text_color_disabled = ThemeManager.theme["CTkSegmentedButton"]["text_color_disabled"] if text_color_disabled is None else self._check_color_type(text_color_disabled) + + self._sb_corner_radius = ThemeManager.theme["CTkSegmentedButton"]["corner_radius"] if corner_radius is None else corner_radius + self._sb_border_width = ThemeManager.theme["CTkSegmentedButton"]["border_width"] if border_width is None else border_width + + self._background_corner_colors = background_corner_colors # rendering options for DrawEngine + + self._command: Callable[[str], None] = command + self._font = CTkFont() if font is None else font + self._state = state + + self._buttons_dict: Dict[str, CTkButton] = {} # mapped from value to button object + if values is None: + self._value_list: List[str] = ["CTkSegmentedButton"] + else: + self._value_list: List[str] = values # Values ordered like buttons rendered on widget + + self._dynamic_resizing = dynamic_resizing + if not self._dynamic_resizing: + self.grid_propagate(False) + + self._check_unique_values(self._value_list) + self._current_value: str = "" + if len(self._value_list) > 0: + self._create_buttons_from_values() + self._create_button_grid() + + self._variable = variable + self._variable_callback_blocked: bool = False + self._variable_callback_name: Union[str, None] = None + + if self._variable is not None: + self._variable_callback_name = self._variable.trace_add("write", self._variable_callback) + self.set(self._variable.get(), from_variable_callback=True) + + super().configure(corner_radius=self._sb_corner_radius, fg_color="transparent") + + def destroy(self): + if self._variable is not None: # remove old callback + self._variable.trace_remove("write", self._variable_callback_name) + + super().destroy() + + def _set_dimensions(self, width: int = None, height: int = None): + super()._set_dimensions(width, height) + + for button in self._buttons_dict.values(): + button.configure(height=height) + + def _variable_callback(self, var_name, index, mode): + if not self._variable_callback_blocked: + self.set(self._variable.get(), from_variable_callback=True) + + def _get_index_by_value(self, value: str): + for index, value_from_list in enumerate(self._value_list): + if value_from_list == value: + return index + + raise ValueError(f"CTkSegmentedButton does not contain value '{value}'") + + def _configure_button_corners_for_index(self, index: int): + if index == 0 and len(self._value_list) == 1: + if self._background_corner_colors is None: + self._buttons_dict[self._value_list[index]].configure(background_corner_colors=(self._bg_color, self._bg_color, self._bg_color, self._bg_color)) + else: + self._buttons_dict[self._value_list[index]].configure(background_corner_colors=self._background_corner_colors) + + elif index == 0: + if self._background_corner_colors is None: + self._buttons_dict[self._value_list[index]].configure(background_corner_colors=(self._bg_color, self._sb_fg_color, self._sb_fg_color, self._bg_color)) + else: + self._buttons_dict[self._value_list[index]].configure(background_corner_colors=(self._background_corner_colors[0], self._sb_fg_color, self._sb_fg_color, self._background_corner_colors[3])) + + elif index == len(self._value_list) - 1: + if self._background_corner_colors is None: + self._buttons_dict[self._value_list[index]].configure(background_corner_colors=(self._sb_fg_color, self._bg_color, self._bg_color, self._sb_fg_color)) + else: + self._buttons_dict[self._value_list[index]].configure(background_corner_colors=(self._sb_fg_color, self._background_corner_colors[1], self._background_corner_colors[2], self._sb_fg_color)) + + else: + self._buttons_dict[self._value_list[index]].configure(background_corner_colors=(self._sb_fg_color, self._sb_fg_color, self._sb_fg_color, self._sb_fg_color)) + + def _unselect_button_by_value(self, value: str): + if value in self._buttons_dict: + self._buttons_dict[value].configure(fg_color=self._sb_unselected_color, + hover_color=self._sb_unselected_hover_color) + + def _select_button_by_value(self, value: str): + if self._current_value is not None and self._current_value != "": + self._unselect_button_by_value(self._current_value) + + self._current_value = value + + self._buttons_dict[value].configure(fg_color=self._sb_selected_color, + hover_color=self._sb_selected_hover_color) + + def _create_button(self, index: int, value: str) -> CTkButton: + new_button = CTkButton(self, + width=0, + height=self._current_height, + corner_radius=self._sb_corner_radius, + border_width=self._sb_border_width, + fg_color=self._sb_unselected_color, + border_color=self._sb_fg_color, + hover_color=self._sb_unselected_hover_color, + text_color=self._sb_text_color, + text_color_disabled=self._sb_text_color_disabled, + text=value, + font=self._font, + state=self._state, + command=lambda v=value: self.set(v, from_button_callback=True), + background_corner_colors=None, + round_width_to_even_numbers=False, + round_height_to_even_numbers=False) # DrawEngine rendering option (so that theres no gap between buttons) + + return new_button + + @staticmethod + def _check_unique_values(values: List[str]): + """ raises exception if values are not unique """ + if len(values) != len(set(values)): + raise ValueError("CTkSegmentedButton values are not unique") + + def _create_button_grid(self): + # remove minsize from every grid cell in the first row + number_of_columns, _ = self.grid_size() + for n in range(number_of_columns): + self.grid_columnconfigure(n, weight=1, minsize=0) + self.grid_rowconfigure(0, weight=1) + + for index, value in enumerate(self._value_list): + self.grid_columnconfigure(index, weight=1, minsize=self._current_height) + self._buttons_dict[value].grid(row=0, column=index, sticky="ew") + + def _create_buttons_from_values(self): + assert len(self._buttons_dict) == 0 + assert len(self._value_list) > 0 + + for index, value in enumerate(self._value_list): + self._buttons_dict[value] = self._create_button(index, value) + self._configure_button_corners_for_index(index) + + def configure(self, **kwargs): + if "bg_color" in kwargs: + super().configure(bg_color=kwargs.pop("bg_color")) + + if len(self._buttons_dict) > 0: + self._configure_button_corners_for_index(0) + if len(self._buttons_dict) > 1: + max_index = len(self._buttons_dict) - 1 + self._configure_button_corners_for_index(max_index) + + if "fg_color" in kwargs: + self._sb_fg_color = self._check_color_type(kwargs.pop("fg_color")) + for index, button in enumerate(self._buttons_dict.values()): + button.configure(border_color=self._sb_fg_color) + self._configure_button_corners_for_index(index) + + if "selected_color" in kwargs: + self._sb_selected_color = self._check_color_type(kwargs.pop("selected_color")) + if self._current_value in self._buttons_dict: + self._buttons_dict[self._current_value].configure(fg_color=self._sb_selected_color) + + if "selected_hover_color" in kwargs: + self._sb_selected_hover_color = self._check_color_type(kwargs.pop("selected_hover_color")) + if self._current_value in self._buttons_dict: + self._buttons_dict[self._current_value].configure(hover_color=self._sb_selected_hover_color) + + if "unselected_color" in kwargs: + self._sb_unselected_color = self._check_color_type(kwargs.pop("unselected_color")) + for value, button in self._buttons_dict.items(): + if value != self._current_value: + button.configure(fg_color=self._sb_unselected_color) + + if "unselected_hover_color" in kwargs: + self._sb_unselected_hover_color = self._check_color_type(kwargs.pop("unselected_hover_color")) + for value, button in self._buttons_dict.items(): + if value != self._current_value: + button.configure(hover_color=self._sb_unselected_hover_color) + + if "text_color" in kwargs: + self._sb_text_color = self._check_color_type(kwargs.pop("text_color")) + for button in self._buttons_dict.values(): + button.configure(text_color=self._sb_text_color) + + if "text_color_disabled" in kwargs: + self._sb_text_color_disabled = self._check_color_type(kwargs.pop("text_color_disabled")) + for button in self._buttons_dict.values(): + button.configure(text_color_disabled=self._sb_text_color_disabled) + + if "background_corner_colors" in kwargs: + self._background_corner_colors = kwargs.pop("background_corner_colors") + for i in range(len(self._buttons_dict)): + self._configure_button_corners_for_index(i) + + if "font" in kwargs: + self._font = kwargs.pop("font") + for button in self._buttons_dict.values(): + button.configure(font=self._font) + + if "values" in kwargs: + for button in self._buttons_dict.values(): + button.destroy() + self._buttons_dict.clear() + self._value_list = kwargs.pop("values") + + self._check_unique_values(self._value_list) + + if len(self._value_list) > 0: + self._create_buttons_from_values() + self._create_button_grid() + + if self._current_value in self._value_list: + self._select_button_by_value(self._current_value) + + if "variable" in kwargs: + if self._variable is not None: # remove old callback + self._variable.trace_remove("write", self._variable_callback_name) + + self._variable = kwargs.pop("variable") + + if self._variable is not None and self._variable != "": + self._variable_callback_name = self._variable.trace_add("write", self._variable_callback) + self.set(self._variable.get(), from_variable_callback=True) + else: + self._variable = None + + if "dynamic_resizing" in kwargs: + self._dynamic_resizing = kwargs.pop("dynamic_resizing") + if not self._dynamic_resizing: + self.grid_propagate(False) + else: + self.grid_propagate(True) + + if "command" in kwargs: + self._command = kwargs.pop("command") + + if "state" in kwargs: + self._state = kwargs.pop("state") + for button in self._buttons_dict.values(): + button.configure(state=self._state) + + super().configure(**kwargs) + + def cget(self, attribute_name: str) -> any: + if attribute_name == "corner_radius": + return self._sb_corner_radius + elif attribute_name == "border_width": + return self._sb_border_width + + elif attribute_name == "fg_color": + return self._sb_fg_color + elif attribute_name == "selected_color": + return self._sb_selected_color + elif attribute_name == "selected_hover_color": + return self._sb_selected_hover_color + elif attribute_name == "unselected_color": + return self._sb_unselected_color + elif attribute_name == "unselected_hover_color": + return self._sb_unselected_hover_color + elif attribute_name == "text_color": + return self._sb_text_color + elif attribute_name == "text_color_disabled": + return self._sb_text_color_disabled + + elif attribute_name == "font": + return self._font + elif attribute_name == "values": + return copy.copy(self._value_list) + elif attribute_name == "variable": + return self._variable + elif attribute_name == "dynamic_resizing": + return self._dynamic_resizing + elif attribute_name == "command": + return self._command + + else: + return super().cget(attribute_name) + + def set(self, value: str, from_variable_callback: bool = False, from_button_callback: bool = False): + if value == self._current_value: + return + elif value in self._buttons_dict: + self._select_button_by_value(value) + + if self._variable is not None and not from_variable_callback: + self._variable_callback_blocked = True + self._variable.set(value) + self._variable_callback_blocked = False + else: + if self._current_value in self._buttons_dict: + self._unselect_button_by_value(self._current_value) + self._current_value = value + + if self._variable is not None and not from_variable_callback: + self._variable_callback_blocked = True + self._variable.set(value) + self._variable_callback_blocked = False + + if from_button_callback: + if self._command is not None: + self._command(self._current_value) + + def get(self) -> str: + return self._current_value + + def insert(self, index: int, value: str): + if value not in self._buttons_dict: + if value != "": + self._value_list.insert(index, value) + self._buttons_dict[value] = self._create_button(index, value) + + self._configure_button_corners_for_index(index) + if index > 0: + self._configure_button_corners_for_index(index - 1) + if index < len(self._buttons_dict) - 1: + self._configure_button_corners_for_index(index + 1) + + self._create_button_grid() + + if value == self._current_value: + self._select_button_by_value(self._current_value) + else: + raise ValueError(f"CTkSegmentedButton can not insert value ''") + else: + raise ValueError(f"CTkSegmentedButton can not insert value '{value}', already part of the values") + + def move(self, new_index: int, value: str): + if 0 <= new_index < len(self._value_list): + if value in self._buttons_dict: + self.delete(value) + self.insert(new_index, value) + else: + raise ValueError(f"CTkSegmentedButton has no value named '{value}'") + else: + raise ValueError(f"CTkSegmentedButton new_index {new_index} not in range of value list with len {len(self._value_list)}") + + def delete(self, value: str): + if value in self._buttons_dict: + self._buttons_dict[value].destroy() + self._buttons_dict.pop(value) + index_to_remove = self._get_index_by_value(value) + self._value_list.pop(index_to_remove) + + # removed index was outer right element + if index_to_remove == len(self._buttons_dict) and len(self._buttons_dict) > 0: + self._configure_button_corners_for_index(index_to_remove - 1) + + # removed index was outer left element + if index_to_remove == 0 and len(self._buttons_dict) > 0: + self._configure_button_corners_for_index(0) + + #if index_to_remove <= len(self._buttons_dict) - 1: + # self._configure_button_corners_for_index(index_to_remove) + + self._create_button_grid() + else: + raise ValueError(f"CTkSegmentedButton does not contain value '{value}'") + + def bind(self, sequence=None, command=None, add=None): + raise NotImplementedError + + def unbind(self, sequence=None, funcid=None): + raise NotImplementedError + diff --git a/customtkinter/windows/widgets/ctk_slider.py b/customtkinter/windows/widgets/ctk_slider.py new file mode 100644 index 0000000..3016a5c --- /dev/null +++ b/customtkinter/windows/widgets/ctk_slider.py @@ -0,0 +1,397 @@ +import tkinter +import sys +from typing import Union, Tuple, Callable, Optional + +from .core_rendering import CTkCanvas +from .theme import ThemeManager +from .core_rendering import DrawEngine +from .core_widget_classes import CTkBaseClass + + +class CTkSlider(CTkBaseClass): + """ + Slider with rounded corners, border, number of steps, variable support, vertical orientation. + For detailed information check out the documentation. + """ + + def __init__(self, + master: any, + width: Optional[int] = None, + height: Optional[int] = None, + corner_radius: Optional[int] = None, + button_corner_radius: Optional[int] = None, + border_width: Optional[int] = None, + button_length: Optional[int] = None, + + bg_color: Union[str, Tuple[str, str]] = "transparent", + fg_color: Optional[Union[str, Tuple[str, str]]] = None, + border_color: Union[str, Tuple[str, str]] = "transparent", + progress_color: Optional[Union[str, Tuple[str, str]]] = None, + button_color: Optional[Union[str, Tuple[str, str]]] = None, + button_hover_color: Optional[Union[str, Tuple[str, str]]] = None, + + from_: int = 0, + to: int = 1, + state: str = "normal", + number_of_steps: Union[int, None] = None, + hover: bool = True, + command: Union[Callable[[float], None], None] = None, + variable: Union[tkinter.Variable, None] = None, + orientation: str = "horizontal", + **kwargs): + + # set default dimensions according to orientation + if width is None: + if orientation.lower() == "vertical": + width = 16 + else: + width = 200 + if height is None: + if orientation.lower() == "vertical": + height = 200 + else: + height = 16 + + # transfer basic functionality (_bg_color, size, __appearance_mode, scaling) to CTkBaseClass + super().__init__(master=master, bg_color=bg_color, width=width, height=height, **kwargs) + + # color + self._border_color = self._check_color_type(border_color, transparency=True) + self._fg_color = ThemeManager.theme["CTkSlider"]["fg_color"] if fg_color is None else self._check_color_type(fg_color) + self._progress_color = ThemeManager.theme["CTkSlider"]["progress_color"] if progress_color is None else self._check_color_type(progress_color, transparency=True) + self._button_color = ThemeManager.theme["CTkSlider"]["button_color"] if button_color is None else self._check_color_type(button_color) + self._button_hover_color = ThemeManager.theme["CTkSlider"]["button_hover_color"] if button_hover_color is None else self._check_color_type(button_hover_color) + + # shape + self._corner_radius = ThemeManager.theme["CTkSlider"]["corner_radius"] if corner_radius is None else corner_radius + self._button_corner_radius = ThemeManager.theme["CTkSlider"]["button_corner_radius"] if button_corner_radius is None else button_corner_radius + self._border_width = ThemeManager.theme["CTkSlider"]["border_width"] if border_width is None else border_width + self._button_length = ThemeManager.theme["CTkSlider"]["button_length"] if button_length is None else button_length + self._value: float = 0.5 # initial value of slider in percent + self._orientation = orientation + self._hover_state: bool = False + self._hover = hover + self._from_ = from_ + self._to = to + self._number_of_steps = number_of_steps + self._output_value = self._from_ + (self._value * (self._to - self._from_)) + + if self._corner_radius < self._button_corner_radius: + self._corner_radius = self._button_corner_radius + + # callback and control variables + self._command = command + self._variable: tkinter.Variable = variable + self._variable_callback_blocked: bool = False + self._variable_callback_name: Union[bool, None] = None + self._state = state + + self.grid_rowconfigure(0, weight=1) + self.grid_columnconfigure(0, weight=1) + + self._canvas = CTkCanvas(master=self, + highlightthickness=0, + width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height)) + self._canvas.grid(column=0, row=0, rowspan=1, columnspan=1, sticky="nswe") + self._draw_engine = DrawEngine(self._canvas) + + self._create_bindings() + self._set_cursor() + self._draw() # initial draw + + if self._variable is not None: + self._variable_callback_name = self._variable.trace_add("write", self._variable_callback) + self._variable_callback_blocked = True + self.set(self._variable.get(), from_variable_callback=True) + self._variable_callback_blocked = False + + def _create_bindings(self, sequence: Optional[str] = None): + """ set necessary bindings for functionality of widget, will overwrite other bindings """ + if sequence is None or sequence == "": + self._canvas.bind("", self._on_enter) + if sequence is None or sequence == "": + self._canvas.bind("", self._on_leave) + if sequence is None or sequence == "": + self._canvas.bind("", self._clicked) + if sequence is None or sequence == "": + self._canvas.bind("", self._clicked) + + def _set_scaling(self, *args, **kwargs): + super()._set_scaling(*args, **kwargs) + + self._canvas.configure(width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height)) + self._draw(no_color_updates=True) + + def _set_dimensions(self, width=None, height=None): + super()._set_dimensions(width, height) + + self._canvas.configure(width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height)) + self._draw() + + def destroy(self): + # remove variable_callback from variable callbacks if variable exists + if self._variable is not None: + self._variable.trace_remove("write", self._variable_callback_name) + + super().destroy() + + def _set_cursor(self): + if self._state == "normal" and self._cursor_manipulation_enabled: + if sys.platform == "darwin": + self.configure(cursor="pointinghand") + elif sys.platform.startswith("win"): + self.configure(cursor="hand2") + + elif self._state == "disabled" and self._cursor_manipulation_enabled: + if sys.platform == "darwin": + self.configure(cursor="arrow") + elif sys.platform.startswith("win"): + self.configure(cursor="arrow") + + def _draw(self, no_color_updates=False): + super()._draw(no_color_updates) + + if self._orientation.lower() == "horizontal": + orientation = "w" + elif self._orientation.lower() == "vertical": + orientation = "s" + else: + orientation = "w" + + requires_recoloring = self._draw_engine.draw_rounded_slider_with_border_and_button(self._apply_widget_scaling(self._current_width), + self._apply_widget_scaling(self._current_height), + self._apply_widget_scaling(self._corner_radius), + self._apply_widget_scaling(self._border_width), + self._apply_widget_scaling(self._button_length), + self._apply_widget_scaling(self._button_corner_radius), + self._value, orientation) + + if no_color_updates is False or requires_recoloring: + self._canvas.configure(bg=self._apply_appearance_mode(self._bg_color)) + + if self._border_color == "transparent": + self._canvas.itemconfig("border_parts", fill=self._apply_appearance_mode(self._bg_color), + outline=self._apply_appearance_mode(self._bg_color)) + else: + self._canvas.itemconfig("border_parts", fill=self._apply_appearance_mode(self._border_color), + outline=self._apply_appearance_mode(self._border_color)) + + self._canvas.itemconfig("inner_parts", fill=self._apply_appearance_mode(self._fg_color), + outline=self._apply_appearance_mode(self._fg_color)) + + if self._progress_color == "transparent": + self._canvas.itemconfig("progress_parts", fill=self._apply_appearance_mode(self._fg_color), + outline=self._apply_appearance_mode(self._fg_color)) + else: + self._canvas.itemconfig("progress_parts", fill=self._apply_appearance_mode(self._progress_color), + outline=self._apply_appearance_mode(self._progress_color)) + + if self._hover_state is True: + self._canvas.itemconfig("slider_parts", + fill=self._apply_appearance_mode(self._button_hover_color), + outline=self._apply_appearance_mode(self._button_hover_color)) + else: + self._canvas.itemconfig("slider_parts", + fill=self._apply_appearance_mode(self._button_color), + outline=self._apply_appearance_mode(self._button_color)) + + def configure(self, require_redraw=False, **kwargs): + if "state" in kwargs: + self._state = kwargs.pop("state") + self._set_cursor() + require_redraw = True + + if "fg_color" in kwargs: + self._fg_color = self._check_color_type(kwargs.pop("fg_color")) + require_redraw = True + + if "progress_color" in kwargs: + self._progress_color = self._check_color_type(kwargs.pop("progress_color"), transparency=True) + require_redraw = True + + if "button_color" in kwargs: + self._button_color = self._check_color_type(kwargs.pop("button_color")) + require_redraw = True + + if "button_hover_color" in kwargs: + self._button_hover_color = self._check_color_type(kwargs.pop("button_hover_color")) + require_redraw = True + + if "border_color" in kwargs: + self._border_color = self._check_color_type(kwargs.pop("border_color"), transparency=True) + require_redraw = True + + if "border_width" in kwargs: + self._border_width = kwargs.pop("border_width") + require_redraw = True + + if "from_" in kwargs: + self._from_ = kwargs.pop("from_") + + if "to" in kwargs: + self._to = kwargs.pop("to") + + if "number_of_steps" in kwargs: + self._number_of_steps = kwargs.pop("number_of_steps") + + if "hover" in kwargs: + self._hover = kwargs.pop("hover") + + if "command" in kwargs: + self._command = kwargs.pop("command") + + if "variable" in kwargs: + if self._variable is not None: + self._variable.trace_remove("write", self._variable_callback_name) + + self._variable = kwargs.pop("variable") + + if self._variable is not None and self._variable != "": + self._variable_callback_name = self._variable.trace_add("write", self._variable_callback) + self.set(self._variable.get(), from_variable_callback=True) + else: + self._variable = None + + super().configure(require_redraw=require_redraw, **kwargs) + + def cget(self, attribute_name: str) -> any: + if attribute_name == "corner_radius": + return self._corner_radius + elif attribute_name == "button_corner_radius": + return self._button_corner_radius + elif attribute_name == "border_width": + return self._border_width + elif attribute_name == "button_length": + return self._button_length + + elif attribute_name == "fg_color": + return self._fg_color + elif attribute_name == "border_color": + return self._border_color + elif attribute_name == "progress_color": + return self._progress_color + elif attribute_name == "button_color": + return self._button_color + elif attribute_name == "button_hover_color": + return self._button_hover_color + + elif attribute_name == "from_": + return self._from_ + elif attribute_name == "to": + return self._to + elif attribute_name == "state": + return self._state + elif attribute_name == "number_of_steps": + return self._number_of_steps + elif attribute_name == "hover": + return self._hover + elif attribute_name == "command": + return self._command + elif attribute_name == "variable": + return self._variable + elif attribute_name == "orientation": + return self._orientation + + else: + return super().cget(attribute_name) + + def _clicked(self, event=None): + if self._state == "normal": + if self._orientation.lower() == "horizontal": + self._value = self._reverse_widget_scaling(event.x / self._current_width) + else: + self._value = 1 - self._reverse_widget_scaling(event.y / self._current_height) + + if self._value > 1: + self._value = 1 + if self._value < 0: + self._value = 0 + + self._output_value = self._round_to_step_size(self._from_ + (self._value * (self._to - self._from_))) + self._value = (self._output_value - self._from_) / (self._to - self._from_) + + self._draw(no_color_updates=False) + + if self._variable is not None: + self._variable_callback_blocked = True + self._variable.set(round(self._output_value) if isinstance(self._variable, tkinter.IntVar) else self._output_value) + self._variable_callback_blocked = False + + if self._command is not None: + self._command(self._output_value) + + def _on_enter(self, event=0): + if self._hover is True and self._state == "normal": + self._hover_state = True + self._canvas.itemconfig("slider_parts", + fill=self._apply_appearance_mode(self._button_hover_color), + outline=self._apply_appearance_mode(self._button_hover_color)) + + def _on_leave(self, event=0): + self._hover_state = False + self._canvas.itemconfig("slider_parts", + fill=self._apply_appearance_mode(self._button_color), + outline=self._apply_appearance_mode(self._button_color)) + + def _round_to_step_size(self, value) -> float: + if self._number_of_steps is not None: + step_size = (self._to - self._from_) / self._number_of_steps + value = self._to - (round((self._to - value) / step_size) * step_size) + return value + else: + return value + + def get(self) -> float: + return self._output_value + + def set(self, output_value, from_variable_callback=False): + if self._from_ < self._to: + if output_value > self._to: + output_value = self._to + elif output_value < self._from_: + output_value = self._from_ + else: + if output_value < self._to: + output_value = self._to + elif output_value > self._from_: + output_value = self._from_ + + self._output_value = self._round_to_step_size(output_value) + self._value = (self._output_value - self._from_) / (self._to - self._from_) + + self._draw(no_color_updates=False) + + if self._variable is not None and not from_variable_callback: + self._variable_callback_blocked = True + self._variable.set(round(self._output_value) if isinstance(self._variable, tkinter.IntVar) else self._output_value) + self._variable_callback_blocked = False + + def _variable_callback(self, var_name, index, mode): + if not self._variable_callback_blocked: + self.set(self._variable.get(), from_variable_callback=True) + + def bind(self, sequence: str = None, command: Callable = None, add: Union[str, bool] = True): + """ called on the tkinter.Canvas """ + if not (add == "+" or add is True): + raise ValueError("'add' argument can only be '+' or True to preserve internal callbacks") + self._canvas.bind(sequence, command, add=True) + + def unbind(self, sequence: str = None, funcid: str = None): + """ called on the tkinter.Label and tkinter.Canvas """ + if funcid is not None: + raise ValueError("'funcid' argument can only be None, because there is a bug in" + + " tkinter and its not clear whether the internal callbacks will be unbinded or not") + self._canvas.unbind(sequence, None) + self._create_bindings(sequence=sequence) # restore internal callbacks for sequence + + def focus(self): + return self._canvas.focus() + + def focus_set(self): + return self._canvas.focus_set() + + def focus_force(self): + return self._canvas.focus_force() diff --git a/customtkinter/windows/widgets/ctk_switch.py b/customtkinter/windows/widgets/ctk_switch.py new file mode 100644 index 0000000..6e8de13 --- /dev/null +++ b/customtkinter/windows/widgets/ctk_switch.py @@ -0,0 +1,475 @@ +import tkinter +import sys +from typing import Union, Tuple, Callable, Optional + +from .core_rendering import CTkCanvas +from .theme import ThemeManager +from .core_rendering import DrawEngine +from .core_widget_classes import CTkBaseClass +from .font import CTkFont + + +class CTkSwitch(CTkBaseClass): + """ + Switch with rounded corners, border, label, command, variable support. + For detailed information check out the documentation. + """ + + def __init__(self, + master: any, + width: int = 100, + height: int = 24, + switch_width: int = 36, + switch_height: int = 18, + corner_radius: Optional[int] = None, + border_width: Optional[int] = None, + button_length: Optional[int] = None, + + bg_color: Union[str, Tuple[str, str]] = "transparent", + fg_color: Optional[Union[str, Tuple[str, str]]] = None, + border_color: Union[str, Tuple[str, str]] = "transparent", + progress_color: Optional[Union[str, Tuple[str, str]]] = None, + button_color: Optional[Union[str, Tuple[str, str]]] = None, + button_hover_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, + + text: str = "CTkSwitch", + font: Optional[Union[tuple, CTkFont]] = None, + textvariable: Union[tkinter.Variable, None] = None, + onvalue: Union[int, str] = 1, + offvalue: Union[int, str] = 0, + variable: Union[tkinter.Variable, None] = None, + hover: bool = True, + command: Union[Callable, None] = None, + state: str = tkinter.NORMAL, + **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) + + # dimensions + self._switch_width = switch_width + self._switch_height = switch_height + + # color + self._border_color = self._check_color_type(border_color, transparency=True) + self._fg_color = ThemeManager.theme["CTkSwitch"]["fg_Color"] if fg_color is None else self._check_color_type(fg_color) + self._progress_color = ThemeManager.theme["CTkSwitch"]["progress_color"] if progress_color is None else self._check_color_type(progress_color, transparency=True) + self._button_color = ThemeManager.theme["CTkSwitch"]["button_color"] if button_color is None else self._check_color_type(button_color) + self._button_hover_color = ThemeManager.theme["CTkSwitch"]["button_hover_color"] if button_hover_color is None else self._check_color_type(button_hover_color) + self._text_color = ThemeManager.theme["CTkSwitch"]["text_color"] if text_color is None else self._check_color_type(text_color) + self._text_color_disabled = ThemeManager.theme["CTkSwitch"]["text_color_disabled"] if text_color_disabled is None else self._check_color_type(text_color_disabled) + + # text + self._text = text + self._text_label = None + + # font + self._font = CTkFont() if font is None else self._check_font_type(font) + if isinstance(self._font, CTkFont): + self._font.add_size_configure_callback(self._update_font) + + # shape + self._corner_radius = ThemeManager.theme["CTkSwitch"]["corner_radius"] if corner_radius is None else corner_radius + self._border_width = ThemeManager.theme["CTkSwitch"]["border_width"] if border_width is None else border_width + self._button_length = ThemeManager.theme["CTkSwitch"]["button_length"] if button_length is None else button_length + self._hover_state: bool = False + self._check_state: bool = False # True if switch is activated + self._hover = hover + self._state = state + self._onvalue = onvalue + self._offvalue = offvalue + + # callback and control variables + self._command = command + self._variable = variable + self._variable_callback_blocked = False + self._variable_callback_name = None + self._textvariable = textvariable + + # configure grid system (3x1) + self.grid_columnconfigure(0, weight=0) + self.grid_columnconfigure(1, weight=0, minsize=self._apply_widget_scaling(6)) + self.grid_columnconfigure(2, weight=1) + self.grid_rowconfigure(0, weight=1) + + self._bg_canvas = CTkCanvas(master=self, + highlightthickness=0, + width=self._apply_widget_scaling(self._current_width), + height=self._apply_widget_scaling(self._current_height)) + self._bg_canvas.grid(row=0, column=0, columnspan=3, sticky="nswe") + + self._canvas = CTkCanvas(master=self, + highlightthickness=0, + width=self._apply_widget_scaling(self._switch_width), + height=self._apply_widget_scaling(self._switch_height)) + self._canvas.grid(row=0, column=0, sticky="") + self._draw_engine = DrawEngine(self._canvas) + + self._text_label = tkinter.Label(master=self, + bd=0, + padx=0, + pady=0, + text=self._text, + justify=tkinter.LEFT, + font=self._apply_font_scaling(self._font), + textvariable=self._textvariable) + self._text_label.grid(row=0, column=2, sticky="w") + self._text_label["anchor"] = "w" + + if self._variable is not None and self._variable != "": + self._variable_callback_name = self._variable.trace_add("write", self._variable_callback) + self._check_state = True if self._variable.get() == self._onvalue else False + + self._create_bindings() + self._set_cursor() + self._draw() # initial draw + + def _create_bindings(self, sequence: Optional[str] = None): + """ set necessary bindings for functionality of widget, will overwrite other bindings """ + if sequence is None or sequence == "": + self._canvas.bind("", self._on_enter) + self._text_label.bind("", self._on_enter) + if sequence is None or sequence == "": + self._canvas.bind("", self._on_leave) + self._text_label.bind("", self._on_leave) + if sequence is None or sequence == "": + self._canvas.bind("", self.toggle) + self._text_label.bind("", self.toggle) + + def _set_scaling(self, *args, **kwargs): + super()._set_scaling(*args, **kwargs) + + self.grid_columnconfigure(1, weight=0, minsize=self._apply_widget_scaling(6)) + self._text_label.configure(font=self._apply_font_scaling(self._font)) + + self._bg_canvas.configure(width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height)) + self._canvas.configure(width=self._apply_widget_scaling(self._switch_width), + height=self._apply_widget_scaling(self._switch_height)) + self._draw(no_color_updates=True) + + def _set_dimensions(self, width: int = None, height: int = None): + super()._set_dimensions(width, height) + + self._bg_canvas.configure(width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height)) + + def _update_font(self): + """ pass font to tkinter widgets with applied font scaling and update grid with workaround """ + self._text_label.configure(font=self._apply_font_scaling(self._font)) + + # Workaround to force grid to be resized when text changes size. + # Otherwise grid will lag and only resizes if other mouse action occurs. + self._bg_canvas.grid_forget() + self._bg_canvas.grid(row=0, column=0, columnspan=3, sticky="nswe") + + def destroy(self): + # remove variable_callback from variable callbacks if variable exists + if self._variable is not None: + self._variable.trace_remove("write", self._variable_callback_name) + + if isinstance(self._font, CTkFont): + self._font.remove_size_configure_callback(self._update_font) + + super().destroy() + + def _set_cursor(self): + if self._cursor_manipulation_enabled: + if self._state == tkinter.DISABLED: + if sys.platform == "darwin": + self._canvas.configure(cursor="arrow") + if self._text_label is not None: + self._text_label.configure(cursor="arrow") + elif sys.platform.startswith("win"): + self._canvas.configure(cursor="arrow") + if self._text_label is not None: + self._text_label.configure(cursor="arrow") + + elif self._state == tkinter.NORMAL: + if sys.platform == "darwin": + self._canvas.configure(cursor="pointinghand") + if self._text_label is not None: + self._text_label.configure(cursor="pointinghand") + elif sys.platform.startswith("win"): + self._canvas.configure(cursor="hand2") + if self._text_label is not None: + self._text_label.configure(cursor="hand2") + + def _draw(self, no_color_updates=False): + super()._draw(no_color_updates) + + if self._check_state is True: + requires_recoloring = self._draw_engine.draw_rounded_slider_with_border_and_button(self._apply_widget_scaling(self._switch_width), + self._apply_widget_scaling(self._switch_height), + self._apply_widget_scaling(self._corner_radius), + self._apply_widget_scaling(self._border_width), + self._apply_widget_scaling(self._button_length), + self._apply_widget_scaling(self._corner_radius), + 1, "w") + else: + requires_recoloring = self._draw_engine.draw_rounded_slider_with_border_and_button(self._apply_widget_scaling(self._switch_width), + self._apply_widget_scaling(self._switch_height), + self._apply_widget_scaling(self._corner_radius), + self._apply_widget_scaling(self._border_width), + self._apply_widget_scaling(self._button_length), + self._apply_widget_scaling(self._corner_radius), + 0, "w") + + if no_color_updates is False or requires_recoloring: + self._bg_canvas.configure(bg=self._apply_appearance_mode(self._bg_color)) + self._canvas.configure(bg=self._apply_appearance_mode(self._bg_color)) + + if self._border_color == "transparent": + self._canvas.itemconfig("border_parts", + fill=self._apply_appearance_mode(self._bg_color), + outline=self._apply_appearance_mode(self._bg_color)) + else: + self._canvas.itemconfig("border_parts", + fill=self._apply_appearance_mode(self._border_color), + outline=self._apply_appearance_mode(self._border_color)) + + self._canvas.itemconfig("inner_parts", + fill=self._apply_appearance_mode(self._fg_color), + outline=self._apply_appearance_mode(self._fg_color)) + + if self._progress_color == "transparent": + self._canvas.itemconfig("progress_parts", + fill=self._apply_appearance_mode(self._fg_color), + outline=self._apply_appearance_mode(self._fg_color)) + else: + self._canvas.itemconfig("progress_parts", + fill=self._apply_appearance_mode(self._progress_color), + outline=self._apply_appearance_mode(self._progress_color)) + + self._canvas.itemconfig("slider_parts", + fill=self._apply_appearance_mode(self._button_color), + outline=self._apply_appearance_mode(self._button_color)) + + if self._state == tkinter.DISABLED: + self._text_label.configure(fg=(self._apply_appearance_mode(self._text_color_disabled))) + else: + self._text_label.configure(fg=self._apply_appearance_mode(self._text_color)) + + self._text_label.configure(bg=self._apply_appearance_mode(self._bg_color)) + + def configure(self, require_redraw=False, **kwargs): + if "corner_radius" in kwargs: + self._corner_radius = kwargs.pop("corner_radius") + require_redraw = True + + if "border_width" in kwargs: + self._border_width = kwargs.pop("border_width") + require_redraw = True + + if "button_length" in kwargs: + self._button_length = kwargs.pop("button_length") + require_redraw = True + + if "switch_width" in kwargs: + self._switch_width = kwargs.pop("switch_width") + self._canvas.configure(width=self._apply_widget_scaling(self._switch_width)) + require_redraw = True + + if "switch_height" in kwargs: + self._switch_height = kwargs.pop("switch_height") + self._canvas.configure(height=self._apply_widget_scaling(self._switch_height)) + require_redraw = True + + if "text" in kwargs: + self._text = kwargs.pop("text") + self._text_label.configure(text=self._text) + + if "font" in kwargs: + if isinstance(self._font, CTkFont): + self._font.remove_size_configure_callback(self._update_font) + self._font = self._check_font_type(kwargs.pop("font")) + if isinstance(self._font, CTkFont): + self._font.add_size_configure_callback(self._update_font) + + self._update_font() + + if "state" in kwargs: + self._state = kwargs.pop("state") + self._set_cursor() + require_redraw = True + + if "fg_color" in kwargs: + self._fg_color = self._check_color_type(kwargs.pop("fg_color")) + require_redraw = True + + if "progress_color" in kwargs: + self._progress_color = self._check_color_type(kwargs.pop("progress_color"), transparency=True) + require_redraw = True + + if "button_color" in kwargs: + self._button_color = self._check_color_type(kwargs.pop("button_color")) + require_redraw = True + + if "button_hover_color" in kwargs: + self._button_hover_color = self._check_color_type(kwargs.pop("button_hover_color")) + require_redraw = True + + if "border_color" in kwargs: + self._border_color = self._check_color_type(kwargs.pop("border_color"), transparency=True) + require_redraw = True + + if "hover" in kwargs: + self._hover = kwargs.pop("hover") + + if "command" in kwargs: + self._command = kwargs.pop("command") + + if "textvariable" in kwargs: + self._textvariable = kwargs.pop("textvariable") + self._text_label.configure(textvariable=self._textvariable) + + if "variable" in kwargs: + if self._variable is not None and self._variable != "": + self._variable.trace_remove("write", self._variable_callback_name) + + self._variable = kwargs.pop("variable") + + if self._variable is not None and self._variable != "": + self._variable_callback_name = self._variable.trace_add("write", self._variable_callback) + self._check_state = True if self._variable.get() == self._onvalue else False + require_redraw = True + + super().configure(require_redraw=require_redraw, **kwargs) + + def cget(self, attribute_name: str) -> any: + if attribute_name == "corner_radius": + return self._corner_radius + elif attribute_name == "border_width": + return self._border_width + elif attribute_name == "button_length": + return self._button_length + elif attribute_name == "switch_width": + return self._switch_width + elif attribute_name == "switch_height": + return self._switch_height + + elif attribute_name == "fg_color": + return self._fg_color + elif attribute_name == "border_color": + return self._border_color + elif attribute_name == "progress_color": + return self._progress_color + elif attribute_name == "button_color": + return self._button_color + elif attribute_name == "button_hover_color": + return self._button_hover_color + elif attribute_name == "text_color": + return self._text_color + elif attribute_name == "text_color_disabled": + return self._text_color_disabled + + elif attribute_name == "text": + return self._text + elif attribute_name == "font": + return self._font + elif attribute_name == "textvariable": + return self._textvariable + elif attribute_name == "onvalue": + return self._onvalue + elif attribute_name == "offvalue": + return self._offvalue + elif attribute_name == "variable": + return self._variable + elif attribute_name == "hover": + return self._hover + elif attribute_name == "command": + return self._command + elif attribute_name == "state": + return self._state + + else: + return super().cget(attribute_name) + + def toggle(self, event=None): + if self._state is not tkinter.DISABLED: + if self._check_state is True: + self._check_state = False + else: + self._check_state = True + + self._draw(no_color_updates=True) + + if self._variable is not None: + self._variable_callback_blocked = True + self._variable.set(self._onvalue if self._check_state is True else self._offvalue) + self._variable_callback_blocked = False + + if self._command is not None: + self._command() + + def select(self, from_variable_callback=False): + if self._state is not tkinter.DISABLED or from_variable_callback: + self._check_state = True + + self._draw(no_color_updates=True) + + if self._variable is not None and not from_variable_callback: + self._variable_callback_blocked = True + self._variable.set(self._onvalue) + self._variable_callback_blocked = False + + def deselect(self, from_variable_callback=False): + if self._state is not tkinter.DISABLED or from_variable_callback: + self._check_state = False + + self._draw(no_color_updates=True) + + if self._variable is not None and not from_variable_callback: + self._variable_callback_blocked = True + self._variable.set(self._offvalue) + self._variable_callback_blocked = False + + def get(self) -> Union[int, str]: + return self._onvalue if self._check_state is True else self._offvalue + + def _on_enter(self, event=0): + if self._hover is True and self._state == "normal": + self._hover_state = True + self._canvas.itemconfig("slider_parts", + fill=self._apply_appearance_mode(self._button_hover_color), + outline=self._apply_appearance_mode(self._button_hover_color)) + + def _on_leave(self, event=0): + self._hover_state = False + self._canvas.itemconfig("slider_parts", + fill=self._apply_appearance_mode(self._button_color), + outline=self._apply_appearance_mode(self._button_color)) + + def _variable_callback(self, var_name, index, mode): + if not self._variable_callback_blocked: + if self._variable.get() == self._onvalue: + self.select(from_variable_callback=True) + elif self._variable.get() == self._offvalue: + self.deselect(from_variable_callback=True) + + def bind(self, sequence: str = None, command: Callable = None, add: Union[str, bool] = True): + """ called on the tkinter.Canvas """ + if not (add == "+" or add is True): + raise ValueError("'add' argument can only be '+' or True to preserve internal callbacks") + self._canvas.bind(sequence, command, add=True) + self._text_label.bind(sequence, command, add=True) + + def unbind(self, sequence: str = None, funcid: str = None): + """ called on the tkinter.Label and tkinter.Canvas """ + if funcid is not None: + raise ValueError("'funcid' argument can only be None, because there is a bug in" + + " tkinter and its not clear whether the internal callbacks will be unbinded or not") + self._canvas.unbind(sequence, None) + self._text_label.unbind(sequence, None) + self._create_bindings(sequence=sequence) # restore internal callbacks for sequence + + def focus(self): + return self._text_label.focus() + + def focus_set(self): + return self._text_label.focus_set() + + def focus_force(self): + return self._text_label.focus_force() diff --git a/customtkinter/windows/widgets/ctk_tabview.py b/customtkinter/windows/widgets/ctk_tabview.py new file mode 100644 index 0000000..75bde4b --- /dev/null +++ b/customtkinter/windows/widgets/ctk_tabview.py @@ -0,0 +1,370 @@ +import tkinter +from typing import Union, Tuple, Dict, List, Callable, Optional + +from .theme import ThemeManager +from .ctk_frame import CTkFrame +from .core_rendering import CTkCanvas +from .core_rendering import DrawEngine +from .core_widget_classes import CTkBaseClass +from .ctk_segmented_button import CTkSegmentedButton + + +class CTkTabview(CTkBaseClass): + """ + Tabview... + For detailed information check out the documentation. + """ + + _top_spacing: int = 10 # px on top of the buttons + _top_button_overhang: int = 8 # px + _button_height: int = 26 + _segmented_button_border_width: int = 3 + + def __init__(self, + master: any, + width: int = 300, + height: int = 250, + corner_radius: Optional[int] = None, + border_width: Optional[int] = None, + + bg_color: Union[str, Tuple[str, str]] = "transparent", + fg_color: Optional[Union[str, Tuple[str, str]]] = None, + border_color: Optional[Union[str, Tuple[str, str]]] = None, + + segmented_button_fg_color: Optional[Union[str, Tuple[str, str]]] = None, + segmented_button_selected_color: Optional[Union[str, Tuple[str, str]]] = None, + segmented_button_selected_hover_color: Optional[Union[str, Tuple[str, str]]] = None, + segmented_button_unselected_color: Optional[Union[str, Tuple[str, str]]] = None, + segmented_button_unselected_hover_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, + + command: Union[Callable, None] = None, + state: str = "normal", + **kwargs): + + # transfer some functionality to CTkFrame + super().__init__(master=master, bg_color=bg_color, width=width, height=height, **kwargs) + + # color + self._border_color = ThemeManager.theme["CTkFrame"]["border_color"] if border_color is None else self._check_color_type(border_color) + + # determine fg_color of frame + if fg_color is None: + if isinstance(self.master, (CTkFrame, CTkTabview)): + if self.master.cget("fg_color") == ThemeManager.theme["CTkFrame"]["fg_color"]: + self._fg_color = ThemeManager.theme["CTkFrame"]["top_fg_color"] + else: + self._fg_color = ThemeManager.theme["CTkFrame"]["fg_color"] + else: + self._fg_color = ThemeManager.theme["CTkFrame"]["fg_color"] + else: + self._fg_color = self._check_color_type(fg_color, transparency=True) + + # shape + self._corner_radius = ThemeManager.theme["CTkFrame"]["corner_radius"] if corner_radius is None else corner_radius + self._border_width = ThemeManager.theme["CTkFrame"]["border_width"] if border_width is None else border_width + + self._canvas = CTkCanvas(master=self, + bg=self._apply_appearance_mode(self._bg_color), + highlightthickness=0, + width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height - self._top_spacing - self._top_button_overhang)) + self._draw_engine = DrawEngine(self._canvas) + + self._segmented_button = CTkSegmentedButton(self, + values=[], + height=self._button_height, + fg_color=segmented_button_fg_color, + selected_color=segmented_button_selected_color, + selected_hover_color=segmented_button_selected_hover_color, + unselected_color=segmented_button_unselected_color, + unselected_hover_color=segmented_button_unselected_hover_color, + text_color=text_color, + text_color_disabled=text_color_disabled, + corner_radius=corner_radius, + border_width=self._segmented_button_border_width, + command=self._segmented_button_callback, + state=state) + self._configure_segmented_button_background_corners() + self._configure_grid() + self._set_grid_canvas() + + self._tab_dict: Dict[str, CTkFrame] = {} + self._name_list: List[str] = [] # list of unique tab names in order of tabs + self._current_name: str = "" + self._command = command + + self._draw() + + def _segmented_button_callback(self, selected_name): + self._current_name = selected_name + self._grid_forget_all_tabs() + self._set_grid_tab_by_name(self._current_name) + + if self._command is not None: + self._command() + + def winfo_children(self) -> List[any]: + """ + winfo_children of CTkTabview without canvas and segmented button widgets, + because it's not a child but part of the CTkTabview itself + """ + + child_widgets = super().winfo_children() + try: + child_widgets.remove(self._canvas) + child_widgets.remove(self._segmented_button) + return child_widgets + except ValueError: + return child_widgets + + def _set_scaling(self, *args, **kwargs): + super()._set_scaling(*args, **kwargs) + + self._canvas.configure(width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height - self._top_spacing - self._top_button_overhang)) + self._configure_grid() + self._draw(no_color_updates=True) + + def _set_dimensions(self, width=None, height=None): + super()._set_dimensions(width, height) + + self._canvas.configure(width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height - self._top_spacing - self._top_button_overhang)) + self._draw() + + def _configure_segmented_button_background_corners(self): + """ needs to be called for changes in fg_color, bg_color """ + + if self._fg_color is not None: + self._segmented_button.configure(background_corner_colors=(self._bg_color, self._bg_color, self._fg_color, self._fg_color)) + else: + self._segmented_button.configure(background_corner_colors=(self._bg_color, self._bg_color, self._bg_color, self._bg_color)) + + def _configure_tab_background_corners_by_name(self, name: str): + """ needs to be called for changes in fg_color, bg_color, border_width """ + + self._tab_dict[name].configure(background_corner_colors=None) + + def _configure_grid(self): + """ create 3 x 4 grid system """ + + self.grid_rowconfigure(0, weight=0, minsize=self._apply_widget_scaling(self._top_spacing)) + self.grid_rowconfigure(1, weight=0, minsize=self._apply_widget_scaling(self._top_button_overhang)) + self.grid_rowconfigure(2, weight=0, minsize=self._apply_widget_scaling(self._button_height - self._top_button_overhang)) + self.grid_rowconfigure(3, weight=1) + + self.grid_columnconfigure(0, weight=1) + + def _set_grid_canvas(self): + self._canvas.grid(row=2, rowspan=2, column=0, columnspan=1, sticky="nsew") + + def _set_grid_segmented_button(self): + """ needs to be called for changes in corner_radius """ + self._segmented_button.grid(row=1, rowspan=2, column=0, columnspan=1, padx=self._apply_widget_scaling(self._corner_radius), sticky="ns") + + def _set_grid_tab_by_name(self, name: str): + """ needs to be called for changes in corner_radius, border_width """ + self._tab_dict[name].grid(row=3, column=0, sticky="nsew", + padx=self._apply_widget_scaling(max(self._corner_radius, self._border_width)), + pady=self._apply_widget_scaling(max(self._corner_radius, self._border_width))) + + def _grid_forget_all_tabs(self): + for frame in self._tab_dict.values(): + frame.grid_forget() + + def _create_tab(self) -> CTkFrame: + new_tab = CTkFrame(self, + height=0, + width=0, + fg_color=self._fg_color, + border_width=0, + corner_radius=self._corner_radius) + return new_tab + + def _draw(self, no_color_updates: bool = False): + super()._draw(no_color_updates) + + if not self._canvas.winfo_exists(): + return + + requires_recoloring = self._draw_engine.draw_rounded_rect_with_border(self._apply_widget_scaling(self._current_width), + self._apply_widget_scaling(self._current_height - self._top_spacing - self._top_button_overhang), + self._apply_widget_scaling(self._corner_radius), + self._apply_widget_scaling(self._border_width)) + + if no_color_updates is False or requires_recoloring: + if self._fg_color == "transparent": + self._canvas.itemconfig("inner_parts", + fill=self._apply_appearance_mode(self._bg_color), + outline=self._apply_appearance_mode(self._bg_color)) + else: + self._canvas.itemconfig("inner_parts", + fill=self._apply_appearance_mode(self._fg_color), + outline=self._apply_appearance_mode(self._fg_color)) + + self._canvas.itemconfig("border_parts", + fill=self._apply_appearance_mode(self._border_color), + outline=self._apply_appearance_mode(self._border_color)) + self._canvas.configure(bg=self._apply_appearance_mode(self._bg_color)) + tkinter.Frame.configure(self, bg=self._apply_appearance_mode(self._bg_color)) # configure bg color of tkinter.Frame, cuase canvas does not fill frame + + def configure(self, require_redraw=False, **kwargs): + if "corner_radius" in kwargs: + self._corner_radius = kwargs.pop("corner_radius") + require_redraw = True + if "border_width" in kwargs: + self._border_width = kwargs.pop("border_width") + require_redraw = True + + if "fg_color" in kwargs: + self._fg_color = self._check_color_type(kwargs.pop("fg_color"), transparency=True) + require_redraw = True + if "border_color" in kwargs: + self._border_color = self._check_color_type(kwargs.pop("border_color")) + require_redraw = True + if "segmented_button_fg_color" in kwargs: + self._segmented_button.configure(fg_color=kwargs.pop("segmented_button_fg_color")) + if "segmented_button_selected_color" in kwargs: + self._segmented_button.configure(selected_color=kwargs.pop("segmented_button_selected_color")) + if "segmented_button_selected_hover_color" in kwargs: + self._segmented_button.configure(selected_hover_color=kwargs.pop("segmented_button_selected_hover_color")) + if "segmented_button_unselected_color" in kwargs: + self._segmented_button.configure(unselected_color=kwargs.pop("segmented_button_unselected_color")) + if "segmented_button_unselected_hover_color" in kwargs: + self._segmented_button.configure(unselected_hover_color=kwargs.pop("segmented_button_unselected_hover_color")) + if "text_color" in kwargs: + self._segmented_button.configure(text_color=kwargs.pop("text_color")) + if "text_color_disabled" in kwargs: + self._segmented_button.configure(text_color_disabled=kwargs.pop("text_color_disabled")) + + if "command" in kwargs: + self._command = kwargs.pop("command") + if "state" in kwargs: + self._segmented_button.configure(state=kwargs.pop("state")) + + super().configure(require_redraw=require_redraw, **kwargs) + + def cget(self, attribute_name: str): + if attribute_name == "corner_radius": + return self._corner_radius + elif attribute_name == "border_width": + return self._border_width + + elif attribute_name == "fg_color": + return self._fg_color + elif attribute_name == "border_color": + return self._border_color + elif attribute_name == "segmented_button_fg_color": + return self._segmented_button.cget(attribute_name) + elif attribute_name == "segmented_button_selected_color": + return self._segmented_button.cget(attribute_name) + elif attribute_name == "segmented_button_selected_hover_color": + return self._segmented_button.cget(attribute_name) + elif attribute_name == "segmented_button_unselected_color": + return self._segmented_button.cget(attribute_name) + elif attribute_name == "segmented_button_unselected_hover_color": + return self._segmented_button.cget(attribute_name) + elif attribute_name == "text_color": + return self._segmented_button.cget(attribute_name) + elif attribute_name == "text_color_disabled": + return self._segmented_button.cget(attribute_name) + + elif attribute_name == "command": + return self._command + elif attribute_name == "state": + return self._segmented_button.cget(attribute_name) + + else: + return super().cget(attribute_name) + + def tab(self, name: str) -> CTkFrame: + """ returns reference to the tab with given name """ + + if name in self._tab_dict: + return self._tab_dict[name] + else: + raise ValueError(f"CTkTabview has no tab named '{name}'") + + def insert(self, index: int, name: str) -> CTkFrame: + """ creates new tab with given name at position index """ + + if name not in self._tab_dict: + # if no tab exists, set grid for segmented button + if len(self._tab_dict) == 0: + self._set_grid_segmented_button() + + self._name_list.insert(index, name) + self._tab_dict[name] = self._create_tab() + self._segmented_button.insert(index, name) + self._configure_tab_background_corners_by_name(name) + + # if created tab is only tab select this tab + if len(self._tab_dict) == 1: + self._current_name = name + self._segmented_button.set(self._current_name) + self._grid_forget_all_tabs() + self._set_grid_tab_by_name(self._current_name) + + return self._tab_dict[name] + else: + raise ValueError(f"CTkTabview already has tab named '{name}'") + + def add(self, name: str) -> CTkFrame: + """ appends new tab with given name """ + return self.insert(len(self._tab_dict), name) + + def move(self, new_index: int, name: str): + if 0 <= new_index < len(self._name_list): + if name in self._tab_dict: + self._segmented_button.move(new_index, name) + else: + raise ValueError(f"CTkTabview has no name '{name}'") + else: + raise ValueError(f"CTkTabview new_index {new_index} not in range of name list with len {len(self._name_list)}") + + def delete(self, name: str): + """ delete tab by name """ + + if name in self._tab_dict: + self._name_list.remove(name) + self._tab_dict[name].grid_forget() + self._tab_dict.pop(name) + self._segmented_button.delete(name) + + # set current_name to '' and remove segmented button if no tab is left + if len(self._name_list) == 0: + self._current_name = "" + self._segmented_button.grid_forget() + + # if only one tab left, select this tab + elif len(self._name_list) == 1: + self._current_name = self._name_list[0] + self._segmented_button.set(self._current_name) + self._grid_forget_all_tabs() + self._set_grid_tab_by_name(self._current_name) + + # more tabs are left + else: + # if current_name is deleted tab, select first tab at position 0 + if self._current_name == name: + self.set(self._name_list[0]) + else: + raise ValueError(f"CTkTabview has no tab named '{name}'") + + def set(self, name: str): + """ select tab by name """ + + if name in self._tab_dict: + self._current_name = name + self._segmented_button.set(name) + self._grid_forget_all_tabs() + self._set_grid_tab_by_name(name) + else: + raise ValueError(f"CTkTabview has no tab named '{name}'") + + def get(self) -> str: + """ returns name of selected tab, returns empty string if no tab selected """ + return self._current_name diff --git a/customtkinter/windows/widgets/ctk_textbox.py b/customtkinter/windows/widgets/ctk_textbox.py new file mode 100644 index 0000000..eeee9ef --- /dev/null +++ b/customtkinter/windows/widgets/ctk_textbox.py @@ -0,0 +1,500 @@ +import tkinter +from typing import Union, Tuple, Optional, Callable + +from .core_rendering import CTkCanvas +from .ctk_scrollbar import CTkScrollbar +from .theme import ThemeManager +from .core_rendering import DrawEngine +from .core_widget_classes import CTkBaseClass +from .font import CTkFont +from .utility import pop_from_dict_by_set, check_kwargs_empty + + +class CTkTextbox(CTkBaseClass): + """ + Textbox with x and y scrollbars, rounded corners, and all text features of tkinter.Text widget. + Scrollbars only appear when they are needed. Text is wrapped on line end by default, + set wrap='none' to disable automatic line wrapping. + For detailed information check out the documentation. + + Detailed methods and parameters of the underlaying tkinter.Text widget can be found here: + https://anzeljg.github.io/rin2/book2/2405/docs/tkinter/text.html + (most of them are implemented here too) + """ + + _scrollbar_update_time = 200 # interval in ms, to check if scrollbars are needed + + # attributes that are passed to and managed by the tkinter textbox only: + _valid_tk_text_attributes = {"autoseparators", "cursor", "exportselection", + "insertborderwidth", "insertofftime", "insertontime", "insertwidth", + "maxundo", "padx", "pady", "selectborderwidth", "spacing1", + "spacing2", "spacing3", "state", "tabs", "takefocus", "undo", "wrap", + "xscrollcommand", "yscrollcommand"} + + def __init__(self, + master: any, + width: int = 200, + height: int = 200, + corner_radius: Optional[int] = None, + border_width: Optional[int] = None, + border_spacing: int = 3, + + bg_color: Union[str, Tuple[str, str]] = "transparent", + fg_color: Optional[Union[str, Tuple[str, str]]] = None, + border_color: Optional[Union[str, Tuple[str, str]]] = None, + text_color: Optional[Union[str, str]] = None, + scrollbar_button_color: Optional[Union[str, Tuple[str, str]]] = None, + scrollbar_button_hover_color: Optional[Union[str, Tuple[str, str]]] = None, + + font: Optional[Union[tuple, CTkFont]] = None, + activate_scrollbars: bool = True, + **kwargs): + + # transfer basic functionality (_bg_color, size, __appearance_mode, scaling) to CTkBaseClass + super().__init__(master=master, bg_color=bg_color, width=width, height=height) + + # color + self._fg_color = ThemeManager.theme["CTkTextbox"]["fg_color"] if fg_color is None else self._check_color_type(fg_color, transparency=True) + self._border_color = ThemeManager.theme["CTkTextbox"]["border_color"] if border_color is None else self._check_color_type(border_color) + self._text_color = ThemeManager.theme["CTkTextbox"]["text_color"] if text_color is None else self._check_color_type(text_color) + self._scrollbar_button_color = ThemeManager.theme["CTkTextbox"]["scrollbar_button_color"] if scrollbar_button_color is None else self._check_color_type(scrollbar_button_color) + self._scrollbar_button_hover_color = ThemeManager.theme["CTkTextbox"]["scrollbar_button_hover_color"] if scrollbar_button_hover_color is None else self._check_color_type(scrollbar_button_hover_color) + + # shape + self._corner_radius = ThemeManager.theme["CTkTextbox"]["corner_radius"] if corner_radius is None else corner_radius + self._border_width = ThemeManager.theme["CTkTextbox"]["border_width"] if border_width is None else border_width + self._border_spacing = border_spacing + + # font + self._font = CTkFont() if font is None else self._check_font_type(font) + if isinstance(self._font, CTkFont): + self._font.add_size_configure_callback(self._update_font) + + self._canvas = CTkCanvas(master=self, + highlightthickness=0, + width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height)) + self._canvas.grid(row=0, column=0, rowspan=2, columnspan=2, sticky="nsew") + self._canvas.configure(bg=self._apply_appearance_mode(self._bg_color)) + self._draw_engine = DrawEngine(self._canvas) + + self._textbox = tkinter.Text(self, + fg=self._apply_appearance_mode(self._text_color), + width=0, + height=0, + font=self._apply_font_scaling(self._font), + highlightthickness=0, + relief="flat", + insertbackground=self._apply_appearance_mode(self._text_color), + **pop_from_dict_by_set(kwargs, self._valid_tk_text_attributes)) + + check_kwargs_empty(kwargs, raise_error=True) + + # scrollbars + self._scrollbars_activated = activate_scrollbars + self._hide_x_scrollbar = True + self._hide_y_scrollbar = True + + self._y_scrollbar = CTkScrollbar(self, + width=8, + height=0, + border_spacing=0, + fg_color=self._fg_color, + button_color=self._scrollbar_button_color, + button_hover_color=self._scrollbar_button_hover_color, + orientation="vertical", + command=self._textbox.yview) + self._textbox.configure(yscrollcommand=self._y_scrollbar.set) + + self._x_scrollbar = CTkScrollbar(self, + height=8, + width=0, + border_spacing=0, + fg_color=self._fg_color, + button_color=self._scrollbar_button_color, + button_hover_color=self._scrollbar_button_hover_color, + orientation="horizontal", + command=self._textbox.xview) + self._textbox.configure(xscrollcommand=self._x_scrollbar.set) + + self._create_grid_for_text_and_scrollbars(re_grid_textbox=True, re_grid_x_scrollbar=True, re_grid_y_scrollbar=True) + + self.after(50, self._check_if_scrollbars_needed, None, True) + self._draw() + + def _create_grid_for_text_and_scrollbars(self, re_grid_textbox=False, re_grid_x_scrollbar=False, re_grid_y_scrollbar=False): + + # configure 2x2 grid + self.grid_rowconfigure(0, weight=1) + self.grid_rowconfigure(1, weight=0, minsize=self._apply_widget_scaling(max(self._corner_radius, self._border_width + self._border_spacing))) + self.grid_columnconfigure(0, weight=1) + self.grid_columnconfigure(1, weight=0, minsize=self._apply_widget_scaling(max(self._corner_radius, self._border_width + self._border_spacing))) + + if re_grid_textbox: + self._textbox.grid(row=0, column=0, rowspan=1, columnspan=1, sticky="nsew", + padx=(self._apply_widget_scaling(max(self._corner_radius, self._border_width + self._border_spacing)), 0), + pady=(self._apply_widget_scaling(max(self._corner_radius, self._border_width + self._border_spacing)), 0)) + + if re_grid_x_scrollbar: + if not self._hide_x_scrollbar and self._scrollbars_activated: + self._x_scrollbar.grid(row=1, column=0, rowspan=1, columnspan=1, sticky="ewn", + pady=(3, self._border_spacing + self._border_width), + padx=(max(self._corner_radius, self._border_width + self._border_spacing), 0)) # scrollbar grid method without scaling + else: + self._x_scrollbar.grid_forget() + + if re_grid_y_scrollbar: + if not self._hide_y_scrollbar and self._scrollbars_activated: + self._y_scrollbar.grid(row=0, column=1, rowspan=1, columnspan=1, sticky="nsw", + padx=(3, self._border_spacing + self._border_width), + pady=(max(self._corner_radius, self._border_width + self._border_spacing), 0)) # scrollbar grid method without scaling + else: + self._y_scrollbar.grid_forget() + + def _check_if_scrollbars_needed(self, event=None, continue_loop: bool = False): + """ Method hides or places the scrollbars if they are needed on key release event of tkinter.text widget """ + + if self._scrollbars_activated: + if self._textbox.xview() != (0.0, 1.0) and not self._x_scrollbar.winfo_ismapped(): # x scrollbar needed + self._hide_x_scrollbar = False + self._create_grid_for_text_and_scrollbars(re_grid_x_scrollbar=True) + elif self._textbox.xview() == (0.0, 1.0) and self._x_scrollbar.winfo_ismapped(): # x scrollbar not needed + self._hide_x_scrollbar = True + self._create_grid_for_text_and_scrollbars(re_grid_x_scrollbar=True) + + if self._textbox.yview() != (0.0, 1.0) and not self._y_scrollbar.winfo_ismapped(): # y scrollbar needed + self._hide_y_scrollbar = False + self._create_grid_for_text_and_scrollbars(re_grid_y_scrollbar=True) + elif self._textbox.yview() == (0.0, 1.0) and self._y_scrollbar.winfo_ismapped(): # y scrollbar not needed + self._hide_y_scrollbar = True + self._create_grid_for_text_and_scrollbars(re_grid_y_scrollbar=True) + else: + self._hide_x_scrollbar = False + self._hide_x_scrollbar = False + self._create_grid_for_text_and_scrollbars(re_grid_y_scrollbar=True) + + if self._textbox.winfo_exists() and continue_loop is True: + self.after(self._scrollbar_update_time, lambda: self._check_if_scrollbars_needed(continue_loop=True)) + + def _set_scaling(self, *args, **kwargs): + super()._set_scaling(*args, **kwargs) + + self._textbox.configure(font=self._apply_font_scaling(self._font)) + self._canvas.configure(width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height)) + self._create_grid_for_text_and_scrollbars(re_grid_textbox=True, re_grid_x_scrollbar=True, re_grid_y_scrollbar=True) + self._draw(no_color_updates=True) + + def _set_dimensions(self, width=None, height=None): + super()._set_dimensions(width, height) + + self._canvas.configure(width=self._apply_widget_scaling(self._desired_width), + height=self._apply_widget_scaling(self._desired_height)) + self._draw() + + def _update_font(self): + """ pass font to tkinter widgets with applied font scaling and update grid with workaround """ + self._textbox.configure(font=self._apply_font_scaling(self._font)) + + # Workaround to force grid to be resized when text changes size. + # Otherwise grid will lag and only resizes if other mouse action occurs. + self._canvas.grid_forget() + self._canvas.grid(row=0, column=0, rowspan=2, columnspan=2, sticky="nsew") + + def destroy(self): + if isinstance(self._font, CTkFont): + self._font.remove_size_configure_callback(self._update_font) + + super().destroy() + + def _draw(self, no_color_updates=False): + super()._draw(no_color_updates) + + if not self._canvas.winfo_exists(): + return + + requires_recoloring = self._draw_engine.draw_rounded_rect_with_border(self._apply_widget_scaling(self._current_width), + self._apply_widget_scaling(self._current_height), + self._apply_widget_scaling(self._corner_radius), + self._apply_widget_scaling(self._border_width)) + + if no_color_updates is False or requires_recoloring: + if self._fg_color == "transparent": + self._canvas.itemconfig("inner_parts", + fill=self._apply_appearance_mode(self._bg_color), + outline=self._apply_appearance_mode(self._bg_color)) + self._textbox.configure(fg=self._apply_appearance_mode(self._text_color), + bg=self._apply_appearance_mode(self._bg_color), + insertbackground=self._apply_appearance_mode(self._text_color)) + self._x_scrollbar.configure(fg_color=self._bg_color, button_color=self._scrollbar_button_color, + button_hover_color=self._scrollbar_button_hover_color) + self._y_scrollbar.configure(fg_color=self._bg_color, button_color=self._scrollbar_button_color, + button_hover_color=self._scrollbar_button_hover_color) + else: + self._canvas.itemconfig("inner_parts", + fill=self._apply_appearance_mode(self._fg_color), + outline=self._apply_appearance_mode(self._fg_color)) + self._textbox.configure(fg=self._apply_appearance_mode(self._text_color), + bg=self._apply_appearance_mode(self._fg_color), + insertbackground=self._apply_appearance_mode(self._text_color)) + self._x_scrollbar.configure(fg_color=self._fg_color, button_color=self._scrollbar_button_color, + button_hover_color=self._scrollbar_button_hover_color) + self._y_scrollbar.configure(fg_color=self._fg_color, button_color=self._scrollbar_button_color, + button_hover_color=self._scrollbar_button_hover_color) + + self._canvas.itemconfig("border_parts", + fill=self._apply_appearance_mode(self._border_color), + outline=self._apply_appearance_mode(self._border_color)) + self._canvas.configure(bg=self._apply_appearance_mode(self._bg_color)) + + self._canvas.tag_lower("inner_parts") + self._canvas.tag_lower("border_parts") + + def configure(self, require_redraw=False, **kwargs): + if "fg_color" in kwargs: + self._fg_color = self._check_color_type(kwargs.pop("fg_color"), transparency=True) + require_redraw = True + + # check if CTk widgets are children of the frame and change their _bg_color to new frame fg_color + for child in self.winfo_children(): + if isinstance(child, CTkBaseClass) and hasattr(child, "_fg_color"): + child.configure(bg_color=self._fg_color) + + if "border_color" in kwargs: + self._border_color = self._check_color_type(kwargs.pop("border_color")) + require_redraw = True + + if "text_color" in kwargs: + self._text_color = self._check_color_type(kwargs.pop("text_color")) + require_redraw = True + + if "scrollbar_button_color" in kwargs: + self._scrollbar_button_color = self._check_color_type(kwargs.pop("scrollbar_button_color")) + self._x_scrollbar.configure(button_color=self._scrollbar_button_color) + self._y_scrollbar.configure(button_color=self._scrollbar_button_color) + + if "scrollbar_button_hover_color" in kwargs: + self._scrollbar_button_hover_color = self._check_color_type(kwargs.pop("scrollbar_button_hover_color")) + self._x_scrollbar.configure(button_hover_color=self._scrollbar_button_hover_color) + self._y_scrollbar.configure(button_hover_color=self._scrollbar_button_hover_color) + + if "corner_radius" in kwargs: + self._corner_radius = kwargs.pop("corner_radius") + self._create_grid_for_text_and_scrollbars(re_grid_textbox=True, re_grid_x_scrollbar=True, re_grid_y_scrollbar=True) + require_redraw = True + + if "border_width" in kwargs: + self._border_width = kwargs.pop("border_width") + self._create_grid_for_text_and_scrollbars(re_grid_textbox=True, re_grid_x_scrollbar=True, re_grid_y_scrollbar=True) + require_redraw = True + + if "border_spacing" in kwargs: + self._border_spacing = kwargs.pop("border_spacing") + self._create_grid_for_text_and_scrollbars(re_grid_textbox=True, re_grid_x_scrollbar=True, re_grid_y_scrollbar=True) + require_redraw = True + + if "font" in kwargs: + if isinstance(self._font, CTkFont): + self._font.remove_size_configure_callback(self._update_font) + self._font = self._check_font_type(kwargs.pop("font")) + if isinstance(self._font, CTkFont): + self._font.add_size_configure_callback(self._update_font) + + self._update_font() + + self._textbox.configure(**pop_from_dict_by_set(kwargs, self._valid_tk_text_attributes)) + super().configure(require_redraw=require_redraw, **kwargs) + + def cget(self, attribute_name: str) -> any: + if attribute_name == "corner_radius": + return self._corner_radius + elif attribute_name == "border_width": + return self._border_width + elif attribute_name == "border_spacing": + return self._border_spacing + + elif attribute_name == "fg_color": + return self._fg_color + elif attribute_name == "border_color": + return self._border_color + elif attribute_name == "text_color": + return self._text_color + + elif attribute_name == "font": + return self._font + + else: + return super().cget(attribute_name) + + def bind(self, sequence: str = None, command: Callable = None, add: Union[str, bool] = True): + """ called on the tkinter.Canvas """ + if not (add == "+" or add is True): + raise ValueError("'add' argument can only be '+' or True to preserve internal callbacks") + self._textbox.bind(sequence, command, add=True) + + def unbind(self, sequence: str = None, funcid: str = None): + """ called on the tkinter.Label and tkinter.Canvas """ + if funcid is not None: + raise ValueError("'funcid' argument can only be None, because there is a bug in" + + " tkinter and its not clear whether the internal callbacks will be unbinded or not") + self._textbox.unbind(sequence, None) + + def focus(self): + return self._textbox.focus() + + def focus_set(self): + return self._textbox.focus_set() + + def focus_force(self): + return self._textbox.focus_force() + + def insert(self, index, text, tags=None): + return self._textbox.insert(index, text, tags) + + def get(self, index1, index2=None): + return self._textbox.get(index1, index2) + + def bbox(self, index): + return self._textbox.bbox(index) + + def compare(self, index, op, index2): + return self._textbox.compare(index, op, index2) + + def delete(self, index1, index2=None): + return self._textbox.delete(index1, index2) + + def dlineinfo(self, index): + return self._textbox.dlineinfo(index) + + def edit_modified(self, arg=None): + return self._textbox.edit_modified(arg) + + def edit_redo(self): + self._check_if_scrollbars_needed() + return self._textbox.edit_redo() + + def edit_reset(self): + return self._textbox.edit_reset() + + def edit_separator(self): + return self._textbox.edit_separator() + + def edit_undo(self): + self._check_if_scrollbars_needed() + return self._textbox.edit_undo() + + def image_create(self, index, **kwargs): + raise AttributeError("embedding images is forbidden, because would be incompatible with scaling") + + def image_cget(self, index, option): + raise AttributeError("embedding images is forbidden, because would be incompatible with scaling") + + def image_configure(self, index): + raise AttributeError("embedding images is forbidden, because would be incompatible with scaling") + + def image_names(self): + raise AttributeError("embedding images is forbidden, because would be incompatible with scaling") + + def index(self, i): + return self._textbox.index(i) + + def mark_gravity(self, mark, gravity=None): + return self._textbox.mark_gravity(mark, gravity) + + def mark_names(self): + return self._textbox.mark_names() + + def mark_next(self, index): + return self._textbox.mark_next(index) + + def mark_previous(self, index): + return self._textbox.mark_previous(index) + + def mark_set(self, mark, index): + return self._textbox.mark_set(mark, index) + + def mark_unset(self, mark): + return self._textbox.mark_unset(mark) + + def scan_dragto(self, x, y): + return self._textbox.scan_dragto(x, y) + + def scan_mark(self, x, y): + return self._textbox.scan_mark(x, y) + + def search(self, pattern, index, *args, **kwargs): + return self._textbox.search(pattern, index, *args, **kwargs) + + def see(self, index): + return self._textbox.see(index) + + def tag_add(self, tagName, index1, index2=None): + return self._textbox.tag_add(tagName, index1, index2) + + def tag_bind(self, tagName, sequence, func, add=None): + return self._textbox.tag_bind(tagName, sequence, func, add) + + def tag_cget(self, tagName, option): + return self._textbox.tag_cget(tagName, option) + + def tag_config(self, tagName, **kwargs): + if "font" in kwargs: + raise AttributeError("'font' option forbidden, because would be incompatible with scaling") + return self._textbox.tag_config(tagName, **kwargs) + + def tag_delete(self, *tagName): + return self._textbox.tag_delete(*tagName) + + def tag_lower(self, tagName, belowThis=None): + return self._textbox.tag_lower(tagName, belowThis) + + def tag_names(self, index=None): + return self._textbox.tag_names(index) + + def tag_nextrange(self, tagName, index1, index2=None): + return self._textbox.tag_nextrange(tagName, index1, index2) + + def tag_prevrange(self, tagName, index1, index2=None): + return self._textbox.tag_prevrange(tagName, index1, index2) + + def tag_raise(self, tagName, aboveThis=None): + return self._textbox.tag_raise(tagName, aboveThis) + + def tag_ranges(self, tagName): + return self._textbox.tag_ranges(tagName) + + def tag_remove(self, tagName, index1, index2=None): + return self._textbox.tag_remove(tagName, index1, index2) + + def tag_unbind(self, tagName, sequence, funcid=None): + return self._textbox.tag_unbind(tagName, sequence, funcid) + + def window_cget(self, index, option): + raise AttributeError("embedding widgets is forbidden, would probably cause all kinds of problems ;)") + + def window_configure(self, index, option): + raise AttributeError("embedding widgets is forbidden, would probably cause all kinds of problems ;)") + + def window_create(self, index, **kwargs): + raise AttributeError("embedding widgets is forbidden, would probably cause all kinds of problems ;)") + + def window_names(self): + raise AttributeError("embedding widgets is forbidden, would probably cause all kinds of problems ;)") + + def xview(self, *args): + return self._textbox.xview(*args) + + def xview_moveto(self, fraction): + return self._textbox.xview_moveto(fraction) + + def xview_scroll(self, n, what): + return self._textbox.xview_scroll(n, what) + + def yview(self, *args): + return self._textbox.yview(*args) + + def yview_moveto(self, fraction): + return self._textbox.yview_moveto(fraction) + + def yview_scroll(self, n, what): + return self._textbox.yview_scroll(n, what) diff --git a/customtkinter/windows/widgets/font/__init__.py b/customtkinter/windows/widgets/font/__init__.py new file mode 100644 index 0000000..64a49f1 --- /dev/null +++ b/customtkinter/windows/widgets/font/__init__.py @@ -0,0 +1,24 @@ +import os +import sys + +from .ctk_font import CTkFont +from .font_manager import FontManager + +# import DrawEngine to set preferred_drawing_method if loading shapes font fails +from ..core_rendering import DrawEngine + +FontManager.init_font_manager() + +# load Roboto fonts (used on Windows/Linux) +customtkinter_directory = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) +FontManager.load_font(os.path.join(customtkinter_directory, "assets", "fonts", "Roboto", "Roboto-Regular.ttf")) +FontManager.load_font(os.path.join(customtkinter_directory, "assets", "fonts", "Roboto", "Roboto-Medium.ttf")) + +# load font necessary for rendering the widgets (used on Windows/Linux) +if FontManager.load_font(os.path.join(customtkinter_directory, "assets", "fonts", "CustomTkinter_shapes_font.otf")) is False: + # change draw method if font loading failed + if DrawEngine.preferred_drawing_method == "font_shapes": + sys.stderr.write("customtkinter.windows.widgets.font warning: " + + "Preferred drawing method 'font_shapes' can not be used because the font file could not be loaded.\n" + + "Using 'circle_shapes' instead. The rendering quality will be bad!\n") + DrawEngine.preferred_drawing_method = "circle_shapes" diff --git a/customtkinter/windows/widgets/font/__pycache__/__init__.cpython-310.pyc b/customtkinter/windows/widgets/font/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..4d83821 Binary files /dev/null and b/customtkinter/windows/widgets/font/__pycache__/__init__.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/font/__pycache__/ctk_font.cpython-310.pyc b/customtkinter/windows/widgets/font/__pycache__/ctk_font.cpython-310.pyc new file mode 100644 index 0000000..a87499a Binary files /dev/null and b/customtkinter/windows/widgets/font/__pycache__/ctk_font.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/font/__pycache__/font_manager.cpython-310.pyc b/customtkinter/windows/widgets/font/__pycache__/font_manager.cpython-310.pyc new file mode 100644 index 0000000..b84a3af Binary files /dev/null and b/customtkinter/windows/widgets/font/__pycache__/font_manager.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/font/ctk_font.py b/customtkinter/windows/widgets/font/ctk_font.py new file mode 100644 index 0000000..551b3a6 --- /dev/null +++ b/customtkinter/windows/widgets/font/ctk_font.py @@ -0,0 +1,91 @@ +from tkinter.font import Font +import copy +from typing import List, Callable, Tuple, Optional +try: + from typing import Literal +except ImportError: + from typing_extensions import Literal + +from ..theme import ThemeManager + + +class CTkFont(Font): + """ + Font object with size in pixel, independent of scaling. + To get scaled tuple representation use create_scaled_tuple() method. + + family The font family name as a string. + size The font height as an integer in pixel. + weight 'bold' for boldface, 'normal' for regular weight. + slant 'italic' for italic, 'roman' for unslanted. + underline 1 for underlined text, 0 for normal. + overstrike 1 for overstruck text, 0 for normal. + + Tkinter Font: https://anzeljg.github.io/rin2/book2/2405/docs/tkinter/fonts.html + """ + + def __init__(self, + family: Optional[str] = None, + size: Optional[int] = None, + weight: Literal["normal", "bold"] = None, + slant: Literal["italic", "roman"] = "roman", + underline: bool = False, + overstrike: bool = False): + + self._size_configure_callback_list: List[Callable] = [] + + self._size = ThemeManager.theme["CTkFont"]["size"] if size is None else size + + super().__init__(family=ThemeManager.theme["CTkFont"]["family"] if family is None else family, + size=-abs(self._size), + weight=ThemeManager.theme["CTkFont"]["weight"] if weight is None else weight, + slant=slant, + underline=underline, + overstrike=overstrike) + + self._family = super().cget("family") + self._tuple_style_string = f"{super().cget('weight')} {slant} {'underline' if underline else ''} {'overstrike' if overstrike else ''}" + + def add_size_configure_callback(self, callback: Callable): + """ add function, that gets called when font got configured """ + self._size_configure_callback_list.append(callback) + + def remove_size_configure_callback(self, callback: Callable): + """ remove function, that gets called when font got configured """ + self._size_configure_callback_list.remove(callback) + + def create_scaled_tuple(self, font_scaling: float) -> Tuple[str, int, str]: + """ return scaled tuple representation of font in the form (family: str, size: int, style: str)""" + return self._family, round(-abs(self._size) * font_scaling), self._tuple_style_string + + def config(self, *args, **kwargs): + raise AttributeError("'config' is not implemented for CTk widgets. For consistency, always use 'configure' instead.") + + def configure(self, **kwargs): + if "size" in kwargs: + self._size = kwargs.pop("size") + super().configure(size=-abs(self._size)) + + if "family" in kwargs: + super().configure(family=kwargs.pop("family")) + self._family = super().cget("family") + + super().configure(**kwargs) + + # update style string for create_scaled_tuple() method + self._tuple_style_string = f"{super().cget('weight')} {super().cget('slant')} {'underline' if super().cget('underline') else ''} {'overstrike' if super().cget('overstrike') else ''}" + + # call all functions registered with add_size_configure_callback() + for callback in self._size_configure_callback_list: + callback() + + def cget(self, attribute_name: str) -> any: + if attribute_name == "size": + return self._size + if attribute_name == "family": + return self._family + else: + return super().cget(attribute_name) + + def copy(self) -> "CTkFont": + return copy.deepcopy(self) diff --git a/customtkinter/windows/widgets/font/font_manager.py b/customtkinter/windows/widgets/font/font_manager.py new file mode 100644 index 0000000..b3ef369 --- /dev/null +++ b/customtkinter/windows/widgets/font/font_manager.py @@ -0,0 +1,66 @@ +import sys +import os +import shutil +from typing import Union + + +class FontManager: + + linux_font_path = "~/.fonts/" + + @classmethod + def init_font_manager(cls): + # Linux + if sys.platform.startswith("linux"): + try: + if not os.path.isdir(os.path.expanduser(cls.linux_font_path)): + os.mkdir(os.path.expanduser(cls.linux_font_path)) + return True + except Exception as err: + sys.stderr.write("FontManager error: " + str(err) + "\n") + return False + + # other platforms + else: + return True + + @classmethod + def windows_load_font(cls, font_path: Union[str, bytes], private: bool = True, enumerable: bool = False) -> bool: + """ Function taken from: https://stackoverflow.com/questions/11993290/truly-custom-font-in-tkinter/30631309#30631309 """ + + from ctypes import windll, byref, create_unicode_buffer, create_string_buffer + + FR_PRIVATE = 0x10 + FR_NOT_ENUM = 0x20 + + if isinstance(font_path, bytes): + path_buffer = create_string_buffer(font_path) + add_font_resource_ex = windll.gdi32.AddFontResourceExA + elif isinstance(font_path, str): + path_buffer = create_unicode_buffer(font_path) + add_font_resource_ex = windll.gdi32.AddFontResourceExW + else: + raise TypeError('font_path must be of type bytes or str') + + flags = (FR_PRIVATE if private else 0) | (FR_NOT_ENUM if not enumerable else 0) + num_fonts_added = add_font_resource_ex(byref(path_buffer), flags, 0) + return bool(min(num_fonts_added, 1)) + + @classmethod + def load_font(cls, font_path: str) -> bool: + # Windows + if sys.platform.startswith("win"): + return cls.windows_load_font(font_path, private=True, enumerable=False) + + # Linux + elif sys.platform.startswith("linux"): + try: + shutil.copy(font_path, os.path.expanduser(cls.linux_font_path)) + return True + except Exception as err: + sys.stderr.write("FontManager error: " + str(err) + "\n") + return False + + # macOS and others + else: + return False diff --git a/customtkinter/windows/widgets/image/__init__.py b/customtkinter/windows/widgets/image/__init__.py new file mode 100644 index 0000000..b712c89 --- /dev/null +++ b/customtkinter/windows/widgets/image/__init__.py @@ -0,0 +1 @@ +from .ctk_image import CTkImage diff --git a/customtkinter/windows/widgets/image/__pycache__/__init__.cpython-310.pyc b/customtkinter/windows/widgets/image/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..fc6e143 Binary files /dev/null and b/customtkinter/windows/widgets/image/__pycache__/__init__.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/image/__pycache__/ctk_image.cpython-310.pyc b/customtkinter/windows/widgets/image/__pycache__/ctk_image.cpython-310.pyc new file mode 100644 index 0000000..1f1b44b Binary files /dev/null and b/customtkinter/windows/widgets/image/__pycache__/ctk_image.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/image/ctk_image.py b/customtkinter/windows/widgets/image/ctk_image.py new file mode 100644 index 0000000..0247cdd --- /dev/null +++ b/customtkinter/windows/widgets/image/ctk_image.py @@ -0,0 +1,122 @@ +from typing import Tuple, Dict, Callable, List +try: + from PIL import Image, ImageTk +except ImportError: + pass + + +class CTkImage: + """ + Class to store one or two PIl.Image.Image objects and display size independent of scaling: + + light_image: PIL.Image.Image for light mode + dark_image: PIL.Image.Image for dark mode + size: tuple (, ) with display size for both images + + One of the two images can be None and will be replaced by the other image. + """ + + _checked_PIL_import = False + + def __init__(self, + light_image: "Image.Image" = None, + dark_image: "Image.Image" = None, + size: Tuple[int, int] = (20, 20)): + + if not self._checked_PIL_import: + self._check_pil_import() + + self._light_image = light_image + self._dark_image = dark_image + self._check_images() + self._size = size + + self._configure_callback_list: List[Callable] = [] + self._scaled_light_photo_images: Dict[Tuple[int, int], ImageTk.PhotoImage] = {} + self._scaled_dark_photo_images: Dict[Tuple[int, int], ImageTk.PhotoImage] = {} + + @classmethod + def _check_pil_import(cls): + try: + _, _ = Image, ImageTk + except NameError: + raise ImportError("PIL.Image and PIL.ImageTk couldn't be imported") + + def add_configure_callback(self, callback: Callable): + """ add function, that gets called when image got configured """ + self._configure_callback_list.append(callback) + + def remove_configure_callback(self, callback: Callable): + """ remove function, that gets called when image got configured """ + self._configure_callback_list.remove(callback) + + def configure(self, **kwargs): + if "light_image" in kwargs: + self._light_image = kwargs.pop("light_image") + self._scaled_light_photo_images = {} + self._check_images() + if "dark_image" in kwargs: + self._dark_image = kwargs.pop("dark_image") + self._scaled_dark_photo_images = {} + self._check_images() + if "size" in kwargs: + self._size = kwargs.pop("size") + + # call all functions registered with add_configure_callback() + for callback in self._configure_callback_list: + callback() + + def cget(self, attribute_name: str) -> any: + if attribute_name == "light_image": + return self._light_image + if attribute_name == "dark_image": + return self._dark_image + if attribute_name == "size": + return self._size + + def _check_images(self): + # check types + if self._light_image is not None and not isinstance(self._light_image, Image.Image): + raise ValueError(f"CTkImage: light_image must be instance if PIL.Image.Image, not {type(self._light_image)}") + if self._dark_image is not None and not isinstance(self._dark_image, Image.Image): + raise ValueError(f"CTkImage: dark_image must be instance if PIL.Image.Image, not {type(self._dark_image)}") + + # check values + if self._light_image is None and self._dark_image is None: + raise ValueError("CTkImage: No image given, light_image is None and dark_image is None.") + + # check sizes + if self._light_image is not None and self._dark_image is not None and self._light_image.size != self._dark_image.size: + raise ValueError(f"CTkImage: light_image size {self._light_image.size} must be the same as dark_image size {self._dark_image.size}.") + + def _get_scaled_size(self, widget_scaling: float) -> Tuple[int, int]: + return round(self._size[0] * widget_scaling), round(self._size[1] * widget_scaling) + + def _get_scaled_light_photo_image(self, scaled_size: Tuple[int, int]) -> "ImageTk.PhotoImage": + if scaled_size in self._scaled_light_photo_images: + return self._scaled_light_photo_images[scaled_size] + else: + self._scaled_light_photo_images[scaled_size] = ImageTk.PhotoImage(self._light_image.resize(scaled_size)) + return self._scaled_light_photo_images[scaled_size] + + def _get_scaled_dark_photo_image(self, scaled_size: Tuple[int, int]) -> "ImageTk.PhotoImage": + if scaled_size in self._scaled_dark_photo_images: + return self._scaled_dark_photo_images[scaled_size] + else: + self._scaled_dark_photo_images[scaled_size] = ImageTk.PhotoImage(self._dark_image.resize(scaled_size)) + return self._scaled_dark_photo_images[scaled_size] + + def create_scaled_photo_image(self, widget_scaling: float, appearance_mode: str) -> "ImageTk.PhotoImage": + scaled_size = self._get_scaled_size(widget_scaling) + + if appearance_mode == "light" and self._light_image is not None: + return self._get_scaled_light_photo_image(scaled_size) + elif appearance_mode == "light" and self._light_image is None: + return self._get_scaled_dark_photo_image(scaled_size) + + elif appearance_mode == "dark" and self._dark_image is not None: + return self._get_scaled_dark_photo_image(scaled_size) + elif appearance_mode == "dark" and self._dark_image is None: + return self._get_scaled_light_photo_image(scaled_size) + + diff --git a/customtkinter/windows/widgets/scaling/__init__.py b/customtkinter/windows/widgets/scaling/__init__.py new file mode 100644 index 0000000..8fc0db8 --- /dev/null +++ b/customtkinter/windows/widgets/scaling/__init__.py @@ -0,0 +1,7 @@ +import sys + +from .scaling_base_class import CTkScalingBaseClass +from .scaling_tracker import ScalingTracker + +if sys.platform.startswith("win") and sys.getwindowsversion().build < 9000: # No automatic scaling on Windows < 8.1 + ScalingTracker.deactivate_automatic_dpi_awareness = True diff --git a/customtkinter/windows/widgets/scaling/__pycache__/__init__.cpython-310.pyc b/customtkinter/windows/widgets/scaling/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..05da204 Binary files /dev/null and b/customtkinter/windows/widgets/scaling/__pycache__/__init__.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/scaling/__pycache__/scaling_base_class.cpython-310.pyc b/customtkinter/windows/widgets/scaling/__pycache__/scaling_base_class.cpython-310.pyc new file mode 100644 index 0000000..b113161 Binary files /dev/null and b/customtkinter/windows/widgets/scaling/__pycache__/scaling_base_class.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/scaling/__pycache__/scaling_tracker.cpython-310.pyc b/customtkinter/windows/widgets/scaling/__pycache__/scaling_tracker.cpython-310.pyc new file mode 100644 index 0000000..d2887fa Binary files /dev/null and b/customtkinter/windows/widgets/scaling/__pycache__/scaling_tracker.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/scaling/scaling_base_class.py b/customtkinter/windows/widgets/scaling/scaling_base_class.py new file mode 100644 index 0000000..0d7b29b --- /dev/null +++ b/customtkinter/windows/widgets/scaling/scaling_base_class.py @@ -0,0 +1,159 @@ +from typing import Union, Tuple +import copy +import re +try: + from typing import Literal +except ImportError: + from typing_extensions import Literal + +from .scaling_tracker import ScalingTracker +from ..font import CTkFont + + +class CTkScalingBaseClass: + """ + Super-class that manages the scaling values and callbacks. + Works for widgets and windows, type must be set in init method with + scaling_type attribute. Methods: + + - _set_scaling() abstractmethod, gets called when scaling changes, must be overridden + - destroy() must be called when sub-class is destroyed + - _apply_widget_scaling() + - _reverse_widget_scaling() + - _apply_window_scaling() + - _reverse_window_scaling() + - _apply_font_scaling() + - _apply_argument_scaling() + - _apply_geometry_scaling() + - _reverse_geometry_scaling() + - _parse_geometry_string() + + """ + def __init__(self, scaling_type: Literal["widget", "window"] = "widget"): + self.__scaling_type = scaling_type + + if self.__scaling_type == "widget": + ScalingTracker.add_widget(self._set_scaling, self) # add callback for automatic scaling changes + self.__widget_scaling = ScalingTracker.get_widget_scaling(self) + elif self.__scaling_type == "window": + ScalingTracker.activate_high_dpi_awareness() # make process DPI aware + ScalingTracker.add_window(self._set_scaling, self) # add callback for automatic scaling changes + self.__window_scaling = ScalingTracker.get_window_scaling(self) + + def destroy(self): + if self.__scaling_type == "widget": + ScalingTracker.remove_widget(self._set_scaling, self) + elif self.__scaling_type == "window": + ScalingTracker.remove_window(self._set_scaling, self) + + def _set_scaling(self, new_widget_scaling, new_window_scaling): + """ can be overridden, but super method must be called at the beginning """ + self.__widget_scaling = new_widget_scaling + self.__window_scaling = new_window_scaling + + def _get_widget_scaling(self) -> float: + return self.__widget_scaling + + def _get_window_scaling(self) -> float: + return self.__window_scaling + + def _apply_widget_scaling(self, value: Union[int, float]) -> Union[float]: + assert self.__scaling_type == "widget" + return value * self.__widget_scaling + + def _reverse_widget_scaling(self, value: Union[int, float]) -> Union[float]: + assert self.__scaling_type == "widget" + return value / self.__widget_scaling + + def _apply_window_scaling(self, value: Union[int, float]) -> int: + assert self.__scaling_type == "window" + return int(value * self.__window_scaling) + + def _reverse_window_scaling(self, scaled_value: Union[int, float]) -> int: + assert self.__scaling_type == "window" + return int(scaled_value / self.__window_scaling) + + def _apply_font_scaling(self, font: Union[Tuple, CTkFont]) -> tuple: + """ Takes CTkFont object and returns tuple font with scaled size, has to be called again for every change of font object """ + assert self.__scaling_type == "widget" + + if type(font) == tuple: + if len(font) == 1: + return font + elif len(font) == 2: + return font[0], -abs(round(font[1] * self.__widget_scaling)) + elif 3 <= len(font) <= 6: + return font[0], -abs(round(font[1] * self.__widget_scaling)), font[2:] + else: + raise ValueError(f"Can not scale font {font}. font needs to be tuple of len 1, 2 or 3") + + elif isinstance(font, CTkFont): + return font.create_scaled_tuple(self.__widget_scaling) + else: + raise ValueError(f"Can not scale font '{font}' of type {type(font)}. font needs to be tuple or instance of CTkFont") + + def _apply_argument_scaling(self, kwargs: dict) -> dict: + assert self.__scaling_type == "widget" + + scaled_kwargs = copy.copy(kwargs) + + # scale padding values + if "pady" in scaled_kwargs: + if isinstance(scaled_kwargs["pady"], (int, float)): + scaled_kwargs["pady"] = self._apply_widget_scaling(scaled_kwargs["pady"]) + elif isinstance(scaled_kwargs["pady"], tuple): + scaled_kwargs["pady"] = tuple([self._apply_widget_scaling(v) for v in scaled_kwargs["pady"]]) + if "padx" in kwargs: + if isinstance(scaled_kwargs["padx"], (int, float)): + scaled_kwargs["padx"] = self._apply_widget_scaling(scaled_kwargs["padx"]) + elif isinstance(scaled_kwargs["padx"], tuple): + scaled_kwargs["padx"] = tuple([self._apply_widget_scaling(v) for v in scaled_kwargs["padx"]]) + + # scaled x, y values for place geometry manager + if "x" in scaled_kwargs: + scaled_kwargs["x"] = self._apply_widget_scaling(scaled_kwargs["x"]) + if "y" in scaled_kwargs: + scaled_kwargs["y"] = self._apply_widget_scaling(scaled_kwargs["y"]) + + return scaled_kwargs + + @staticmethod + def _parse_geometry_string(geometry_string: str) -> tuple: + # index: 1 2 3 4 5 6 + # regex group structure: ('x', '', '', '+-+-', '-', '-') + result = re.search(r"((\d+)x(\d+)){0,1}(\+{0,1}([+-]{0,1}\d+)\+{0,1}([+-]{0,1}\d+)){0,1}", geometry_string) + + width = int(result.group(2)) if result.group(2) is not None else None + height = int(result.group(3)) if result.group(3) is not None else None + x = int(result.group(5)) if result.group(5) is not None else None + y = int(result.group(6)) if result.group(6) is not None else None + + return width, height, x, y + + def _apply_geometry_scaling(self, geometry_string: str) -> str: + assert self.__scaling_type == "window" + + width, height, x, y = self._parse_geometry_string(geometry_string) + + if x is None and y is None: # no and in geometry_string + return f"{round(width * self.__window_scaling)}x{round(height * self.__window_scaling)}" + + elif width is None and height is None: # no and in geometry_string + return f"+{x}+{y}" + + else: + return f"{round(width * self.__window_scaling)}x{round(height * self.__window_scaling)}+{x}+{y}" + + def _reverse_geometry_scaling(self, scaled_geometry_string: str) -> str: + assert self.__scaling_type == "window" + + width, height, x, y = self._parse_geometry_string(scaled_geometry_string) + + if x is None and y is None: # no and in geometry_string + return f"{round(width / self.__window_scaling)}x{round(height / self.__window_scaling)}" + + elif width is None and height is None: # no and in geometry_string + return f"+{x}+{y}" + + else: + return f"{round(width / self.__window_scaling)}x{round(height / self.__window_scaling)}+{x}+{y}" diff --git a/customtkinter/windows/widgets/scaling/scaling_tracker.py b/customtkinter/windows/widgets/scaling/scaling_tracker.py new file mode 100644 index 0000000..d3627c2 --- /dev/null +++ b/customtkinter/windows/widgets/scaling/scaling_tracker.py @@ -0,0 +1,206 @@ +import tkinter +import sys +from typing import Callable + + +class ScalingTracker: + deactivate_automatic_dpi_awareness = False + + window_widgets_dict = {} # contains window objects as keys with list of widget callbacks as elements + window_dpi_scaling_dict = {} # contains window objects as keys and corresponding scaling factors + + widget_scaling = 1 # user values which multiply to detected window scaling factor + window_scaling = 1 + + update_loop_running = False + update_loop_interval = 100 # ms + loop_pause_after_new_scaling = 1500 # ms + + @classmethod + def get_widget_scaling(cls, widget) -> float: + window_root = cls.get_window_root_of_widget(widget) + return cls.window_dpi_scaling_dict[window_root] * cls.widget_scaling + + @classmethod + def get_window_scaling(cls, window) -> float: + window_root = cls.get_window_root_of_widget(window) + return cls.window_dpi_scaling_dict[window_root] * cls.window_scaling + + @classmethod + def set_widget_scaling(cls, widget_scaling_factor: float): + cls.widget_scaling = max(widget_scaling_factor, 0.4) + cls.update_scaling_callbacks_all() + + @classmethod + def set_window_scaling(cls, window_scaling_factor: float): + cls.window_scaling = max(window_scaling_factor, 0.4) + cls.update_scaling_callbacks_all() + + @classmethod + def get_window_root_of_widget(cls, widget): + current_widget = widget + + while isinstance(current_widget, tkinter.Tk) is False and\ + isinstance(current_widget, tkinter.Toplevel) is False: + current_widget = current_widget.master + + return current_widget + + @classmethod + def update_scaling_callbacks_all(cls): + for window, callback_list in cls.window_widgets_dict.items(): + for set_scaling_callback in callback_list: + if not cls.deactivate_automatic_dpi_awareness: + set_scaling_callback(cls.window_dpi_scaling_dict[window] * cls.widget_scaling, + cls.window_dpi_scaling_dict[window] * cls.window_scaling) + else: + set_scaling_callback(cls.widget_scaling, + cls.window_scaling) + + @classmethod + def update_scaling_callbacks_for_window(cls, window): + for set_scaling_callback in cls.window_widgets_dict[window]: + if not cls.deactivate_automatic_dpi_awareness: + set_scaling_callback(cls.window_dpi_scaling_dict[window] * cls.widget_scaling, + cls.window_dpi_scaling_dict[window] * cls.window_scaling) + else: + set_scaling_callback(cls.widget_scaling, + cls.window_scaling) + + @classmethod + def add_widget(cls, widget_callback: Callable, widget): + window_root = cls.get_window_root_of_widget(widget) + + if window_root not in cls.window_widgets_dict: + cls.window_widgets_dict[window_root] = [widget_callback] + else: + cls.window_widgets_dict[window_root].append(widget_callback) + + if window_root not in cls.window_dpi_scaling_dict: + cls.window_dpi_scaling_dict[window_root] = cls.get_window_dpi_scaling(window_root) + + if not cls.update_loop_running: + window_root.after(100, cls.check_dpi_scaling) + cls.update_loop_running = True + + @classmethod + def remove_widget(cls, widget_callback, widget): + window_root = cls.get_window_root_of_widget(widget) + try: + cls.window_widgets_dict[window_root].remove(widget_callback) + except: + pass + + @classmethod + def remove_window(cls, window_callback, window): + try: + del cls.window_widgets_dict[window] + except: + pass + + @classmethod + def add_window(cls, window_callback, window): + if window not in cls.window_widgets_dict: + cls.window_widgets_dict[window] = [window_callback] + else: + cls.window_widgets_dict[window].append(window_callback) + + if window not in cls.window_dpi_scaling_dict: + cls.window_dpi_scaling_dict[window] = cls.get_window_dpi_scaling(window) + + @classmethod + def activate_high_dpi_awareness(cls): + """ make process DPI aware, customtkinter elements will get scaled automatically, + only gets activated when CTk object is created """ + + if not cls.deactivate_automatic_dpi_awareness: + if sys.platform == "darwin": + pass # high DPI scaling works automatically on macOS + + elif sys.platform.startswith("win"): + import ctypes + + # Values for SetProcessDpiAwareness and SetProcessDpiAwarenessContext: + # internal enum PROCESS_DPI_AWARENESS + # { + # Process_DPI_Unaware = 0, + # Process_System_DPI_Aware = 1, + # Process_Per_Monitor_DPI_Aware = 2 + # } + # + # internal enum DPI_AWARENESS_CONTEXT + # { + # DPI_AWARENESS_CONTEXT_UNAWARE = 16, + # DPI_AWARENESS_CONTEXT_SYSTEM_AWARE = 17, + # DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE = 18, + # DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = 34 + # } + + # ctypes.windll.user32.SetProcessDpiAwarenessContext(34) # Non client area scaling at runtime (titlebar) + # does not work with resizable(False, False), window starts growing on monitor with different scaling (weird tkinter bug...) + # ctypes.windll.user32.EnableNonClientDpiScaling(hwnd) does not work for some reason (tested on Windows 11) + + # It's too bad, that these Windows API methods don't work properly with tkinter. But I tested days with multiple monitor setups, + # and I don't think there is anything left to do. So this is the best option at the moment: + + ctypes.windll.shcore.SetProcessDpiAwareness(2) # Titlebar does not scale at runtime + else: + pass # DPI awareness on Linux not implemented + + @classmethod + def get_window_dpi_scaling(cls, window) -> float: + if not cls.deactivate_automatic_dpi_awareness: + if sys.platform == "darwin": + return 1 # scaling works automatically on macOS + + elif sys.platform.startswith("win"): + from ctypes import windll, pointer, wintypes + + DPI100pc = 96 # DPI 96 is 100% scaling + DPI_type = 0 # MDT_EFFECTIVE_DPI = 0, MDT_ANGULAR_DPI = 1, MDT_RAW_DPI = 2 + window_hwnd = wintypes.HWND(window.winfo_id()) + monitor_handle = windll.user32.MonitorFromWindow(window_hwnd, wintypes.DWORD(2)) # MONITOR_DEFAULTTONEAREST = 2 + x_dpi, y_dpi = wintypes.UINT(), wintypes.UINT() + windll.shcore.GetDpiForMonitor(monitor_handle, DPI_type, pointer(x_dpi), pointer(y_dpi)) + return (x_dpi.value + y_dpi.value) / (2 * DPI100pc) + + else: + return 1 # DPI awareness on Linux not implemented + else: + return 1 + + @classmethod + def check_dpi_scaling(cls): + new_scaling_detected = False + + # check for every window if scaling value changed + for window in cls.window_widgets_dict: + if window.winfo_exists() and not window.state() == "iconic": + current_dpi_scaling_value = cls.get_window_dpi_scaling(window) + if current_dpi_scaling_value != cls.window_dpi_scaling_dict[window]: + cls.window_dpi_scaling_dict[window] = current_dpi_scaling_value + + if sys.platform.startswith("win"): + window.attributes("-alpha", 0.15) + + window.block_update_dimensions_event() + cls.update_scaling_callbacks_for_window(window) + window.unblock_update_dimensions_event() + + if sys.platform.startswith("win"): + window.attributes("-alpha", 1) + + new_scaling_detected = True + + # find an existing tkinter object for the next call of .after() + for app in cls.window_widgets_dict.keys(): + try: + if new_scaling_detected: + app.after(cls.loop_pause_after_new_scaling, cls.check_dpi_scaling) + else: + app.after(cls.update_loop_interval, cls.check_dpi_scaling) + return + except Exception: + continue + + cls.update_loop_running = False diff --git a/customtkinter/windows/widgets/theme/__init__.py b/customtkinter/windows/widgets/theme/__init__.py new file mode 100644 index 0000000..bd7395a --- /dev/null +++ b/customtkinter/windows/widgets/theme/__init__.py @@ -0,0 +1,9 @@ +from .theme_manager import ThemeManager + +# load default blue theme +try: + ThemeManager.load_theme("blue") +except FileNotFoundError as err: + raise FileNotFoundError(f"{err}\n\nThe .json theme file for CustomTkinter could not be found.\n" + + f"If packaging with pyinstaller was used, have a look at the wiki:\n" + + f"https://github.com/TomSchimansky/CustomTkinter/wiki/Packaging#windows-pyinstaller-auto-py-to-exe") diff --git a/customtkinter/windows/widgets/theme/__pycache__/__init__.cpython-310.pyc b/customtkinter/windows/widgets/theme/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..171f336 Binary files /dev/null and b/customtkinter/windows/widgets/theme/__pycache__/__init__.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/theme/__pycache__/theme_manager.cpython-310.pyc b/customtkinter/windows/widgets/theme/__pycache__/theme_manager.cpython-310.pyc new file mode 100644 index 0000000..e707128 Binary files /dev/null and b/customtkinter/windows/widgets/theme/__pycache__/theme_manager.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/theme/theme_manager.py b/customtkinter/windows/widgets/theme/theme_manager.py new file mode 100644 index 0000000..e04b679 --- /dev/null +++ b/customtkinter/windows/widgets/theme/theme_manager.py @@ -0,0 +1,47 @@ +import sys +import os +import json +from typing import List, Union + + +class ThemeManager: + + theme: dict = {} # contains all the theme data + _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): + script_directory = os.path.dirname(os.path.abspath(__file__)) + + if theme_name_or_path in cls._built_in_themes: + with open(os.path.join(script_directory, "../../../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) + + # store theme path for saving + 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"] + + @classmethod + def save_theme(cls): + if cls._currently_loaded_theme is not None: + if cls._currently_loaded_theme not in cls._built_in_themes: + with open(cls._currently_loaded_theme, "r") as f: + json.dump(cls.theme, f, indent=2) + else: + raise ValueError(f"cannot modify builtin theme '{cls._currently_loaded_theme}'") + else: + raise ValueError(f"cannot save theme, no theme is loaded") diff --git a/customtkinter/windows/widgets/utility/__init__.py b/customtkinter/windows/widgets/utility/__init__.py new file mode 100644 index 0000000..c4b6fe8 --- /dev/null +++ b/customtkinter/windows/widgets/utility/__init__.py @@ -0,0 +1 @@ +from .utility_functions import pop_from_dict_by_set, check_kwargs_empty diff --git a/customtkinter/windows/widgets/utility/__pycache__/__init__.cpython-310.pyc b/customtkinter/windows/widgets/utility/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..42608ea Binary files /dev/null and b/customtkinter/windows/widgets/utility/__pycache__/__init__.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/utility/__pycache__/utility_functions.cpython-310.pyc b/customtkinter/windows/widgets/utility/__pycache__/utility_functions.cpython-310.pyc new file mode 100644 index 0000000..dc466e2 Binary files /dev/null and b/customtkinter/windows/widgets/utility/__pycache__/utility_functions.cpython-310.pyc differ diff --git a/customtkinter/windows/widgets/utility/utility_functions.py b/customtkinter/windows/widgets/utility/utility_functions.py new file mode 100644 index 0000000..a9968bb --- /dev/null +++ b/customtkinter/windows/widgets/utility/utility_functions.py @@ -0,0 +1,22 @@ + +def pop_from_dict_by_set(dictionary: dict, valid_keys: set) -> dict: + """ remove and create new dict with key value pairs of dictionary, where key is in valid_keys """ + new_dictionary = {} + + for key in list(dictionary.keys()): + if key in valid_keys: + new_dictionary[key] = dictionary.pop(key) + + return new_dictionary + + +def check_kwargs_empty(kwargs_dict, raise_error=False) -> bool: + """ returns True if kwargs are empty, False otherwise, raises error if not empty """ + + if len(kwargs_dict) > 0: + if raise_error: + raise ValueError(f"{list(kwargs_dict.keys())} are not supported arguments. Look at the documentation for supported arguments.") + else: + return True + else: + return False diff --git a/main.py b/main.py index 6d98769..2030fe3 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,5 @@ from data_structure import * +import customtkinter as ctk class Expression: """manipule les expression sous forme d'arbre""" @@ -19,46 +20,152 @@ class Expression: return True return False - def evalue(self) -> float: + def evalue(self, x=0) -> float: """renvoie le résultat de l'expression""" if self.est_feuille(): + if self.val == 'x': + return x return float(self.val) if self.val == '+': - return self.fils_gauche.evalue() + self.fils_droit.evalue() + return self.fils_gauche.evalue(x) + self.fils_droit.evalue(x) if self.val == '*': - return self.fils_gauche.evalue() * self.fils_droit.evalue() + return self.fils_gauche.evalue(x) * self.fils_droit.evalue(x) if self.val == '/': - return self.fils_gauche.evalue() / self.fils_droit.evalue() + return self.fils_gauche.evalue(x) / self.fils_droit.evalue(x) if self.val == '^': - return self.fils_gauche.evalue() ** self.fils_droit.evalue() + return self.fils_gauche.evalue(x) ** self.fils_droit.evalue(x) if self.val == '-': - return self.fils_gauche.evalue() - self.fils_droit.evalue() + return self.fils_gauche.evalue(x) - self.fils_droit.evalue(x) + + def valeurs_de_fonction(self): + """calcul les 100 premieres valeurs""" + result = [] + for i in range(-100, 100): + result.append((i, self.evalue(i))) + return result + + + +class App(ctk.CTk): + def __init__(self) -> None: + super().__init__() + self.grid_columnconfigure(0, weight=0) + + # Calcul Frame + + self.calcul_frame = ctk.CTkFrame(self,corner_radius=0, fg_color="transparent") + + self.screen = ctk.CTkTextbox(self.calcul_frame) + self.screen.pack(padx=20, pady=20, fill="both", expand=True) + + self.numbers = [] + + for i, val in enumerate(["(", ")", "/", "^", 7,8,9,"*", 4, 5, 6, "-", 1, 2, 3, "+", "clear", 0, '.', "exe"]): # add numbers button + self.numbers.append(ctk.CTkButton(self, text=val)) + if val == "clear": + self.numbers[i]._command = self.textbox_clear + elif val == "exe": + self.numbers[i]._command = self.calculate + else: + self.numbers[i]._command = lambda x=val: self.add_value(x) + self.numbers[i].grid(row=1+i//4, column=i%4+1, sticky="NSEW", padx=5, pady=5) + + # Fonction Frame + + self.fonction_frame = ctk.CTkFrame(self, corner_radius=0, fg_color="transparent") + + self.fonction_screen = ctk.CTkCanvas(self.fonction_frame) + self.fonction_screen.pack(padx=20, pady=20, fill="both", expand=True) + + + # navigation menu + self.navigation_frame = ctk.CTkFrame(self, corner_radius=0) + self.navigation_frame.grid_rowconfigure(4, weight=1) + self.navigation_frame.grid(row=0, rowspan=10, column=0, sticky="nsew") + + self.titre = ctk.CTkLabel(self.navigation_frame, text="Wx Calculator", + compound="left", font=ctk.CTkFont(size=15, weight="bold")) + self.titre.grid(row=0, column=0, padx=20, pady=20) + + self.home_button = ctk.CTkButton(self.navigation_frame, corner_radius=0, height=40, border_spacing=10, text="Calcul", + fg_color="transparent", text_color=("gray10", "gray90"), hover_color=("gray70", "gray30"), + anchor="w", command=lambda: self.select_frame_by_name("Calcul")) + self.home_button.grid(row=1, column=0, sticky="ew") + + self.function_button = ctk.CTkButton(self.navigation_frame, corner_radius=0, height=40, border_spacing=10, text="Fonction", + fg_color="transparent", text_color=("gray10", "gray90"), hover_color=("gray70", "gray30"), + anchor="w", command=lambda: self.select_frame_by_name("Fonction")) + self.function_button.grid(row=2, column=0, sticky="ew") + + self.appearance_mode_menu = ctk.CTkOptionMenu(self.navigation_frame, values=["Light", "Dark", "System"], + command=self.change_appearance_mode_event) + self.appearance_mode_menu.grid(row=4, column=0, padx=20, pady=20, sticky="s") + + # select default frame + self.select_frame_by_name("Calcul") + + def select_frame_by_name(self, name): + # set button color for selected button + self.home_button.configure(fg_color=("gray75", "gray25") if name == "Calcul" else "transparent") + self.function_button.configure(fg_color=("gray75", "gray25") if name == "Fonction" else "transparent") + + # show selected frame + if name == "Calcul": + self.calcul_frame.grid(columnspan=4, column=1, row=0, sticky="nsew") + else: + self.calcul_frame.grid_forget() + if name == "Fonction": + self.fonction_frame.grid(columnspan=4, column=1, row=0, sticky="nsew") + else: + self.fonction_frame.grid_forget() + + def change_appearance_mode_event(self, new_appearance_mode): + ctk.set_appearance_mode(new_appearance_mode) + + def add_value(self, value) -> None: + self.screen.insert('end', value) + + def textbox_clear(self): + self.screen.delete("0.0", "end") + + def draw_framing(self): + pass + + def calculate(self) -> None: + exp = list(self.screen.get("0.0", "end").strip()) + exp = inf2npi(exp) + result = npi2tree(exp).evalue() + self.textbox_clear() + self.screen.insert("end", result) + def npi2tree(expr: list) -> Expression: """renvoie l'arbre formé a partir de l'expression donnée""" pile = Pile_chaine() for val in expr: - if not val.isdigit(): + if not val.isdigit() and val != "x": # on inverse pour avoir les nombres dans le bon ordre nombre2, nombre1 = pile.depiler(), pile.depiler() pile.empiler(Expression(val, nombre1, nombre2)) else: - pile.empiler(Expression(int(val), None, None)) + pile.empiler(Expression(val, None, None)) return pile.sommet() def inf2npi(expr: list) -> list: + """Transforme une expression infixé en notation polonaise inversée""" operator_stack = Pile_chaine() operator_priority = { '+': 1, '-': 1, '*': 2, + '^': 2, '/': 2, '(': 0, ')': 0 } output = [] for val in expr: - if val.isdigit(): + if val.isdigit() or val == 'x': output.append(val) else: if operator_stack.est_vide() or ( val == '(' or operator_priority[val] > operator_priority[operator_stack.sommet()]): @@ -82,7 +189,10 @@ def inf2npi(expr: list) -> list: return output # [3, '-', 6, '*', 4, '+', 3] -exp = inf2npi(list('6*(4+3)')) -print(exp) +exp = inf2npi(list('x^2')) +print(npi2tree(exp).evalue(2)) + +print(npi2tree(exp).valeurs_de_fonction()) -print(npi2tree(exp)) \ No newline at end of file +gui = App() +gui.mainloop() \ No newline at end of file