changeed driectory structure, moved scaling and appearance mode functionality to super classes

This commit is contained in:
Tom Schimansky
2022-10-29 13:11:55 +02:00
parent e5484cb6cd
commit c2a5b4881e
42 changed files with 280 additions and 294 deletions

View File

@ -1,11 +1,11 @@
from typing import Union, Tuple
from ..widgets.ctk_label import CTkLabel
from ..widgets.ctk_entry import CTkEntry
from ..windows.ctk_toplevel import CTkToplevel
from ..widgets.ctk_button import CTkButton
from ..appearance_mode_tracker import AppearanceModeTracker
from ..theme_manager import ThemeManager
from .widgets.ctk_label import CTkLabel
from .widgets.ctk_entry import CTkEntry
from .ctk_toplevel import CTkToplevel
from .widgets.ctk_button import CTkButton
from .widgets.appearance_mode.appearance_mode_tracker import AppearanceModeTracker
from .widgets.theme.theme_manager import ThemeManager
class CTkInputDialog:

View File

@ -4,17 +4,16 @@ import sys
import os
import platform
import ctypes
import re
from typing import Union, Tuple, List
from typing import Union, Tuple
from ..appearance_mode_tracker import AppearanceModeTracker
from ..theme_manager import ThemeManager
from ..scaling_tracker import ScalingTracker
from .widgets.theme.theme_manager import ThemeManager
from .widgets.scaling.scaling_base_class import CTkScalingBaseClass
from .widgets.appearance_mode.appearance_mode_base_class import CTkAppearanceModeBaseClass
from ..utility.utility_functions import pop_from_dict_by_set, check_kwargs_empty
class CTk(tkinter.Tk):
class CTk(tkinter.Tk, CTkAppearanceModeBaseClass, CTkScalingBaseClass):
"""
Main app window with dark titlebar on Windows and macOS.
For detailed information check out the documentation.
@ -33,21 +32,15 @@ class CTk(tkinter.Tk):
fg_color: Union[str, Tuple[str, str]] = "default_theme",
**kwargs):
ScalingTracker.activate_high_dpi_awareness() # make process DPI aware
self._enable_macos_dark_title_bar()
super().__init__(**pop_from_dict_by_set(kwargs, self._valid_tk_constructor_arguments))
# 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)
# add set_appearance_mode method to callback list of AppearanceModeTracker for appearance mode changes
AppearanceModeTracker.add(self._set_appearance_mode, self)
self._appearance_mode = AppearanceModeTracker.get_mode() # 0: "Light" 1: "Dark"
# add set_scaling method to callback list of ScalingTracker for automatic scaling changes
ScalingTracker.add_widget(self._set_scaling, self)
self._window_scaling = ScalingTracker.get_window_scaling(self)
self._current_width = 600 # initial window size, always without scaling
self._current_width = 600 # initial window size, independent of scaling
self._current_height = 500
self._min_width: int = 0
self._min_height: int = 0
@ -57,8 +50,11 @@ class CTk(tkinter.Tk):
self._fg_color = ThemeManager.theme["color"]["window_bg_color"] if fg_color == "default_theme" else fg_color
# set bg of tkinter.Tk
super().configure(bg=self._apply_appearance_mode(self._fg_color))
super().title("CTk")
# set title and initial geometry
self.title("CTk")
self.geometry(f"{self._current_width}x{self._current_height}")
self._state_before_windows_set_titlebar_color = None
@ -77,6 +73,14 @@ class CTk(tkinter.Tk):
self._block_update_dimensions_event = False
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":
@ -123,12 +127,6 @@ class CTk(tkinter.Tk):
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 destroy(self):
AppearanceModeTracker.remove(self._set_appearance_mode)
ScalingTracker.remove_window(self._set_scaling, self)
self._disable_macos_dark_title_bar()
super().destroy()
def withdraw(self):
if self._window_exists is False:
self._withdraw_called_before_window_exists = True
@ -201,59 +199,6 @@ class CTk(tkinter.Tk):
else:
return self._reverse_geometry_scaling(super().geometry())
@staticmethod
def _parse_geometry_string(geometry_string: str) -> tuple:
# index: 1 2 3 4 5 6
# regex group structure: ('<width>x<height>', '<width>', '<height>', '+-<x>+-<y>', '-<x>', '-<y>')
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:
width, height, x, y = self._parse_geometry_string(geometry_string)
if x is None and y is None: # no <x> and <y> 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 <width> and <height> 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:
width, height, x, y = self._parse_geometry_string(scaled_geometry_string)
if x is None and y is None: # no <x> and <y> 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 <width> and <height> in geometry_string
return f"+{x}+{y}"
else:
return f"{round(width / self._window_scaling)}x{round(height / self._window_scaling)}+{x}+{y}"
def _apply_window_scaling(self, value):
if isinstance(value, (int, float)):
return int(value * self._window_scaling)
else:
return value
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 type(color) == tuple or type(color) == list:
return color[self._appearance_mode]
else:
return color
def configure(self, **kwargs):
if "fg_color" in kwargs:
self._fg_color = kwargs.pop("fg_color")

View File

@ -4,17 +4,16 @@ import sys
import os
import platform
import ctypes
import re
from typing import Union, Tuple, List
from typing import Union, Tuple
from ..appearance_mode_tracker import AppearanceModeTracker
from ..theme_manager import ThemeManager
from ..scaling_tracker import ScalingTracker
from .widgets.theme.theme_manager import ThemeManager
from .widgets.scaling.scaling_base_class import CTkScalingBaseClass
from .widgets.appearance_mode.appearance_mode_base_class import CTkAppearanceModeBaseClass
from ..utility.utility_functions import pop_from_dict_by_set, check_kwargs_empty
class CTkToplevel(tkinter.Toplevel):
class CTkToplevel(tkinter.Toplevel, CTkAppearanceModeBaseClass, CTkScalingBaseClass):
"""
Toplevel window with dark titlebar on Windows and macOS.
For detailed information check out the documentation.
@ -33,17 +32,12 @@ class CTkToplevel(tkinter.Toplevel):
self._enable_macos_dark_title_bar()
# call init methods of super classees
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)
# add set_appearance_mode method to callback list of AppearanceModeTracker for appearance mode changes
AppearanceModeTracker.add(self._set_appearance_mode, self)
self._appearance_mode = AppearanceModeTracker.get_mode() # 0: "Light" 1: "Dark"
# add set_scaling method to callback list of ScalingTracker for automatic scaling changes
ScalingTracker.add_widget(self._set_scaling, self)
self._window_scaling = ScalingTracker.get_window_scaling(self)
self._current_width = 200 # initial window size, always without scaling
self._current_height = 200
self._min_width: int = 0
@ -54,7 +48,10 @@ class CTkToplevel(tkinter.Toplevel):
self._fg_color = ThemeManager.theme["color"]["window_bg_color"] if fg_color == "default_theme" else 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")
self._state_before_windows_set_titlebar_color = None
@ -71,6 +68,14 @@ class CTkToplevel(tkinter.Toplevel):
self.bind('<Configure>', self._update_dimensions_event)
self.bind('<FocusIn>', 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":
@ -114,65 +119,6 @@ class CTkToplevel(tkinter.Toplevel):
else:
return self._reverse_geometry_scaling(super().geometry())
@staticmethod
def _parse_geometry_string(geometry_string: str) -> tuple:
# index: 1 2 3 4 5 6
# regex group structure: ('<width>x<height>', '<width>', '<height>', '+-<x>+-<y>', '-<x>', '-<y>')
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:
width, height, x, y = self._parse_geometry_string(geometry_string)
if x is None and y is None: # no <x> and <y> 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 <width> and <height> 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:
width, height, x, y = self._parse_geometry_string(scaled_geometry_string)
if x is None and y is None: # no <x> and <y> 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 <width> and <height> in geometry_string
return f"+{x}+{y}"
else:
return f"{round(width / self._window_scaling)}x{round(height / self._window_scaling)}+{x}+{y}"
def _apply_window_scaling(self, value):
if isinstance(value, (int, float)):
return int(value * self._window_scaling)
else:
return value
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 type(color) == tuple or type(color) == list:
return color[self._appearance_mode]
else:
return color
def destroy(self):
AppearanceModeTracker.remove(self._set_appearance_mode)
ScalingTracker.remove_window(self._set_scaling, self)
self._disable_macos_dark_title_bar()
super().destroy()
def withdraw(self):
if self._windows_set_titlebar_color_called:
self._withdraw_called_after_windows_set_titlebar_color = True

View File

@ -0,0 +1,3 @@
from customtkinter.windows.widgets.core_rendering.ctk_canvas import CTkCanvas
CTkCanvas.init_font_character_mapping()

View File

@ -0,0 +1,27 @@
from typing import Union, Tuple, List
from abc import ABC, abstractmethod
from .appearance_mode_tracker import AppearanceModeTracker
class CTkAppearanceModeBaseClass(ABC):
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 _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 type(color) == tuple or type(color) == list:
return color[self._appearance_mode]
else:
return color
@abstractmethod
def _set_appearance_mode(self, mode_string: str):
return

View File

@ -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 = 500 # 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"

View File

@ -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)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,233 @@
import tkinter
import sys
from typing import Union, Tuple, Callable, List
from ..theme.theme_manager import ThemeManager
from ..scaling.scaling_tracker import ScalingTracker
from ..font.ctk_font import CTkFont
from ..appearance_mode.appearance_mode_tracker import AppearanceModeTracker
from ..appearance_mode.appearance_mode_base_class import CTkAppearanceModeBaseClass
class DropdownMenu(tkinter.Menu, CTkAppearanceModeBaseClass):
def __init__(self, *args,
min_character_width: int = 18,
fg_color: Union[str, Tuple[str, str]] = "default_theme",
hover_color: Union[str, Tuple[str, str]] = "default_theme",
text_color: Union[str, Tuple[str, str]] = "default_theme",
font: Union[tuple, CTkFont] = "default_theme",
command: Callable = None,
values: List[str] = None,
**kwargs):
# call init methods of super classes
tkinter.Menu.__init__(self, *args, **kwargs)
CTkAppearanceModeBaseClass.__init__(self)
ScalingTracker.add_widget(self._set_scaling, self)
self._widget_scaling = ScalingTracker.get_widget_scaling(self)
AppearanceModeTracker.add(self._set_appearance_mode, self)
self._appearance_mode = AppearanceModeTracker.get_mode() # 0: "Light" 1: "Dark"
self._min_character_width = min_character_width
self._fg_color = ThemeManager.theme["color"]["dropdown_color"] if fg_color == "default_theme" else fg_color
self._hover_color = ThemeManager.theme["color"]["dropdown_hover"] if hover_color == "default_theme" else hover_color
self._text_color = ThemeManager.theme["color"]["text"] if text_color == "default_theme" else text_color
# font
self._font = CTkFont() if font == "default_theme" 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=ThemeManager._apply_appearance_mode(self._hover_color, self._appearance_mode),
borderwidth=self._apply_widget_scaling(4),
activeborderwidth=self._apply_widget_scaling(4),
bg=ThemeManager._apply_appearance_mode(self._fg_color, self._appearance_mode),
fg=ThemeManager._apply_appearance_mode(self._text_color, self._appearance_mode),
activeforeground=ThemeManager._apply_appearance_mode(self._text_color, self._appearance_mode),
font=self._apply_font_scaling(self._font),
cursor="hand2")
else:
super().configure(tearoff=False,
relief="flat",
activebackground=ThemeManager._apply_appearance_mode(self._hover_color, self._appearance_mode),
borderwidth=0,
activeborderwidth=0,
bg=ThemeManager._apply_appearance_mode(self._fg_color, self._appearance_mode),
fg=ThemeManager._apply_appearance_mode(self._text_color, self._appearance_mode),
activeforeground=ThemeManager._apply_appearance_mode(self._text_color, self._appearance_mode),
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 = kwargs.pop("fg_color")
super().configure(bg=ThemeManager._apply_appearance_mode(self._fg_color, self._appearance_mode))
if "hover_color" in kwargs:
self._hover_color = kwargs.pop("hover_color")
super().configure(activebackground=ThemeManager._apply_appearance_mode(self._hover_color, self._appearance_mode))
if "text_color" in kwargs:
self._text_color = kwargs.pop("text_color")
super().configure(fg=ThemeManager._apply_appearance_mode(self._text_color, self._appearance_mode))
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)
def _apply_widget_scaling(self, value: Union[int, float, str]) -> Union[float, str]:
if isinstance(value, (int, float)):
return value * self._widget_scaling
else:
return value
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 """
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 len(font) == 3:
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")
@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)}\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='<name>', size=<size in px>)\n" +
f"font=('<name>', <size in px>)\n")
def _set_scaling(self, new_widget_scaling, new_window_scaling):
self._widget_scaling = new_widget_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 """
if mode_string.lower() == "dark":
self._appearance_mode = 1
elif mode_string.lower() == "light":
self._appearance_mode = 0
self._configure_menu_for_platforms()

View File

@ -0,0 +1,296 @@
import sys
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
from ...ctk_tk import CTk
from ...ctk_toplevel import CTkToplevel
from ..theme.theme_manager import ThemeManager
from ..font.ctk_font import CTkFont
from ..appearance_mode.appearance_mode_base_class import CTkAppearanceModeBaseClass
from ..scaling.scaling_base_class import CTkScalingBaseClass
from ....utility.utility_functions 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 = None,
width: int = 0,
height: int = 0,
bg_color: Union[str, Tuple[str, str], None] = None,
**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 is None else bg_color
# set bg color of tkinter.Frame
super().configure(bg=self._apply_appearance_mode(self._bg_color))
# add configure callback to tkinter.Frame
super().bind('<Configure>', 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)) and not isinstance(self.master, (CTkBaseClass, CTk, CTkToplevel)):
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[<attribute>] 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):
return
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 = kwargs.pop("bg_color")
if new_bg_color is None:
self._bg_color = self._detect_color_of_master()
else:
self._bg_color = 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.")
@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)}\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='<name>', size=<size in px>)\n" +
f"font=('<name>', <size in px>)\n")
def _update_dimensions_event(self, event):
# only redraw if dimensions changed (for performance), independent of scaling
if round(self._current_width) != round(event.width / self._widget_scaling) or round(self._current_height) != round(event.height / self._widget_scaling):
self._current_width = (event.width / self._widget_scaling) # adjust current size according to new size given by event
self._current_height = (event.height / self._widget_scaling) # _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 color of self.master widget to set correct _bg_color """
if master_widget is None:
master_widget = self.master
if isinstance(master_widget, (CTkBaseClass, CTk, CTkToplevel)) and hasattr(master_widget, "_fg_color"):
if master_widget.cget("fg_color") is not None:
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):
if mode_string.lower() == "dark":
self._appearance_mode = 1
elif mode_string.lower() == "light":
self._appearance_mode = 0
super().configure(bg=self._apply_appearance_mode(self._bg_color))
self._draw()
def _set_scaling(self, new_widget_scaling, new_window_scaling):
self._widget_scaling = new_widget_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 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()

View File

@ -0,0 +1,530 @@
import tkinter
import sys
from typing import Union, Tuple, Callable
from .core_rendering.ctk_canvas import CTkCanvas
from .theme.theme_manager import ThemeManager
from .core_rendering.draw_engine import DrawEngine
from .core_widget_classes.widget_base_class import CTkBaseClass
from .font.ctk_font import CTkFont
from .image.ctk_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 = 6
def __init__(self,
master: any = None,
width: int = 140,
height: int = 28,
corner_radius: Union[int, str] = "default_theme",
border_width: Union[int, str] = "default_theme",
border_spacing: int = 2,
bg_color: Union[str, Tuple[str, str], None] = None,
fg_color: Union[str, Tuple[str, str], None] = "default_theme",
hover_color: Union[str, Tuple[str, str]] = "default_theme",
border_color: Union[str, Tuple[str, str]] = "default_theme",
text_color: Union[str, Tuple[str, str]] = "default_theme",
text_color_disabled: Union[str, Tuple[str, str]] = "default_theme",
background_corner_colors: Tuple[Union[str, Tuple[str, str]]] = None,
round_width_to_even_numbers: bool = True,
round_height_to_even_numbers: bool = True,
text: str = "CTkButton",
font: Union[tuple, CTkFont] = "default_theme",
textvariable: tkinter.Variable = None,
image: Union[tkinter.PhotoImage, CTkImage] = None,
state: str = "normal",
hover: bool = True,
command: Callable = 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)
# color
self._fg_color = ThemeManager.theme["color"]["button"] if fg_color == "default_theme" else fg_color
self._hover_color = ThemeManager.theme["color"]["button_hover"] if hover_color == "default_theme" else hover_color
self._border_color = ThemeManager.theme["color"]["button_border"] if border_color == "default_theme" else border_color
self._text_color = ThemeManager.theme["color"]["text_button"] if text_color == "default_theme" else text_color
self._text_color_disabled = ThemeManager.theme["color"]["text_button_disabled"] if text_color_disabled == "default_theme" else text_color_disabled
self._background_corner_colors = background_corner_colors # rendering options for DrawEngine
# shape
self._corner_radius = ThemeManager.theme["shape"]["button_corner_radius"] if corner_radius == "default_theme" else corner_radius
self._border_width = ThemeManager.theme["shape"]["button_border_width"] if border_width == "default_theme" else border_width
self._round_width_to_even_numbers = round_width_to_even_numbers # rendering options for DrawEngine
self._round_height_to_even_numbers = round_height_to_even_numbers # rendering options for DrawEngine
self._corner_radius = min(self._corner_radius, round(self._current_height/2))
self._compound = compound
self._anchor = anchor
self._border_spacing = border_spacing
# text, image
self._image = image
self._image_label: Union[tkinter.Label, None] = None
self._text = text
self._text_label: Union[tkinter.Label, None] = None
# font
self._font = CTkFont() if font == "default_theme" 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._textvariable = textvariable
self._state = state
self._hover = hover
self._click_animation_running: bool = False
# canvas
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
# canvas event bindings
self._canvas.bind("<Enter>", self._on_enter)
self._canvas.bind("<Leave>", self._on_leave)
self._canvas.bind("<Button-1>", self._clicked)
self._canvas.bind("<Button-1>", self._clicked)
# configure cursor and initial draw
self._set_cursor()
self._draw()
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._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 """
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 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):
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 is None:
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("<Enter>", self._on_enter)
self._text_label.bind("<Leave>", self._on_leave)
self._text_label.bind("<Button-1>", self._clicked)
self._text_label.bind("<Button-1>", 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._fg_color is None:
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._create_grid()
self._image_label.bind("<Enter>", self._on_enter)
self._image_label.bind("<Leave>", self._on_leave)
self._image_label.bind("<Button-1>", self._clicked)
self._image_label.bind("<Button-1>", self._clicked)
if no_color_updates is False:
# set image_label bg color (background color of label)
if self._fg_color is None:
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))
self._image_label.configure(image=self._image) # set image
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 = kwargs.pop("fg_color")
require_redraw = True
if "hover_color" in kwargs:
self._hover_color = kwargs.pop("hover_color")
require_redraw = True
if "border_color" in kwargs:
self._border_color = kwargs.pop("border_color")
require_redraw = True
if "text_color" in kwargs:
self._text_color = kwargs.pop("text_color")
require_redraw = True
if "text_color_disabled" in kwargs:
self._text_color_disabled = 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)
self._create_grid()
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:
self._image = kwargs.pop("image")
self._create_grid()
require_redraw = True
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 is None:
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._command is not 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)
self._command()
def bind(self, sequence: str = None, command: Callable = None, add: str = None) -> str:
""" called on the tkinter.Label and tkinter.Canvas """
canvas_bind_return = self._canvas.bind(sequence, command, add)
label_bind_return = self._text_label.bind(sequence, command, add)
return canvas_bind_return + " + " + label_bind_return
def unbind(self, sequence: str, funcid: str = None):
""" called on the tkinter.Label and tkinter.Canvas """
canvas_bind_return, label_bind_return = funcid.split(" + ")
self._canvas.unbind(sequence, canvas_bind_return)
self._text_label.unbind(sequence, label_bind_return)
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()

View File

@ -0,0 +1,438 @@
import tkinter
import sys
from typing import Union, Tuple, Callable
from .core_rendering.ctk_canvas import CTkCanvas
from .theme.theme_manager import ThemeManager
from .core_rendering.draw_engine import DrawEngine
from .core_widget_classes.widget_base_class import CTkBaseClass
from .font.ctk_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 = None,
width: int = 100,
height: int = 24,
checkbox_width: int = 24,
checkbox_height: int = 24,
corner_radius: Union[int, str] = "default_theme",
border_width: Union[int, str] = "default_theme",
bg_color: Union[str, Tuple[str, str], None] = None,
fg_color: Union[str, Tuple[str, str]] = "default_theme",
hover_color: Union[str, Tuple[str, str]] = "default_theme",
border_color: Union[str, Tuple[str, str]] = "default_theme",
checkmark_color: Union[str, Tuple[str, str]] = "default_theme",
text_color: Union[str, Tuple[str, str]] = "default_theme",
text_color_disabled: Union[str, Tuple[str, str]] = "default_theme",
text: str = "CTkCheckBox",
font: Union[tuple, CTkFont] = "default_theme",
textvariable: tkinter.Variable = None,
state: str = tkinter.NORMAL,
hover: bool = True,
command: Callable = None,
onvalue: Union[int, str] = 1,
offvalue: Union[int, str] = 0,
variable: tkinter.Variable = 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["color"]["button"] if fg_color == "default_theme" else fg_color
self._hover_color = ThemeManager.theme["color"]["button_hover"] if hover_color == "default_theme" else hover_color
self._border_color = ThemeManager.theme["color"]["checkbox_border"] if border_color == "default_theme" else border_color
self._checkmark_color = ThemeManager.theme["color"]["checkmark"] if checkmark_color == "default_theme" else checkmark_color
# shape
self._corner_radius = ThemeManager.theme["shape"]["checkbox_corner_radius"] if corner_radius == "default_theme" else corner_radius
self._border_width = ThemeManager.theme["shape"]["checkbox_border_width"] if border_width == "default_theme" else border_width
# text
self._text = text
self._text_label: Union[tkinter.Label, None] = None
self._text_color = ThemeManager.theme["color"]["text"] if text_color == "default_theme" else text_color
self._text_color_disabled = ThemeManager.theme["color"]["text_disabled"] if text_color_disabled == "default_theme" else text_color_disabled
# font
self._font = CTkFont() if font == "default_theme" 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._canvas.bind("<Enter>", self._on_enter)
self._canvas.bind("<Leave>", self._on_leave)
self._canvas.bind("<Button-1>", self.toggle)
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"
self._text_label.bind("<Enter>", self._on_enter)
self._text_label.bind("<Leave>", self._on_leave)
self._text_label.bind("<Button-1>", self.toggle)
# 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._draw() # initial draw
self._set_cursor()
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):
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 "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 = kwargs.pop("fg_color")
require_redraw = True
if "hover_color" in kwargs:
self._hover_color = kwargs.pop("hover_color")
require_redraw = True
if "text_color" in kwargs:
self._text_color = kwargs.pop("text_color")
require_redraw = True
if "border_color" in kwargs:
self._border_color = 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=None, command=None, add=None):
""" called on the tkinter.Canvas """
return self._canvas.bind(sequence, command, add)
def unbind(self, sequence, funcid=None):
""" called on the tkinter.Canvas """
return self._canvas.unbind(sequence, funcid)
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()

View File

@ -0,0 +1,413 @@
import tkinter
import sys
from typing import Union, Tuple, Callable, List
from .core_widget_classes.dropdown_menu import DropdownMenu
from .core_rendering.ctk_canvas import CTkCanvas
from .theme.theme_manager import ThemeManager
from .core_rendering.draw_engine import DrawEngine
from .core_widget_classes.widget_base_class import CTkBaseClass
from .font.ctk_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 = None,
width: int = 140,
height: int = 28,
corner_radius: Union[int, str] = "default_theme",
border_width: Union[int, str] = "default_theme",
bg_color: Union[str, Tuple[str, str], None] = None,
fg_color: Union[str, Tuple[str, str]] = "default_theme",
border_color: Union[str, Tuple[str, str]] = "default_theme",
button_color: Union[str, Tuple[str, str]] = "default_theme",
button_hover_color: Union[str, Tuple[str, str]] = "default_theme",
dropdown_fg_color: Union[str, Tuple[str, str]] = "default_theme",
dropdown_hover_color: Union[str, Tuple[str, str]] = "default_theme",
dropdown_text_color: Union[str, Tuple[str, str]] = "default_theme",
text_color: Union[str, Tuple[str, str]] = "default_theme",
text_color_disabled: Union[str, Tuple[str, str]] = "default_theme",
font: Union[tuple, CTkFont] = "default_theme",
dropdown_font: Union[tuple, CTkFont] = "default_theme",
values: List[str] = None,
state: str = tkinter.NORMAL,
hover: bool = True,
variable: tkinter.Variable = None,
command: Callable = 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)
# color variables
self._fg_color = ThemeManager.theme["color"]["entry"] if fg_color == "default_theme" else fg_color
self._border_color = ThemeManager.theme["color"]["combobox_border"] if border_color == "default_theme" else border_color
self._button_color = ThemeManager.theme["color"]["combobox_border"] if button_color == "default_theme" else button_color
self._button_hover_color = ThemeManager.theme["color"]["combobox_button_hover"] if button_hover_color == "default_theme" else button_hover_color
# shape
self._corner_radius = ThemeManager.theme["shape"]["button_corner_radius"] if corner_radius == "default_theme" else corner_radius
self._border_width = ThemeManager.theme["shape"]["entry_border_width"] if border_width == "default_theme" else border_width
# text and font
self._text_color = ThemeManager.theme["color"]["text"] if text_color == "default_theme" else text_color
self._text_color_disabled = ThemeManager.theme["color"]["text_disabled"] if text_color_disabled == "default_theme" else text_color_disabled
# font
self._font = CTkFont() if font == "default_theme" 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()
# insert default value
if len(self._values) > 0:
self._entry.insert(0, self._values[0])
else:
self._entry.insert(0, "CTkComboBox")
self._draw() # initial draw
# event bindings
self._canvas.tag_bind("right_parts", "<Enter>", self._on_enter)
self._canvas.tag_bind("dropdown_arrow", "<Enter>", self._on_enter)
self._canvas.tag_bind("right_parts", "<Leave>", self._on_leave)
self._canvas.tag_bind("dropdown_arrow", "<Leave>", self._on_leave)
self._canvas.tag_bind("right_parts", "<Button-1>", self._clicked)
self._canvas.tag_bind("dropdown_arrow", "<Button-1>", self._clicked)
if self._variable is not None:
self._entry.configure(textvariable=self._variable)
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):
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._border_color),
fill=self._apply_appearance_mode(self._border_color))
self._canvas.itemconfig("border_parts_right",
outline=self._apply_appearance_mode(self._border_color),
fill=self._apply_appearance_mode(self._border_color))
self._entry.configure(bg=self._apply_appearance_mode(self._fg_color),
fg=self._apply_appearance_mode(self._text_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 = kwargs.pop("fg_color")
require_redraw = True
if "border_color" in kwargs:
self._border_color = kwargs.pop("border_color")
require_redraw = True
if "button_color" in kwargs:
self._button_color = kwargs.pop("button_color")
require_redraw = True
if "button_hover_color" in kwargs:
self._button_hover_color = 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 = kwargs.pop("text_color")
require_redraw = True
if "text_color_disabled" in kwargs:
self._text_color_disabled = 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 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=0):
if self._state is not tkinter.DISABLED and len(self._values) > 0:
self._open_dropdown_menu()
def bind(self, sequence=None, command=None, add=None):
""" called on the tkinter.Entry """
return self._entry.bind(sequence, command, add)
def unbind(self, sequence, funcid=None):
""" called on the tkinter.Entry """
return self._entry.unbind(sequence, funcid)
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()

View File

@ -0,0 +1,368 @@
import tkinter
from typing import Union, Tuple
from .core_rendering.ctk_canvas import CTkCanvas
from .theme.theme_manager import ThemeManager
from .core_rendering.draw_engine import DrawEngine
from .core_widget_classes.widget_base_class import CTkBaseClass
from .font.ctk_font import CTkFont
from customtkinter.utility.utility_functions 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 = None,
width: int = 140,
height: int = 28,
corner_radius: int = "default_theme",
border_width: int = "default_theme",
bg_color: Union[str, Tuple[str, str], None] = None,
fg_color: Union[str, Tuple[str, str], None] = "default_theme",
border_color: Union[str, Tuple[str, str]] = "default_theme",
text_color: Union[str, Tuple[str, str]] = "default_theme",
placeholder_text_color: Union[str, Tuple[str, str]] = "default_theme",
textvariable: tkinter.Variable = None,
placeholder_text: str = None,
font: Union[tuple, CTkFont] = "default_theme",
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["color"]["entry"] if fg_color == "default_theme" else fg_color
self._text_color = ThemeManager.theme["color"]["text"] if text_color == "default_theme" else text_color
self._placeholder_text_color = ThemeManager.theme["color"]["entry_placeholder_text"] if placeholder_text_color == "default_theme" else placeholder_text_color
self._border_color = ThemeManager.theme["color"]["entry_border"] if border_color == "default_theme" else border_color
# shape
self._corner_radius = ThemeManager.theme["shape"]["button_corner_radius"] if corner_radius == "default_theme" else corner_radius
self._border_width = ThemeManager.theme["shape"]["entry_border_width"] if border_width == "default_theme" 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 == "default_theme" 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))
self._create_grid()
check_kwargs_empty(kwargs, raise_error=True)
self._entry.bind('<FocusOut>', self._entry_focus_out)
self._entry.bind('<FocusIn>', self._entry_focus_in)
self._activate_placeholder()
self._draw()
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):
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) is not None:
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),
fg=self._apply_appearance_mode(self._text_color),
disabledbackground=self._apply_appearance_mode(self._fg_color),
disabledforeground=self._apply_appearance_mode(self._text_color),
highlightcolor=self._apply_appearance_mode(self._fg_color),
insertbackground=self._apply_appearance_mode(self._text_color))
else:
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),
fg=self._apply_appearance_mode(self._text_color),
disabledbackground=self._apply_appearance_mode(self._bg_color),
disabledforeground=self._apply_appearance_mode(self._text_color),
highlightcolor=self._apply_appearance_mode(self._bg_color),
insertbackground=self._apply_appearance_mode(self._text_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))
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 = kwargs.pop("fg_color")
require_redraw = True
if "text_color" in kwargs:
self._text_color = kwargs.pop("text_color")
require_redraw = True
if "border_color" in kwargs:
self._border_color = 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 "placeholder_text_color" in kwargs:
self._placeholder_text_color = kwargs.pop("placeholder_text_color")
require_redraw = True
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=None):
""" called on the tkinter.Entry """
return self._entry.bind(sequence, command, add)
def unbind(self, sequence, funcid=None):
""" called on the tkinter.Entry """
return self._entry.unbind(sequence, funcid)
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), show="")
self._entry.delete(0, tkinter.END)
self._entry.insert(0, self._placeholder_text)
def _deactivate_placeholder(self):
if self._placeholder_text_active:
self._placeholder_text_active = False
self._entry.config(fg=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)

View File

@ -0,0 +1,188 @@
from typing import Union, Tuple, List
from .core_rendering.ctk_canvas import CTkCanvas
from .theme.theme_manager import ThemeManager
from .core_rendering.draw_engine import DrawEngine
from .core_widget_classes.widget_base_class 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 = None,
width: int = 200,
height: int = 200,
corner_radius: Union[int, str] = "default_theme",
border_width: Union[int, str] = "default_theme",
bg_color: Union[str, Tuple[str, str], None] = None,
fg_color: Union[str, Tuple[str, str], None] = "default_theme",
border_color: Union[str, Tuple[str, str]] = "default_theme",
background_corner_colors: Tuple[Union[str, Tuple[str, str]]] = None,
overwrite_preferred_drawing_method: str = 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["color"]["frame_border"] if border_color == "default_theme" else border_color
# determine fg_color of frame
if fg_color == "default_theme":
if isinstance(self.master, CTkFrame):
if self.master._fg_color == ThemeManager.theme["color"]["frame_low"]:
self._fg_color = ThemeManager.theme["color"]["frame_high"]
else:
self._fg_color = ThemeManager.theme["color"]["frame_low"]
else:
self._fg_color = ThemeManager.theme["color"]["frame_low"]
else:
self._fg_color = fg_color
self._background_corner_colors = background_corner_colors # rendering options for DrawEngine
# shape
self._corner_radius = ThemeManager.theme["shape"]["frame_corner_radius"] if corner_radius == "default_theme" else corner_radius
self._border_width = ThemeManager.theme["shape"]["frame_border_width"] if border_width == "default_theme" 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):
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 is None:
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 = kwargs.pop("fg_color")
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 None
if self._fg_color is None:
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 = 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=None):
""" called on the tkinter.Canvas """
return self._canvas.bind(sequence, command, add)
def unbind(self, sequence, funcid=None):
""" called on the tkinter.Canvas """
return self._canvas.unbind(sequence, funcid)

View File

@ -0,0 +1,221 @@
import tkinter
from typing import Union, Tuple, Callable
from .core_rendering.ctk_canvas import CTkCanvas
from .theme.theme_manager import ThemeManager
from .core_rendering.draw_engine import DrawEngine
from .core_widget_classes.widget_base_class import CTkBaseClass
from .font.ctk_font import CTkFont
from customtkinter.utility.utility_functions 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 = {"compound", "cursor", "image", "justify", "padx", "pady",
"textvariable", "state", "takefocus", "underline", "wraplength"}
def __init__(self,
master: any = None,
width: int = 140,
height: int = 28,
corner_radius: Union[int, str] = "default_theme",
bg_color: Union[str, Tuple[str, str], None] = None,
fg_color: Union[str, Tuple[str, str], None] = "default_theme",
text_color: Union[str, Tuple[str, str]] = "default_theme",
text: str = "CTkLabel",
font: Union[tuple, CTkFont] = "default_theme",
anchor: str = "center", # label anchor: center, n, e, s, 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)
# color
self._fg_color = ThemeManager.theme["color"]["label"] if fg_color == "default_theme" else fg_color
self._text_color = ThemeManager.theme["color"]["text"] if text_color == "default_theme" else text_color
# shape
self._corner_radius = ThemeManager.theme["shape"]["label_corner_radius"] if corner_radius == "default_theme" else corner_radius
# text
self._anchor = anchor
self._text = text
# font
self._font = CTkFont() if font == "default_theme" 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._text_label = tkinter.Label(master=self,
highlightthickness=0,
padx=0,
pady=0,
borderwidth=1,
anchor=self._anchor,
text=self._text,
font=self._apply_font_scaling(self._font))
self._text_label.configure(**pop_from_dict_by_set(kwargs, self._valid_tk_label_attributes))
text_label_grid_sticky = self._anchor if self._anchor != "center" else ""
self._text_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))))
check_kwargs_empty(kwargs, raise_error=True)
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._text_label.configure(font=self._apply_font_scaling(self._font))
text_label_grid_sticky = self._anchor if self._anchor != "center" else ""
self._text_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))))
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._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="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):
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) is not None:
self._canvas.itemconfig("inner_parts",
fill=self._apply_appearance_mode(self._fg_color),
outline=self._apply_appearance_mode(self._fg_color))
self._text_label.configure(fg=self._apply_appearance_mode(self._text_color),
bg=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._text_label.configure(fg=self._apply_appearance_mode(self._text_color),
bg=self._apply_appearance_mode(self._bg_color))
self._canvas.configure(bg=self._apply_appearance_mode(self._bg_color))
def configure(self, require_redraw=False, **kwargs):
if "anchor" in kwargs:
self._anchor = kwargs.pop("anchor")
text_label_grid_sticky = self._anchor if self._anchor != "center" else ""
self._text_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))))
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 "fg_color" in kwargs:
self._fg_color = kwargs.pop("fg_color")
require_redraw = True
if "text_color" in kwargs:
self._text_color = kwargs.pop("text_color")
require_redraw = True
if "corner_radius" in kwargs:
self._corner_radius = kwargs.pop("corner_radius")
text_label_grid_sticky = self._anchor if self._anchor != "center" else ""
self._text_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))))
require_redraw = True
self._text_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 == "anchor":
return self._anchor
elif attribute_name in self._valid_tk_label_attributes:
return self._text_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 = None) -> str:
""" called on the tkinter.Label and tkinter.Canvas """
canvas_bind_return = self._canvas.bind(sequence, command, add)
label_bind_return = self._text_label.bind(sequence, command, add)
return canvas_bind_return + " + " + label_bind_return
def unbind(self, sequence: str, funcid: str = None):
""" called on the tkinter.Label and tkinter.Canvas """
canvas_bind_return, label_bind_return = funcid.split(" + ")
self._canvas.unbind(sequence, canvas_bind_return)
self._text_label.unbind(sequence, label_bind_return)
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()

View File

@ -0,0 +1,414 @@
import tkinter
import sys
from typing import Union, Tuple, Callable
from .core_rendering.ctk_canvas import CTkCanvas
from .theme.theme_manager import ThemeManager
from .core_rendering.draw_engine import DrawEngine
from .core_widget_classes.widget_base_class import CTkBaseClass
from .core_widget_classes.dropdown_menu import DropdownMenu
from .font.ctk_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 = None,
width: int = 140,
height: int = 28,
corner_radius: Union[int, str] = "default_theme",
bg_color: Union[str, Tuple[str, str], None] = None,
fg_color: Union[str, Tuple[str, str]] = "default_theme",
button_color: Union[str, Tuple[str, str]] = "default_theme",
button_hover_color: Union[str, Tuple[str, str]] = "default_theme",
text_color: Union[str, Tuple[str, str]] = "default_theme",
text_color_disabled: Union[str, Tuple[str, str]] = "default_theme",
dropdown_fg_color: Union[str, Tuple[str, str]] = "default_theme",
dropdown_hover_color: Union[str, Tuple[str, str]] = "default_theme",
dropdown_text_color: Union[str, Tuple[str, str]] = "default_theme",
font: Union[tuple, CTkFont] = "default_theme",
dropdown_font: Union[tuple, CTkFont] = "default_theme",
values: list = None,
variable: tkinter.Variable = None,
state: str = tkinter.NORMAL,
hover: bool = True,
command: Callable[[str], 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["color"]["button"] if fg_color == "default_theme" else fg_color
self._button_color = ThemeManager.theme["color"]["optionmenu_button"] if button_color == "default_theme" else button_color
self._button_hover_color = ThemeManager.theme["color"]["optionmenu_button_hover"] if button_hover_color == "default_theme" else button_hover_color
# shape
self._corner_radius = ThemeManager.theme["shape"]["button_corner_radius"] if corner_radius == "default_theme" else corner_radius
# text and font
self._text_color = ThemeManager.theme["color"]["text_button"] if text_color == "default_theme" else text_color
self._text_color_disabled = ThemeManager.theme["color"]["text_button_disabled"] if text_color_disabled == "default_theme" else text_color_disabled
# font
self._font = CTkFont() if font == "default_theme" 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)
self._create_grid()
if not self._dynamic_resizing:
self.grid_propagate(0)
if self._cursor_manipulation_enabled:
if sys.platform == "darwin":
self.configure(cursor="pointinghand")
elif sys.platform.startswith("win"):
self.configure(cursor="hand2")
# event bindings
self._canvas.bind("<Enter>", self._on_enter)
self._canvas.bind("<Leave>", self._on_leave)
self._canvas.bind("<Button-1>", self._clicked)
self._canvas.bind("<Button-1>", self._clicked)
self._text_label.bind("<Enter>", self._on_enter)
self._text_label.bind("<Leave>", self._on_leave)
self._text_label.bind("<Button-1>", self._clicked)
self._text_label.bind("<Button-1>", self._clicked)
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_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):
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 = kwargs.pop("fg_color")
require_redraw = True
if "button_color" in kwargs:
self._button_color = kwargs.pop("button_color")
require_redraw = True
if "button_hover_color" in kwargs:
self._button_hover_color = kwargs.pop("button_hover_color")
require_redraw = True
if "text_color" in kwargs:
self._text_color = kwargs.pop("text_color")
require_redraw = True
if "dropdown_color" in kwargs:
self._dropdown_menu.configure(fg_color=kwargs.pop("dropdown_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 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: str = None) -> str:
""" called on the tkinter.Label and tkinter.Canvas """
canvas_bind_return = self._canvas.bind(sequence, command, add)
label_bind_return = self._text_label.bind(sequence, command, add)
return canvas_bind_return + " + " + label_bind_return
def unbind(self, sequence: str, funcid: str = None):
""" called on the tkinter.Label and tkinter.Canvas """
canvas_bind_return, label_bind_return = funcid.split(" + ")
self._canvas.unbind(sequence, canvas_bind_return)
self._text_label.unbind(sequence, label_bind_return)
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()

View File

@ -0,0 +1,293 @@
import tkinter
import math
from typing import Union, Tuple
from .core_rendering.ctk_canvas import CTkCanvas
from .theme.theme_manager import ThemeManager
from .core_rendering.draw_engine import DrawEngine
from .core_widget_classes.widget_base_class 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 = None,
width: Union[int, str] = "default_init",
height: Union[int, str] = "default_init",
corner_radius: Union[str, Tuple[str, str]] = "default_theme",
border_width: Union[int, str] = "default_theme",
bg_color: Union[str, Tuple[str, str], None] = None,
fg_color: Union[str, Tuple[str, str]] = "default_theme",
border_color: Union[str, Tuple[str, str]] = "default_theme",
progress_color: Union[str, Tuple[str, str]] = "default_theme",
variable: tkinter.Variable = None,
orientation: str = "horizontal",
mode: str = "determinate",
determinate_speed: float = 1,
indeterminate_speed: float = 1,
**kwargs):
# set default dimensions according to orientation
if width == "default_init":
if orientation.lower() == "vertical":
width = 8
else:
width = 200
if height == "default_init":
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["color"]["progressbar_border"] if border_color == "default_theme" else border_color
self._fg_color = ThemeManager.theme["color"]["progressbar"] if fg_color == "default_theme" else fg_color
self._progress_color = ThemeManager.theme["color"]["progressbar_progress"] if progress_color == "default_theme" else progress_color
# control variable
self._variable = variable
self._variable_callback_blocked = False
self._variable_callback_name = None
# shape
self._corner_radius = ThemeManager.theme["shape"]["progressbar_corner_radius"] if corner_radius == "default_theme" else corner_radius
self._border_width = ThemeManager.theme["shape"]["progressbar_border_width"] if border_width == "default_theme" 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):
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 "fg_color" in kwargs:
self._fg_color = kwargs.pop("fg_color")
require_redraw = True
if "border_color" in kwargs:
self._border_color = kwargs.pop("border_color")
require_redraw = True
if "progress_color" in kwargs:
self._progress_color = kwargs.pop("progress_color")
require_redraw = True
if "border_width" in kwargs:
self._border_width = kwargs.pop("border_width")
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 indeterminate mode """
if not self._loop_running:
self._loop_running = True
self._internal_loop()
def stop(self):
""" stop indeterminate mode """
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.after(20, self._internal_loop)
else:
self._indeterminate_value += self._indeterminate_speed
self._draw()
self.after(20, self._internal_loop)
def step(self):
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=None, command=None, add=None):
""" called on the tkinter.Canvas """
return self._canvas.bind(sequence, command, add)
def unbind(self, sequence, funcid=None):
""" called on the tkinter.Canvas """
return self._canvas.unbind(sequence, funcid)
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()

View File

@ -0,0 +1,399 @@
import tkinter
import sys
from typing import Union, Tuple, Callable
from .core_rendering.ctk_canvas import CTkCanvas
from .theme.theme_manager import ThemeManager
from .core_rendering.draw_engine import DrawEngine
from .core_widget_classes.widget_base_class import CTkBaseClass
from .font.ctk_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 = None,
width: int = 100,
height: int = 22,
radiobutton_width: int = 22,
radiobutton_height: int = 22,
corner_radius: Union[int, str] = "default_theme",
border_width_unchecked: Union[int, str] = "default_theme",
border_width_checked: Union[int, str] = "default_theme",
bg_color: Union[str, Tuple[str, str], None] = None,
fg_color: Union[str, Tuple[str, str]] = "default_theme",
hover_color: Union[str, Tuple[str, str]] = "default_theme",
border_color: Union[str, Tuple[str, str]] = "default_theme",
text_color: Union[str, Tuple[str, str]] = "default_theme",
text_color_disabled: Union[str, Tuple[str, str]] = "default_theme",
text: str = "CTkRadioButton",
font: Union[tuple, CTkFont] = "default_theme",
textvariable: tkinter.Variable = None,
variable: tkinter.Variable = None,
value: Union[int, str] = 0,
state: str = tkinter.NORMAL,
hover: bool = True,
command: Callable = 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["color"]["button"] if fg_color == "default_theme" else fg_color
self._hover_color = ThemeManager.theme["color"]["button_hover"] if hover_color == "default_theme" else hover_color
self._border_color = ThemeManager.theme["color"]["checkbox_border"] if border_color == "default_theme" else border_color
# shape
self._corner_radius = ThemeManager.theme["shape"]["radiobutton_corner_radius"] if corner_radius == "default_theme" else corner_radius
self._border_width_unchecked = ThemeManager.theme["shape"]["radiobutton_border_width_unchecked"] if border_width_unchecked == "default_theme" else border_width_unchecked
self._border_width_checked = ThemeManager.theme["shape"]["radiobutton_border_width_checked"] if border_width_checked == "default_theme" else border_width_checked
self._border_width = self._border_width_unchecked
# text
self._text = text
self._text_label: Union[tkinter.Label, None] = None
self._text_color = ThemeManager.theme["color"]["text"] if text_color == "default_theme" else text_color
self._text_color_disabled = ThemeManager.theme["color"]["text_disabled"] if text_color_disabled == "default_theme" else text_color_disabled
# font
self._font = CTkFont() if font == "default_theme" 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._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._canvas.bind("<Enter>", self._on_enter)
self._canvas.bind("<Leave>", self._on_leave)
self._canvas.bind("<Button-1>", self.invoke)
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"
self._text_label.bind("<Enter>", self._on_enter)
self._text_label.bind("<Leave>", self._on_leave)
self._text_label.bind("<Button-1>", self.invoke)
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._draw() # initial draw
self._set_cursor()
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):
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))
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 "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 = kwargs.pop("fg_color")
require_redraw = True
if "hover_color" in kwargs:
self._hover_color = kwargs.pop("hover_color")
require_redraw = True
if "text_color" in kwargs:
self._text_color = kwargs.pop("text_color")
require_redraw = True
if "border_color" in kwargs:
self._border_color = kwargs.pop("border_color")
require_redraw = True
if "border_width" in kwargs:
self._border_width = kwargs.pop("border_width")
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._border_width = self._border_width_checked
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._border_width = self._border_width_unchecked
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=None, command=None, add=None):
""" called on the tkinter.Canvas """
return self._canvas.bind(sequence, command, add)
def unbind(self, sequence, funcid=None):
""" called on the tkinter.Canvas """
return self._canvas.unbind(sequence, funcid)
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()

View File

@ -0,0 +1,265 @@
import sys
from typing import Union, Tuple, Callable
from .core_rendering.ctk_canvas import CTkCanvas
from .theme.theme_manager import ThemeManager
from .core_rendering.draw_engine import DrawEngine
from .core_widget_classes.widget_base_class 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 = None,
width: Union[int, str] = "default_init",
height: Union[int, str] = "default_init",
corner_radius: Union[int, str] = "default_theme",
border_spacing: Union[int, str] = "default_theme",
minimum_pixel_length: int = 20,
bg_color: Union[str, Tuple[str, str], None] = None,
fg_color: Union[str, Tuple[str, str], None] = "default_theme",
scrollbar_color: Union[str, Tuple[str, str]] = "default_theme",
scrollbar_hover_color: Union[str, Tuple[str, str]] = "default_theme",
hover: bool = True,
command: Callable = None,
orientation: str = "vertical",
**kwargs):
# set default dimensions according to orientation
if width == "default_init":
if orientation.lower() == "vertical":
width = 16
else:
width = 200
if height == "default_init":
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["color"]["frame_high"] if fg_color == "default_theme" else fg_color
self._scrollbar_color = ThemeManager.theme["color"]["scrollbar_button"] if scrollbar_color == "default_theme" else scrollbar_color
self._scrollbar_hover_color = ThemeManager.theme["color"]["scrollbar_button_hover"] if scrollbar_hover_color == "default_theme" else scrollbar_hover_color
# shape
self._corner_radius = ThemeManager.theme["shape"]["scrollbar_corner_radius"] if corner_radius == "default_theme" else corner_radius
self._border_spacing = ThemeManager.theme["shape"]["scrollbar_border_spacing"] if border_spacing == "default_theme" 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._canvas.bind("<Enter>", self._on_enter)
self._canvas.bind("<Leave>", self._on_leave)
self._canvas.tag_bind("border_parts", "<Button-1>", self._clicked)
self._canvas.bind("<B1-Motion>", self._clicked)
self._canvas.bind("<MouseWheel>", self._mouse_scroll_event)
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._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):
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._scrollbar_hover_color),
outline=self._apply_appearance_mode(self._scrollbar_hover_color))
else:
self._canvas.itemconfig("scrollbar_parts",
fill=self._apply_appearance_mode(self._scrollbar_color),
outline=self._apply_appearance_mode(self._scrollbar_color))
if self._fg_color is None:
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 = kwargs.pop("fg_color")
require_redraw = True
if "scrollbar_color" in kwargs:
self._scrollbar_color = kwargs.pop("scrollbar_color")
require_redraw = True
if "scrollbar_hover_color" in kwargs:
self._scrollbar_hover_color = kwargs.pop("scrollbar_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._scrollbar_color
elif attribute_name == "scrollbar_hover_color":
return self._scrollbar_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._scrollbar_hover_color),
fill=self._apply_appearance_mode(self._scrollbar_hover_color))
def _on_leave(self, event=0):
self._hover_state = False
self._canvas.itemconfig("scrollbar_parts",
outline=self._apply_appearance_mode(self._scrollbar_color),
fill=self._apply_appearance_mode(self._scrollbar_color))
def _clicked(self, event):
if self._orientation == "vertical":
value = ((event.y - self._border_spacing) / (self._current_height - 2 * self._border_spacing)) / self._widget_scaling
else:
value = ((event.x - self._border_spacing) / (self._current_width - 2 * self._border_spacing)) / self._widget_scaling
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=None):
""" called on the tkinter.Canvas """
return self._canvas.bind(sequence, command, add)
def unbind(self, sequence, funcid=None):
""" called on the tkinter.Canvas """
return self._canvas.unbind(sequence, funcid)
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()

View File

@ -0,0 +1,406 @@
import tkinter
from typing import Union, Tuple, List, Dict, Callable
from .theme.theme_manager import ThemeManager
from .ctk_button import CTkButton
from .ctk_frame import CTkFrame
from .font.ctk_font import CTkFont
class CTkSegmentedButton(CTkFrame):
"""
Segmented button with corner radius, border width, variable support.
For detailed information check out the documentation.
"""
def __init__(self,
master: any = None,
width: int = 140,
height: int = 28,
corner_radius: Union[int, str] = "default_theme",
border_width: Union[int, str] = 3,
bg_color: Union[str, Tuple[str, str], None] = None,
fg_color: Union[str, Tuple[str, str], None] = "default_theme",
selected_color: Union[str, Tuple[str, str]] = "default_theme",
selected_hover_color: Union[str, Tuple[str, str]] = "default_theme",
unselected_color: Union[str, Tuple[str, str]] = "default_theme",
unselected_hover_color: Union[str, Tuple[str, str]] = "default_theme",
text_color: Union[str, Tuple[str, str]] = "default_theme",
text_color_disabled: Union[str, Tuple[str, str]] = "default_theme",
background_corner_colors: Tuple[Union[str, Tuple[str, str]]] = None,
font: Union[tuple, CTkFont] = "default_theme",
values: list = None,
variable: tkinter.Variable = None,
dynamic_resizing: bool = True,
command: Callable[[str], 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["color"]["segmented_button"] if fg_color == "default_theme" else fg_color
self._sb_selected_color = ThemeManager.theme["color"]["button"] if selected_color == "default_theme" else selected_color
self._sb_selected_hover_color = ThemeManager.theme["color"]["button_hover"] if selected_hover_color == "default_theme" else selected_hover_color
self._sb_unselected_color = ThemeManager.theme["color"]["segmented_button_unselected"] if unselected_color == "default_theme" else unselected_color
self._sb_unselected_hover_color = ThemeManager.theme["color"]["segmented_button_unselected_hover"] if unselected_hover_color == "default_theme" else unselected_hover_color
self._sb_text_color = ThemeManager.theme["color"]["text_button"] if text_color == "default_theme" else text_color
self._sb_text_color_disabled = ThemeManager.theme["color"]["text_button_disabled"] if text_color_disabled == "default_theme" else text_color_disabled
self._sb_corner_radius = ThemeManager.theme["shape"]["button_corner_radius"] if corner_radius == "default_theme" else corner_radius
self._sb_border_width = ThemeManager.theme["shape"]["button_border_width"] if border_width == "default_theme" else border_width
self._background_corner_colors = background_corner_colors # rendering options for DrawEngine
self._command: Callable[[str], None] = command
self._font = (ThemeManager.theme["text"]["font"], ThemeManager.theme["text"]["size"]) if font == "default_theme" 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=None)
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,
height=self._current_height,
width=0,
corner_radius=self._sb_corner_radius,
text=value,
border_width=self._sb_border_width,
border_color=self._sb_fg_color,
fg_color=self._sb_unselected_color,
hover_color=self._sb_unselected_hover_color,
text_color=self._sb_text_color,
text_color_disabled=self._sb_text_color_disabled,
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) # 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 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:
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 '{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}'")

View File

@ -0,0 +1,382 @@
import tkinter
import sys
from typing import Union, Tuple, Callable
from .core_rendering.ctk_canvas import CTkCanvas
from .theme.theme_manager import ThemeManager
from .core_rendering.draw_engine import DrawEngine
from .core_widget_classes.widget_base_class 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 = None,
width: Union[int, str] = "default_init",
height: Union[int, str] = "default_init",
corner_radius: Union[int, str] = "default_theme",
button_corner_radius: Union[int, str] = "default_theme",
border_width: Union[int, str] = "default_theme",
button_length: Union[int, str] = "default_theme",
bg_color: Union[str, Tuple[str, str], None] = None,
fg_color: Union[str, Tuple[str, str]] = "default_theme",
border_color: Union[str, Tuple[str, str], None] = None,
progress_color: Union[str, Tuple[str, str], None] = "default_theme",
button_color: Union[str, Tuple[str, str]] = "default_theme",
button_hover_color: Union[str, Tuple[str, str]] = "default_theme",
from_: int = 0,
to: int = 1,
state: str = "normal",
number_of_steps: Union[int, None] = None,
hover: bool = True,
command: Callable[[float], None] = None,
variable: tkinter.Variable = None,
orientation: str = "horizontal",
**kwargs):
# set default dimensions according to orientation
if width == "default_init":
if orientation.lower() == "vertical":
width = 16
else:
width = 200
if height == "default_init":
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 = border_color
self._fg_color = ThemeManager.theme["color"]["slider"] if fg_color == "default_theme" else fg_color
self._progress_color = ThemeManager.theme["color"]["slider_progress"] if progress_color == "default_theme" else progress_color
self._button_color = ThemeManager.theme["color"]["slider_button"] if button_color == "default_theme" else button_color
self._button_hover_color = ThemeManager.theme["color"]["slider_button_hover"] if button_hover_color == "default_theme" else button_hover_color
# shape
self._corner_radius = ThemeManager.theme["shape"]["slider_corner_radius"] if corner_radius == "default_theme" else corner_radius
self._button_corner_radius = ThemeManager.theme["shape"]["slider_button_corner_radius"] if button_corner_radius == "default_theme" else button_corner_radius
self._border_width = ThemeManager.theme["shape"]["slider_border_width"] if border_width == "default_theme" else border_width
self._button_length = ThemeManager.theme["shape"]["slider_button_length"] if button_length == "default_theme" 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._canvas.bind("<Enter>", self._on_enter)
self._canvas.bind("<Leave>", self._on_leave)
self._canvas.bind("<Button-1>", self._clicked)
self._canvas.bind("<B1-Motion>", self._clicked)
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 _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):
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 is None:
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 is None:
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 = kwargs.pop("fg_color")
require_redraw = True
if "progress_color" in kwargs:
self._progress_color = kwargs.pop("progress_color")
require_redraw = True
if "button_color" in kwargs:
self._button_color = kwargs.pop("button_color")
require_redraw = True
if "button_hover_color" in kwargs:
self._button_hover_color = kwargs.pop("button_hover_color")
require_redraw = True
if "border_color" in kwargs:
self._border_color = kwargs.pop("border_color")
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 = (event.x / self._current_width) / self._widget_scaling
else:
self._value = 1 - (event.y / self._current_height) / self._widget_scaling
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=None, command=None, add=None):
""" called on the tkinter.Canvas """
return self._canvas.bind(sequence, command, add)
def unbind(self, sequence, funcid=None):
""" called on the tkinter.Canvas """
return self._canvas.unbind(sequence, funcid)
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()

View File

@ -0,0 +1,451 @@
import tkinter
import sys
from typing import Union, Tuple, Callable
from .core_rendering.ctk_canvas import CTkCanvas
from .theme.theme_manager import ThemeManager
from .core_rendering.draw_engine import DrawEngine
from .core_widget_classes.widget_base_class import CTkBaseClass
from .font.ctk_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 = None,
width: int = 100,
height: int = 24,
switch_width: int = 36,
switch_height: int = 18,
corner_radius: Union[int, str] = "default_theme",
border_width: Union[int, str] = "default_theme",
button_length: Union[int, str] = "default_theme",
bg_color: Union[str, Tuple[str, str], None] = None,
fg_color: Union[str, Tuple[str, str]] = "default_theme",
border_color: Union[str, Tuple[str, str], None] = None,
progress_color: Union[str, Tuple[str, str]] = "default_theme",
button_color: Union[str, Tuple[str, str]] = "default_theme",
button_hover_color: Union[str, Tuple[str, str]] = "default_theme",
text_color: Union[str, Tuple[str, str]] = "default_theme",
text_color_disabled: Union[str, Tuple[str, str]] = "default_theme",
text: str = "CTkSwitch",
font: Union[tuple, CTkFont] = "default_theme",
textvariable: tkinter.Variable = None,
onvalue: Union[int, str] = 1,
offvalue: Union[int, str] = 0,
variable: tkinter.Variable = None,
hover: bool = True,
command: Callable = 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 = border_color
self._fg_color = ThemeManager.theme["color"]["switch"] if fg_color == "default_theme" else fg_color
self._progress_color = ThemeManager.theme["color"]["switch_progress"] if progress_color == "default_theme" else progress_color
self._button_color = ThemeManager.theme["color"]["switch_button"] if button_color == "default_theme" else button_color
self._button_hover_color = ThemeManager.theme["color"]["switch_button_hover"] if button_hover_color == "default_theme" else button_hover_color
self._text_color = ThemeManager.theme["color"]["text"] if text_color == "default_theme" else text_color
self._text_color_disabled = ThemeManager.theme["color"]["text_disabled"] if text_color_disabled == "default_theme" else text_color_disabled
# text
self._text = text
self._text_label = None
# font
self._font = CTkFont() if font == "default_theme" 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["shape"]["switch_corner_radius"] if corner_radius == "default_theme" else corner_radius
# self.button_corner_radius = ThemeManager.theme["shape"]["switch_button_corner_radius"] if button_corner_radius == "default_theme" else button_corner_radius
self._border_width = ThemeManager.theme["shape"]["switch_border_width"] if border_width == "default_theme" else border_width
self._button_length = ThemeManager.theme["shape"]["switch_button_length"] if button_length == "default_theme" 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: tkinter.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._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._canvas.bind("<Enter>", self._on_enter)
self._canvas.bind("<Leave>", self._on_leave)
self._canvas.bind("<Button-1>", self.toggle)
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"
self._text_label.bind("<Enter>", self._on_enter)
self._text_label.bind("<Leave>", self._on_leave)
self._text_label.bind("<Button-1>", self.toggle)
if self._variable is not None and self._variable != "":
self._variable_callback_name = self._variable.trace_add("write", self._variable_callback)
self.c_heck_state = True if self._variable.get() == self._onvalue else False
self._draw() # initial draw
self._set_cursor()
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):
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 is None:
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 is None:
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 "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 = kwargs.pop("fg_color")
require_redraw = True
if "progress_color" in kwargs:
new_progress_color = kwargs.pop("progress_color")
if new_progress_color is None:
self._progress_color = self._fg_color
else:
self._progress_color = new_progress_color
require_redraw = True
if "button_color" in kwargs:
self._button_color = kwargs.pop("button_color")
require_redraw = True
if "button_hover_color" in kwargs:
self._button_hover_color = kwargs.pop("button_hover_color")
require_redraw = True
if "border_color" in kwargs:
self._border_color = kwargs.pop("border_color")
require_redraw = True
if "border_width" in kwargs:
self._border_width = kwargs.pop("border_width")
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=None, command=None, add=None):
""" called on the tkinter.Canvas """
return self._canvas.bind(sequence, command, add)
def unbind(self, sequence, funcid=None):
""" called on the tkinter.Canvas """
return self._canvas.unbind(sequence, funcid)
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()

View File

@ -0,0 +1,370 @@
from typing import Union, Tuple, Dict, List, Callable
from .theme.theme_manager import ThemeManager
from .ctk_frame import CTkFrame
from .core_widget_classes.widget_base_class import CTkBaseClass
from .ctk_segmented_button import CTkSegmentedButton
from .core_rendering.ctk_canvas import CTkCanvas
from .core_rendering.draw_engine import DrawEngine
class CTkTabview(CTkBaseClass):
"""
Tabview...
For detailed information check out the documentation.
"""
_top_spacing = 10 # px on top of the buttons
_top_button_overhang = 8 # px
_button_height = 26
def __init__(self,
master: any = None,
width: int = 300,
height: int = 250,
corner_radius: Union[int, str] = "default_theme",
border_width: Union[int, str] = "default_theme",
bg_color: Union[str, Tuple[str, str], None] = None,
fg_color: Union[str, Tuple[str, str], None] = "default_theme",
border_color: Union[str, Tuple[str, str]] = "default_theme",
segmented_button_fg_color: Union[str, Tuple[str, str], None] = "default_theme",
segmented_button_selected_color: Union[str, Tuple[str, str]] = "default_theme",
segmented_button_selected_hover_color: Union[str, Tuple[str, str]] = "default_theme",
segmented_button_unselected_color: Union[str, Tuple[str, str]] = "default_theme",
segmented_button_unselected_hover_color: Union[str, Tuple[str, str]] = "default_theme",
text_color: Union[str, Tuple[str, str]] = "default_theme",
text_color_disabled: Union[str, Tuple[str, str]] = "default_theme",
command: Callable = 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["color"]["frame_border"] if border_color == "default_theme" else border_color
# determine fg_color of frame
if fg_color == "default_theme":
if isinstance(self.master, (CTkFrame, CTkTabview)):
if self.master.cget("fg_color") == ThemeManager.theme["color"]["frame_low"]:
self._fg_color = ThemeManager.theme["color"]["frame_high"]
else:
self._fg_color = ThemeManager.theme["color"]["frame_low"]
else:
self._fg_color = ThemeManager.theme["color"]["frame_low"]
else:
self._fg_color = fg_color
# shape
self._corner_radius = ThemeManager.theme["shape"]["frame_corner_radius"] if corner_radius == "default_theme" else corner_radius
self._border_width = ThemeManager.theme["shape"]["frame_border_width"] if border_width == "default_theme" else border_width
self._canvas = CTkCanvas(master=self,
bg=self._apply_appearance_mode(self._bg_color),
highlightthickness=0,
width=self._apply_widget_scaling(self._current_width - self._top_spacing - self._top_button_overhang),
height=self._apply_widget_scaling(self._current_height))
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._apply_widget_scaling(3),
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._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 """
# if self._border_width == 0:
# if self._fg_color is not None:
# self._tab_dict[name].configure(background_corner_colors=(self._fg_color, self._fg_color, self._bg_color, self._bg_color))
# else:
# self._tab_dict[name].configure(background_corner_colors=(self._bg_color, self._bg_color, self._bg_color, self._bg_color))
# else:
# self._tab_dict[name].configure(background_corner_colors=None)
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,
fg_color=self._fg_color,
border_width=0,
corner_radius=self._corner_radius)
return new_tab
def _draw(self, no_color_updates: bool = False):
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 is None:
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))
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 = kwargs.pop("fg_color")
require_redraw = True
if "border_color" in kwargs:
self._border_color = 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

View File

@ -0,0 +1,492 @@
import tkinter
from typing import Union, Tuple
from .core_rendering.ctk_canvas import CTkCanvas
from .ctk_scrollbar import CTkScrollbar
from .theme.theme_manager import ThemeManager
from .core_rendering.draw_engine import DrawEngine
from .core_widget_classes.widget_base_class import CTkBaseClass
from .font.ctk_font import CTkFont
from customtkinter.utility.utility_functions 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 = None,
width: int = 200,
height: int = 200,
corner_radius: Union[int, str] = "default_theme",
border_width: Union[int, str] = "default_theme",
border_spacing: int = 3,
bg_color: Union[str, Tuple[str, str], None] = None,
fg_color: Union[str, Tuple[str, str], None] = "default_theme",
border_color: Union[str, Tuple[str, str]] = "default_theme",
text_color: Union[str, str] = "default_theme",
scrollbar_color: Union[str, Tuple[str, str]] = "default_theme",
scrollbar_hover_color: Union[str, Tuple[str, str]] = "default_theme",
font: Union[tuple, CTkFont] = "default_theme",
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["color"]["entry"] if fg_color == "default_theme" else fg_color
self._border_color = ThemeManager.theme["color"]["frame_border"] if border_color == "default_theme" else border_color
self._text_color = ThemeManager.theme["color"]["text"] if text_color == "default_theme" else text_color
self._scrollbar_color = ThemeManager.theme["color"]["scrollbar_button"] if scrollbar_color == "default_theme" else scrollbar_color
self._scrollbar_hover_color = ThemeManager.theme["color"]["scrollbar_button_hover"] if scrollbar_hover_color == "default_theme" else scrollbar_hover_color
# shape
self._corner_radius = ThemeManager.theme["shape"]["frame_corner_radius"] if corner_radius == "default_theme" else corner_radius
self._border_width = ThemeManager.theme["shape"]["frame_border_width"] if border_width == "default_theme" else border_width
self._border_spacing = border_spacing
# font
self._font = CTkFont() if font == "default_theme" 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._current_width),
height=self._apply_widget_scaling(self._current_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),
bg=self._apply_appearance_mode(self._fg_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,
scrollbar_color=self._scrollbar_color,
scrollbar_hover_color=self._scrollbar_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,
scrollbar_color=self._scrollbar_color,
scrollbar_hover_color=self._scrollbar_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)
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 = True):
""" 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=False, 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):
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 is None:
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, scrollbar_color=self._scrollbar_color,
scrollbar_hover_color=self._scrollbar_hover_color)
self._y_scrollbar.configure(fg_color=self._bg_color, scrollbar_color=self._scrollbar_color,
scrollbar_hover_color=self._scrollbar_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, scrollbar_color=self._scrollbar_color,
scrollbar_hover_color=self._scrollbar_hover_color)
self._y_scrollbar.configure(fg_color=self._fg_color, scrollbar_color=self._scrollbar_color,
scrollbar_hover_color=self._scrollbar_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 = kwargs.pop("fg_color")
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 = kwargs.pop("border_color")
require_redraw = True
if "text_color" in kwargs:
self._text_color = kwargs.pop("text_color")
require_redraw = True
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=None, command=None, add=None):
""" called on the tkinter.Text """
# if sequence is <KeyRelease>, allow only to add the binding to keep the _textbox_modified_event() being called
if sequence == "<KeyRelease>":
return self._textbox.bind(sequence, command, add="+")
else:
return self._textbox.bind(sequence, command, add)
def unbind(self, sequence, funcid=None):
""" called on the tkinter.Text """
return self._textbox.unbind(sequence, funcid)
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):
self._check_if_scrollbars_needed()
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)

View File

@ -0,0 +1,80 @@
from tkinter.font import Font
import copy
from typing import List, Callable, Tuple
from ..theme.theme_manager 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: str = "default_theme",
size: int = "default_theme",
weight: str = "normal",
slant: str = "roman",
underline: bool = False,
overstrike: bool = False):
self._size_configure_callback_list: List[Callable] = []
self._family = family
self._size = ThemeManager.theme["text"]["size"] if size == "default_theme" else size
self._tuple_style_string = f"{weight} {slant} {'underline' if underline else ''} {'overstrike' if overstrike else ''}"
super().__init__(family=ThemeManager.theme["text"]["font"] if family == "default_theme" else family,
size=-abs(self._size),
weight=weight,
slant=slant,
underline=underline,
overstrike=overstrike)
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(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))
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
else:
return super().cget(attribute_name)
def copy(self) -> "CTkFont":
return copy.deepcopy(self)

View File

@ -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

View File

@ -0,0 +1,116 @@
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 (<width>, <height>) 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] = None):
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):
if "Image" not in dir() or "ImageTk" not in dir():
raise ImportError("CTkImage: Couldn't import PIL.Image or PIL.ImageTk. PIL must be installed.")
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[0] * 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: int) -> ImageTk.PhotoImage:
scaled_size = self._get_scaled_size(widget_scaling)
if appearance_mode == 0 and self._light_image is not None:
return self._get_scaled_light_photo_image(scaled_size)
elif appearance_mode == 0 and self._light_image is None:
return self._get_scaled_dark_photo_image(scaled_size)
elif appearance_mode == 1 and self._dark_image is not None:
return self._get_scaled_dark_photo_image(scaled_size)
elif appearance_mode == 1 and self._dark_image is None:
return self._get_scaled_light_photo_image(scaled_size)

View File

@ -0,0 +1,133 @@
from typing import Union, Tuple
from abc import ABC, abstractmethod
import copy
import re
try:
from typing import Literal
except ImportError:
from typing_extensions import Literal
from .scaling_tracker import ScalingTracker
from ..font.ctk_font import CTkFont
class CTkScalingBaseClass(ABC):
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 _apply_widget_scaling(self, value: Union[int, float, str]) -> Union[float, str]:
assert self._scaling_type == "widget"
if isinstance(value, (int, float)):
return value * self._widget_scaling
else:
return value
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 len(font) == 3:
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)
if "pady" in scaled_kwargs:
if isinstance(scaled_kwargs["pady"], (int, float, str)):
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, str)):
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"]])
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: ('<width>x<height>', '<width>', '<height>', '+-<x>+-<y>', '-<x>', '-<y>')
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 <x> and <y> 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 <width> and <height> 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 <x> and <y> 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 <width> and <height> in geometry_string
return f"+{x}+{y}"
else:
return f"{round(width / self._window_scaling)}x{round(height / self._window_scaling)}+{x}+{y}"
def _apply_window_scaling(self, value):
assert self._scaling_type == "window"
if isinstance(value, (int, float)):
return int(value * self._window_scaling)
else:
return value
@abstractmethod
def _set_scaling(self, new_widget_scaling, new_window_scaling):
return

View File

@ -0,0 +1,181 @@
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 = 600 # ms
loop_pause_after_new_scaling = 1000 # 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"):
from ctypes import windll
windll.shcore.SetProcessDpiAwareness(2)
# Microsoft Docs: https://docs.microsoft.com/en-us/windows/win32/api/shellscalingapi/ne-shellscalingapi-process_dpi_awareness
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)
cls.update_scaling_callbacks_for_window(window)
if sys.platform.startswith("win"):
window.after(200, lambda: 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

View File

@ -0,0 +1,27 @@
import sys
import os
import json
class ThemeManager:
theme = {} # contains all the theme data
built_in_themes = ["blue", "green", "dark-blue", "sweetkind"]
@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)
if sys.platform == "darwin":
cls.theme["text"] = cls.theme["text"]["macOS"]
elif sys.platform.startswith("win"):
cls.theme["text"] = cls.theme["text"]["Windows"]
else:
cls.theme["text"] = cls.theme["text"]["Linux"]