Umstellung auf Customtkinter.
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
import sys
|
||||
|
||||
from .scaling_base_class import CTkScalingBaseClass
|
||||
from .scaling_tracker import ScalingTracker
|
||||
|
||||
if sys.platform.startswith("win") and sys.getwindowsversion().build < 9000: # No automatic scaling on Windows < 8.1
|
||||
ScalingTracker.deactivate_automatic_dpi_awareness = True
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,159 @@
|
||||
from typing import Union, Tuple
|
||||
import copy
|
||||
import re
|
||||
try:
|
||||
from typing import Literal
|
||||
except ImportError:
|
||||
from typing_extensions import Literal
|
||||
|
||||
from .scaling_tracker import ScalingTracker
|
||||
from ..font import CTkFont
|
||||
|
||||
|
||||
class CTkScalingBaseClass:
|
||||
"""
|
||||
Super-class that manages the scaling values and callbacks.
|
||||
Works for widgets and windows, type must be set in init method with
|
||||
scaling_type attribute. Methods:
|
||||
|
||||
- _set_scaling() abstractmethod, gets called when scaling changes, must be overridden
|
||||
- destroy() must be called when sub-class is destroyed
|
||||
- _apply_widget_scaling()
|
||||
- _reverse_widget_scaling()
|
||||
- _apply_window_scaling()
|
||||
- _reverse_window_scaling()
|
||||
- _apply_font_scaling()
|
||||
- _apply_argument_scaling()
|
||||
- _apply_geometry_scaling()
|
||||
- _reverse_geometry_scaling()
|
||||
- _parse_geometry_string()
|
||||
|
||||
"""
|
||||
def __init__(self, scaling_type: Literal["widget", "window"] = "widget"):
|
||||
self.__scaling_type = scaling_type
|
||||
|
||||
if self.__scaling_type == "widget":
|
||||
ScalingTracker.add_widget(self._set_scaling, self) # add callback for automatic scaling changes
|
||||
self.__widget_scaling = ScalingTracker.get_widget_scaling(self)
|
||||
elif self.__scaling_type == "window":
|
||||
ScalingTracker.activate_high_dpi_awareness() # make process DPI aware
|
||||
ScalingTracker.add_window(self._set_scaling, self) # add callback for automatic scaling changes
|
||||
self.__window_scaling = ScalingTracker.get_window_scaling(self)
|
||||
|
||||
def destroy(self):
|
||||
if self.__scaling_type == "widget":
|
||||
ScalingTracker.remove_widget(self._set_scaling, self)
|
||||
elif self.__scaling_type == "window":
|
||||
ScalingTracker.remove_window(self._set_scaling, self)
|
||||
|
||||
def _set_scaling(self, new_widget_scaling, new_window_scaling):
|
||||
""" can be overridden, but super method must be called at the beginning """
|
||||
self.__widget_scaling = new_widget_scaling
|
||||
self.__window_scaling = new_window_scaling
|
||||
|
||||
def _get_widget_scaling(self) -> float:
|
||||
return self.__widget_scaling
|
||||
|
||||
def _get_window_scaling(self) -> float:
|
||||
return self.__window_scaling
|
||||
|
||||
def _apply_widget_scaling(self, value: Union[int, float]) -> Union[float]:
|
||||
assert self.__scaling_type == "widget"
|
||||
return value * self.__widget_scaling
|
||||
|
||||
def _reverse_widget_scaling(self, value: Union[int, float]) -> Union[float]:
|
||||
assert self.__scaling_type == "widget"
|
||||
return value / self.__widget_scaling
|
||||
|
||||
def _apply_window_scaling(self, value: Union[int, float]) -> int:
|
||||
assert self.__scaling_type == "window"
|
||||
return int(value * self.__window_scaling)
|
||||
|
||||
def _reverse_window_scaling(self, scaled_value: Union[int, float]) -> int:
|
||||
assert self.__scaling_type == "window"
|
||||
return int(scaled_value / self.__window_scaling)
|
||||
|
||||
def _apply_font_scaling(self, font: Union[Tuple, CTkFont]) -> tuple:
|
||||
""" Takes CTkFont object and returns tuple font with scaled size, has to be called again for every change of font object """
|
||||
assert self.__scaling_type == "widget"
|
||||
|
||||
if type(font) == tuple:
|
||||
if len(font) == 1:
|
||||
return font
|
||||
elif len(font) == 2:
|
||||
return font[0], -abs(round(font[1] * self.__widget_scaling))
|
||||
elif 3 <= len(font) <= 6:
|
||||
return font[0], -abs(round(font[1] * self.__widget_scaling)), font[2:]
|
||||
else:
|
||||
raise ValueError(f"Can not scale font {font}. font needs to be tuple of len 1, 2 or 3")
|
||||
|
||||
elif isinstance(font, CTkFont):
|
||||
return font.create_scaled_tuple(self.__widget_scaling)
|
||||
else:
|
||||
raise ValueError(f"Can not scale font '{font}' of type {type(font)}. font needs to be tuple or instance of CTkFont")
|
||||
|
||||
def _apply_argument_scaling(self, kwargs: dict) -> dict:
|
||||
assert self.__scaling_type == "widget"
|
||||
|
||||
scaled_kwargs = copy.copy(kwargs)
|
||||
|
||||
# scale padding values
|
||||
if "pady" in scaled_kwargs:
|
||||
if isinstance(scaled_kwargs["pady"], (int, float)):
|
||||
scaled_kwargs["pady"] = self._apply_widget_scaling(scaled_kwargs["pady"])
|
||||
elif isinstance(scaled_kwargs["pady"], tuple):
|
||||
scaled_kwargs["pady"] = tuple([self._apply_widget_scaling(v) for v in scaled_kwargs["pady"]])
|
||||
if "padx" in kwargs:
|
||||
if isinstance(scaled_kwargs["padx"], (int, float)):
|
||||
scaled_kwargs["padx"] = self._apply_widget_scaling(scaled_kwargs["padx"])
|
||||
elif isinstance(scaled_kwargs["padx"], tuple):
|
||||
scaled_kwargs["padx"] = tuple([self._apply_widget_scaling(v) for v in scaled_kwargs["padx"]])
|
||||
|
||||
# scaled x, y values for place geometry manager
|
||||
if "x" in scaled_kwargs:
|
||||
scaled_kwargs["x"] = self._apply_widget_scaling(scaled_kwargs["x"])
|
||||
if "y" in scaled_kwargs:
|
||||
scaled_kwargs["y"] = self._apply_widget_scaling(scaled_kwargs["y"])
|
||||
|
||||
return scaled_kwargs
|
||||
|
||||
@staticmethod
|
||||
def _parse_geometry_string(geometry_string: str) -> tuple:
|
||||
# index: 1 2 3 4 5 6
|
||||
# regex group structure: ('<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}"
|
||||
@@ -0,0 +1,206 @@
|
||||
import tkinter
|
||||
import sys
|
||||
from typing import Callable
|
||||
|
||||
|
||||
class ScalingTracker:
|
||||
deactivate_automatic_dpi_awareness = False
|
||||
|
||||
window_widgets_dict = {} # contains window objects as keys with list of widget callbacks as elements
|
||||
window_dpi_scaling_dict = {} # contains window objects as keys and corresponding scaling factors
|
||||
|
||||
widget_scaling = 1 # user values which multiply to detected window scaling factor
|
||||
window_scaling = 1
|
||||
|
||||
update_loop_running = False
|
||||
update_loop_interval = 100 # ms
|
||||
loop_pause_after_new_scaling = 1500 # ms
|
||||
|
||||
@classmethod
|
||||
def get_widget_scaling(cls, widget) -> float:
|
||||
window_root = cls.get_window_root_of_widget(widget)
|
||||
return cls.window_dpi_scaling_dict[window_root] * cls.widget_scaling
|
||||
|
||||
@classmethod
|
||||
def get_window_scaling(cls, window) -> float:
|
||||
window_root = cls.get_window_root_of_widget(window)
|
||||
return cls.window_dpi_scaling_dict[window_root] * cls.window_scaling
|
||||
|
||||
@classmethod
|
||||
def set_widget_scaling(cls, widget_scaling_factor: float):
|
||||
cls.widget_scaling = max(widget_scaling_factor, 0.4)
|
||||
cls.update_scaling_callbacks_all()
|
||||
|
||||
@classmethod
|
||||
def set_window_scaling(cls, window_scaling_factor: float):
|
||||
cls.window_scaling = max(window_scaling_factor, 0.4)
|
||||
cls.update_scaling_callbacks_all()
|
||||
|
||||
@classmethod
|
||||
def get_window_root_of_widget(cls, widget):
|
||||
current_widget = widget
|
||||
|
||||
while isinstance(current_widget, tkinter.Tk) is False and\
|
||||
isinstance(current_widget, tkinter.Toplevel) is False:
|
||||
current_widget = current_widget.master
|
||||
|
||||
return current_widget
|
||||
|
||||
@classmethod
|
||||
def update_scaling_callbacks_all(cls):
|
||||
for window, callback_list in cls.window_widgets_dict.items():
|
||||
for set_scaling_callback in callback_list:
|
||||
if not cls.deactivate_automatic_dpi_awareness:
|
||||
set_scaling_callback(cls.window_dpi_scaling_dict[window] * cls.widget_scaling,
|
||||
cls.window_dpi_scaling_dict[window] * cls.window_scaling)
|
||||
else:
|
||||
set_scaling_callback(cls.widget_scaling,
|
||||
cls.window_scaling)
|
||||
|
||||
@classmethod
|
||||
def update_scaling_callbacks_for_window(cls, window):
|
||||
for set_scaling_callback in cls.window_widgets_dict[window]:
|
||||
if not cls.deactivate_automatic_dpi_awareness:
|
||||
set_scaling_callback(cls.window_dpi_scaling_dict[window] * cls.widget_scaling,
|
||||
cls.window_dpi_scaling_dict[window] * cls.window_scaling)
|
||||
else:
|
||||
set_scaling_callback(cls.widget_scaling,
|
||||
cls.window_scaling)
|
||||
|
||||
@classmethod
|
||||
def add_widget(cls, widget_callback: Callable, widget):
|
||||
window_root = cls.get_window_root_of_widget(widget)
|
||||
|
||||
if window_root not in cls.window_widgets_dict:
|
||||
cls.window_widgets_dict[window_root] = [widget_callback]
|
||||
else:
|
||||
cls.window_widgets_dict[window_root].append(widget_callback)
|
||||
|
||||
if window_root not in cls.window_dpi_scaling_dict:
|
||||
cls.window_dpi_scaling_dict[window_root] = cls.get_window_dpi_scaling(window_root)
|
||||
|
||||
if not cls.update_loop_running:
|
||||
window_root.after(100, cls.check_dpi_scaling)
|
||||
cls.update_loop_running = True
|
||||
|
||||
@classmethod
|
||||
def remove_widget(cls, widget_callback, widget):
|
||||
window_root = cls.get_window_root_of_widget(widget)
|
||||
try:
|
||||
cls.window_widgets_dict[window_root].remove(widget_callback)
|
||||
except:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def remove_window(cls, window_callback, window):
|
||||
try:
|
||||
del cls.window_widgets_dict[window]
|
||||
except:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def add_window(cls, window_callback, window):
|
||||
if window not in cls.window_widgets_dict:
|
||||
cls.window_widgets_dict[window] = [window_callback]
|
||||
else:
|
||||
cls.window_widgets_dict[window].append(window_callback)
|
||||
|
||||
if window not in cls.window_dpi_scaling_dict:
|
||||
cls.window_dpi_scaling_dict[window] = cls.get_window_dpi_scaling(window)
|
||||
|
||||
@classmethod
|
||||
def activate_high_dpi_awareness(cls):
|
||||
""" make process DPI aware, customtkinter elements will get scaled automatically,
|
||||
only gets activated when CTk object is created """
|
||||
|
||||
if not cls.deactivate_automatic_dpi_awareness:
|
||||
if sys.platform == "darwin":
|
||||
pass # high DPI scaling works automatically on macOS
|
||||
|
||||
elif sys.platform.startswith("win"):
|
||||
import ctypes
|
||||
|
||||
# Values for SetProcessDpiAwareness and SetProcessDpiAwarenessContext:
|
||||
# internal enum PROCESS_DPI_AWARENESS
|
||||
# {
|
||||
# Process_DPI_Unaware = 0,
|
||||
# Process_System_DPI_Aware = 1,
|
||||
# Process_Per_Monitor_DPI_Aware = 2
|
||||
# }
|
||||
#
|
||||
# internal enum DPI_AWARENESS_CONTEXT
|
||||
# {
|
||||
# DPI_AWARENESS_CONTEXT_UNAWARE = 16,
|
||||
# DPI_AWARENESS_CONTEXT_SYSTEM_AWARE = 17,
|
||||
# DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE = 18,
|
||||
# DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = 34
|
||||
# }
|
||||
|
||||
# ctypes.windll.user32.SetProcessDpiAwarenessContext(34) # Non client area scaling at runtime (titlebar)
|
||||
# does not work with resizable(False, False), window starts growing on monitor with different scaling (weird tkinter bug...)
|
||||
# ctypes.windll.user32.EnableNonClientDpiScaling(hwnd) does not work for some reason (tested on Windows 11)
|
||||
|
||||
# It's too bad, that these Windows API methods don't work properly with tkinter. But I tested days with multiple monitor setups,
|
||||
# and I don't think there is anything left to do. So this is the best option at the moment:
|
||||
|
||||
ctypes.windll.shcore.SetProcessDpiAwareness(2) # Titlebar does not scale at runtime
|
||||
else:
|
||||
pass # DPI awareness on Linux not implemented
|
||||
|
||||
@classmethod
|
||||
def get_window_dpi_scaling(cls, window) -> float:
|
||||
if not cls.deactivate_automatic_dpi_awareness:
|
||||
if sys.platform == "darwin":
|
||||
return 1 # scaling works automatically on macOS
|
||||
|
||||
elif sys.platform.startswith("win"):
|
||||
from ctypes import windll, pointer, wintypes
|
||||
|
||||
DPI100pc = 96 # DPI 96 is 100% scaling
|
||||
DPI_type = 0 # MDT_EFFECTIVE_DPI = 0, MDT_ANGULAR_DPI = 1, MDT_RAW_DPI = 2
|
||||
window_hwnd = wintypes.HWND(window.winfo_id())
|
||||
monitor_handle = windll.user32.MonitorFromWindow(window_hwnd, wintypes.DWORD(2)) # MONITOR_DEFAULTTONEAREST = 2
|
||||
x_dpi, y_dpi = wintypes.UINT(), wintypes.UINT()
|
||||
windll.shcore.GetDpiForMonitor(monitor_handle, DPI_type, pointer(x_dpi), pointer(y_dpi))
|
||||
return (x_dpi.value + y_dpi.value) / (2 * DPI100pc)
|
||||
|
||||
else:
|
||||
return 1 # DPI awareness on Linux not implemented
|
||||
else:
|
||||
return 1
|
||||
|
||||
@classmethod
|
||||
def check_dpi_scaling(cls):
|
||||
new_scaling_detected = False
|
||||
|
||||
# check for every window if scaling value changed
|
||||
for window in cls.window_widgets_dict:
|
||||
if window.winfo_exists() and not window.state() == "iconic":
|
||||
current_dpi_scaling_value = cls.get_window_dpi_scaling(window)
|
||||
if current_dpi_scaling_value != cls.window_dpi_scaling_dict[window]:
|
||||
cls.window_dpi_scaling_dict[window] = current_dpi_scaling_value
|
||||
|
||||
if sys.platform.startswith("win"):
|
||||
window.attributes("-alpha", 0.15)
|
||||
|
||||
window.block_update_dimensions_event()
|
||||
cls.update_scaling_callbacks_for_window(window)
|
||||
window.unblock_update_dimensions_event()
|
||||
|
||||
if sys.platform.startswith("win"):
|
||||
window.attributes("-alpha", 1)
|
||||
|
||||
new_scaling_detected = True
|
||||
|
||||
# find an existing tkinter object for the next call of .after()
|
||||
for app in cls.window_widgets_dict.keys():
|
||||
try:
|
||||
if new_scaling_detected:
|
||||
app.after(cls.loop_pause_after_new_scaling, cls.check_dpi_scaling)
|
||||
else:
|
||||
app.after(cls.update_loop_interval, cls.check_dpi_scaling)
|
||||
return
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
cls.update_loop_running = False
|
||||
Reference in New Issue
Block a user