scaling_tracker.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. import tkinter
  2. import sys
  3. from typing import Callable
  4. class ScalingTracker:
  5. deactivate_automatic_dpi_awareness = False
  6. window_widgets_dict = {} # contains window objects as keys with list of widget callbacks as elements
  7. window_dpi_scaling_dict = {} # contains window objects as keys and corresponding scaling factors
  8. widget_scaling = 1 # user values which multiply to detected window scaling factor
  9. window_scaling = 1
  10. update_loop_running = False
  11. update_loop_interval = 100 # ms
  12. loop_pause_after_new_scaling = 1500 # ms
  13. @classmethod
  14. def get_widget_scaling(cls, widget) -> float:
  15. window_root = cls.get_window_root_of_widget(widget)
  16. return cls.window_dpi_scaling_dict[window_root] * cls.widget_scaling
  17. @classmethod
  18. def get_window_scaling(cls, window) -> float:
  19. window_root = cls.get_window_root_of_widget(window)
  20. return cls.window_dpi_scaling_dict[window_root] * cls.window_scaling
  21. @classmethod
  22. def set_widget_scaling(cls, widget_scaling_factor: float):
  23. cls.widget_scaling = max(widget_scaling_factor, 0.4)
  24. cls.update_scaling_callbacks_all()
  25. @classmethod
  26. def set_window_scaling(cls, window_scaling_factor: float):
  27. cls.window_scaling = max(window_scaling_factor, 0.4)
  28. cls.update_scaling_callbacks_all()
  29. @classmethod
  30. def get_window_root_of_widget(cls, widget):
  31. current_widget = widget
  32. while isinstance(current_widget, tkinter.Tk) is False and\
  33. isinstance(current_widget, tkinter.Toplevel) is False:
  34. current_widget = current_widget.master
  35. return current_widget
  36. @classmethod
  37. def update_scaling_callbacks_all(cls):
  38. for window, callback_list in cls.window_widgets_dict.items():
  39. for set_scaling_callback in callback_list:
  40. if not cls.deactivate_automatic_dpi_awareness:
  41. set_scaling_callback(cls.window_dpi_scaling_dict[window] * cls.widget_scaling,
  42. cls.window_dpi_scaling_dict[window] * cls.window_scaling)
  43. else:
  44. set_scaling_callback(cls.widget_scaling,
  45. cls.window_scaling)
  46. @classmethod
  47. def update_scaling_callbacks_for_window(cls, window):
  48. for set_scaling_callback in cls.window_widgets_dict[window]:
  49. if not cls.deactivate_automatic_dpi_awareness:
  50. set_scaling_callback(cls.window_dpi_scaling_dict[window] * cls.widget_scaling,
  51. cls.window_dpi_scaling_dict[window] * cls.window_scaling)
  52. else:
  53. set_scaling_callback(cls.widget_scaling,
  54. cls.window_scaling)
  55. @classmethod
  56. def add_widget(cls, widget_callback: Callable, widget):
  57. window_root = cls.get_window_root_of_widget(widget)
  58. if window_root not in cls.window_widgets_dict:
  59. cls.window_widgets_dict[window_root] = [widget_callback]
  60. else:
  61. cls.window_widgets_dict[window_root].append(widget_callback)
  62. if window_root not in cls.window_dpi_scaling_dict:
  63. cls.window_dpi_scaling_dict[window_root] = cls.get_window_dpi_scaling(window_root)
  64. if not cls.update_loop_running:
  65. window_root.after(100, cls.check_dpi_scaling)
  66. cls.update_loop_running = True
  67. @classmethod
  68. def remove_widget(cls, widget_callback, widget):
  69. window_root = cls.get_window_root_of_widget(widget)
  70. try:
  71. cls.window_widgets_dict[window_root].remove(widget_callback)
  72. except:
  73. pass
  74. @classmethod
  75. def remove_window(cls, window_callback, window):
  76. try:
  77. del cls.window_widgets_dict[window]
  78. except:
  79. pass
  80. @classmethod
  81. def add_window(cls, window_callback, window):
  82. if window not in cls.window_widgets_dict:
  83. cls.window_widgets_dict[window] = [window_callback]
  84. else:
  85. cls.window_widgets_dict[window].append(window_callback)
  86. if window not in cls.window_dpi_scaling_dict:
  87. cls.window_dpi_scaling_dict[window] = cls.get_window_dpi_scaling(window)
  88. @classmethod
  89. def activate_high_dpi_awareness(cls):
  90. """ make process DPI aware, customtkinter elements will get scaled automatically,
  91. only gets activated when CTk object is created """
  92. if not cls.deactivate_automatic_dpi_awareness:
  93. if sys.platform == "darwin":
  94. pass # high DPI scaling works automatically on macOS
  95. elif sys.platform.startswith("win"):
  96. import ctypes
  97. # Values for SetProcessDpiAwareness and SetProcessDpiAwarenessContext:
  98. # internal enum PROCESS_DPI_AWARENESS
  99. # {
  100. # Process_DPI_Unaware = 0,
  101. # Process_System_DPI_Aware = 1,
  102. # Process_Per_Monitor_DPI_Aware = 2
  103. # }
  104. #
  105. # internal enum DPI_AWARENESS_CONTEXT
  106. # {
  107. # DPI_AWARENESS_CONTEXT_UNAWARE = 16,
  108. # DPI_AWARENESS_CONTEXT_SYSTEM_AWARE = 17,
  109. # DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE = 18,
  110. # DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = 34
  111. # }
  112. # ctypes.windll.user32.SetProcessDpiAwarenessContext(34) # Non client area scaling at runtime (titlebar)
  113. # does not work with resizable(False, False), window starts growing on monitor with different scaling (weird tkinter bug...)
  114. # ctypes.windll.user32.EnableNonClientDpiScaling(hwnd) does not work for some reason (tested on Windows 11)
  115. # It's too bad, that these Windows API methods don't work properly with tkinter. But I tested days with multiple monitor setups,
  116. # and I don't think there is anything left to do. So this is the best option at the moment:
  117. ctypes.windll.shcore.SetProcessDpiAwareness(2) # Titlebar does not scale at runtime
  118. else:
  119. pass # DPI awareness on Linux not implemented
  120. @classmethod
  121. def get_window_dpi_scaling(cls, window) -> float:
  122. if not cls.deactivate_automatic_dpi_awareness:
  123. if sys.platform == "darwin":
  124. return 1 # scaling works automatically on macOS
  125. elif sys.platform.startswith("win"):
  126. from ctypes import windll, pointer, wintypes
  127. DPI100pc = 96 # DPI 96 is 100% scaling
  128. DPI_type = 0 # MDT_EFFECTIVE_DPI = 0, MDT_ANGULAR_DPI = 1, MDT_RAW_DPI = 2
  129. window_hwnd = wintypes.HWND(window.winfo_id())
  130. monitor_handle = windll.user32.MonitorFromWindow(window_hwnd, wintypes.DWORD(2)) # MONITOR_DEFAULTTONEAREST = 2
  131. x_dpi, y_dpi = wintypes.UINT(), wintypes.UINT()
  132. windll.shcore.GetDpiForMonitor(monitor_handle, DPI_type, pointer(x_dpi), pointer(y_dpi))
  133. return (x_dpi.value + y_dpi.value) / (2 * DPI100pc)
  134. else:
  135. return 1 # DPI awareness on Linux not implemented
  136. else:
  137. return 1
  138. @classmethod
  139. def check_dpi_scaling(cls):
  140. new_scaling_detected = False
  141. # check for every window if scaling value changed
  142. for window in cls.window_widgets_dict:
  143. if window.winfo_exists() and not window.state() == "iconic":
  144. current_dpi_scaling_value = cls.get_window_dpi_scaling(window)
  145. if current_dpi_scaling_value != cls.window_dpi_scaling_dict[window]:
  146. cls.window_dpi_scaling_dict[window] = current_dpi_scaling_value
  147. if sys.platform.startswith("win"):
  148. window.attributes("-alpha", 0.15)
  149. window.block_update_dimensions_event()
  150. cls.update_scaling_callbacks_for_window(window)
  151. window.unblock_update_dimensions_event()
  152. if sys.platform.startswith("win"):
  153. window.attributes("-alpha", 1)
  154. new_scaling_detected = True
  155. # find an existing tkinter object for the next call of .after()
  156. for app in cls.window_widgets_dict.keys():
  157. try:
  158. if new_scaling_detected:
  159. app.after(cls.loop_pause_after_new_scaling, cls.check_dpi_scaling)
  160. else:
  161. app.after(cls.update_loop_interval, cls.check_dpi_scaling)
  162. return
  163. except Exception:
  164. continue
  165. cls.update_loop_running = False