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