ctk_tk.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. import tkinter
  2. import sys
  3. import os
  4. import platform
  5. import ctypes
  6. from typing import Union, Tuple, Optional
  7. from packaging import version
  8. from .widgets.theme import ThemeManager
  9. from .widgets.scaling import CTkScalingBaseClass
  10. from .widgets.appearance_mode import CTkAppearanceModeBaseClass
  11. from customtkinter.windows.widgets.utility.utility_functions import pop_from_dict_by_set, check_kwargs_empty
  12. CTK_PARENT_CLASS = tkinter.Tk
  13. class CTk(CTK_PARENT_CLASS, CTkAppearanceModeBaseClass, CTkScalingBaseClass):
  14. """
  15. Main app window with dark titlebar on Windows and macOS.
  16. For detailed information check out the documentation.
  17. """
  18. _valid_tk_constructor_arguments: set = {"screenName", "baseName", "className", "useTk", "sync", "use"}
  19. _valid_tk_configure_arguments: set = {'bd', 'borderwidth', 'class', 'menu', 'relief', 'screen',
  20. 'use', 'container', 'cursor', 'height',
  21. 'highlightthickness', 'padx', 'pady', 'takefocus', 'visual', 'width'}
  22. _deactivate_macos_window_header_manipulation: bool = False
  23. _deactivate_windows_window_header_manipulation: bool = False
  24. def __init__(self,
  25. fg_color: Optional[Union[str, Tuple[str, str]]] = None,
  26. **kwargs):
  27. self._enable_macos_dark_title_bar()
  28. # call init methods of super classes
  29. CTK_PARENT_CLASS.__init__(self, **pop_from_dict_by_set(kwargs, self._valid_tk_constructor_arguments))
  30. CTkAppearanceModeBaseClass.__init__(self)
  31. CTkScalingBaseClass.__init__(self, scaling_type="window")
  32. check_kwargs_empty(kwargs, raise_error=True)
  33. self._current_width = 600 # initial window size, independent of scaling
  34. self._current_height = 500
  35. self._min_width: int = 0
  36. self._min_height: int = 0
  37. self._max_width: int = 1_000_000
  38. self._max_height: int = 1_000_000
  39. self._last_resizable_args: Union[Tuple[list, dict], None] = None # (args, kwargs)
  40. self._fg_color = ThemeManager.theme["CTk"]["fg_color"] if fg_color is None else self._check_color_type(fg_color)
  41. # set bg of tkinter.Tk
  42. super().configure(bg=self._apply_appearance_mode(self._fg_color))
  43. # set title
  44. self.title("CTk")
  45. # indicator variables
  46. self._iconbitmap_method_called = False # indicates if wm_iconbitmap method got called
  47. self._state_before_windows_set_titlebar_color = None
  48. self._window_exists = False # indicates if the window is already shown through update() or mainloop() after init
  49. self._withdraw_called_before_window_exists = False # indicates if withdraw() was called before window is first shown through update() or mainloop()
  50. self._iconify_called_before_window_exists = False # indicates if iconify() was called before window is first shown through update() or mainloop()
  51. self._block_update_dimensions_event = False
  52. # save focus before calling withdraw
  53. self.focused_widget_before_widthdraw = None
  54. # set CustomTkinter titlebar icon (Windows only)
  55. if sys.platform.startswith("win"):
  56. self.after(200, self._windows_set_titlebar_icon)
  57. # set titlebar color (Windows only)
  58. if sys.platform.startswith("win"):
  59. self._windows_set_titlebar_color(self._get_appearance_mode())
  60. self.bind('<Configure>', self._update_dimensions_event)
  61. self.bind('<FocusIn>', self._focus_in_event)
  62. def destroy(self):
  63. self._disable_macos_dark_title_bar()
  64. # call destroy methods of super classes
  65. tkinter.Tk.destroy(self)
  66. CTkAppearanceModeBaseClass.destroy(self)
  67. CTkScalingBaseClass.destroy(self)
  68. def _focus_in_event(self, event):
  69. # sometimes window looses jumps back on macOS if window is selected from Mission Control, so has to be lifted again
  70. if sys.platform == "darwin":
  71. self.lift()
  72. def _update_dimensions_event(self, event=None):
  73. if not self._block_update_dimensions_event:
  74. detected_width = super().winfo_width() # detect current window size
  75. detected_height = super().winfo_height()
  76. # detected_width = event.width
  77. # detected_height = event.height
  78. if self._current_width != self._reverse_window_scaling(detected_width) or self._current_height != self._reverse_window_scaling(detected_height):
  79. self._current_width = self._reverse_window_scaling(detected_width) # adjust current size according to new size given by event
  80. self._current_height = self._reverse_window_scaling(detected_height) # _current_width and _current_height are independent of the scale
  81. def _set_scaling(self, new_widget_scaling, new_window_scaling):
  82. super()._set_scaling(new_widget_scaling, new_window_scaling)
  83. # Force new dimensions on window by using min, max, and geometry. Without min, max it won't work.
  84. super().minsize(self._apply_window_scaling(self._current_width), self._apply_window_scaling(self._current_height))
  85. super().maxsize(self._apply_window_scaling(self._current_width), self._apply_window_scaling(self._current_height))
  86. super().geometry(f"{self._apply_window_scaling(self._current_width)}x{self._apply_window_scaling(self._current_height)}")
  87. # set new scaled min and max with delay (delay prevents weird bug where window dimensions snap to unscaled dimensions when mouse releases window)
  88. self.after(1000, self._set_scaled_min_max) # Why 1000ms delay? Experience! (Everything tested on Windows 11)
  89. def block_update_dimensions_event(self):
  90. self._block_update_dimensions_event = False
  91. def unblock_update_dimensions_event(self):
  92. self._block_update_dimensions_event = False
  93. def _set_scaled_min_max(self):
  94. if self._min_width is not None or self._min_height is not None:
  95. super().minsize(self._apply_window_scaling(self._min_width), self._apply_window_scaling(self._min_height))
  96. if self._max_width is not None or self._max_height is not None:
  97. super().maxsize(self._apply_window_scaling(self._max_width), self._apply_window_scaling(self._max_height))
  98. def withdraw(self):
  99. if self._window_exists is False:
  100. self._withdraw_called_before_window_exists = True
  101. super().withdraw()
  102. def iconify(self):
  103. if self._window_exists is False:
  104. self._iconify_called_before_window_exists = True
  105. super().iconify()
  106. def update(self):
  107. if self._window_exists is False:
  108. if sys.platform.startswith("win"):
  109. if not self._withdraw_called_before_window_exists and not self._iconify_called_before_window_exists:
  110. # print("window dont exists -> deiconify in update")
  111. self.deiconify()
  112. self._window_exists = True
  113. super().update()
  114. def mainloop(self, *args, **kwargs):
  115. if not self._window_exists:
  116. if sys.platform.startswith("win"):
  117. self._windows_set_titlebar_color(self._get_appearance_mode())
  118. if not self._withdraw_called_before_window_exists and not self._iconify_called_before_window_exists:
  119. # print("window dont exists -> deiconify in mainloop")
  120. self.deiconify()
  121. self._window_exists = True
  122. super().mainloop(*args, **kwargs)
  123. def resizable(self, width: bool = None, height: bool = None):
  124. current_resizable_values = super().resizable(width, height)
  125. self._last_resizable_args = ([], {"width": width, "height": height})
  126. if sys.platform.startswith("win"):
  127. self._windows_set_titlebar_color(self._get_appearance_mode())
  128. return current_resizable_values
  129. def minsize(self, width: int = None, height: int = None):
  130. self._min_width = width
  131. self._min_height = height
  132. if self._current_width < width:
  133. self._current_width = width
  134. if self._current_height < height:
  135. self._current_height = height
  136. super().minsize(self._apply_window_scaling(self._min_width), self._apply_window_scaling(self._min_height))
  137. def maxsize(self, width: int = None, height: int = None):
  138. self._max_width = width
  139. self._max_height = height
  140. if self._current_width > width:
  141. self._current_width = width
  142. if self._current_height > height:
  143. self._current_height = height
  144. super().maxsize(self._apply_window_scaling(self._max_width), self._apply_window_scaling(self._max_height))
  145. def geometry(self, geometry_string: str = None):
  146. if geometry_string is not None:
  147. super().geometry(self._apply_geometry_scaling(geometry_string))
  148. # update width and height attributes
  149. width, height, x, y = self._parse_geometry_string(geometry_string)
  150. if width is not None and height is not None:
  151. self._current_width = max(self._min_width, min(width, self._max_width)) # bound value between min and max
  152. self._current_height = max(self._min_height, min(height, self._max_height))
  153. else:
  154. return self._reverse_geometry_scaling(super().geometry())
  155. def configure(self, **kwargs):
  156. if "fg_color" in kwargs:
  157. self._fg_color = self._check_color_type(kwargs.pop("fg_color"))
  158. super().configure(bg=self._apply_appearance_mode(self._fg_color))
  159. for child in self.winfo_children():
  160. try:
  161. child.configure(bg_color=self._fg_color)
  162. except Exception:
  163. pass
  164. super().configure(**pop_from_dict_by_set(kwargs, self._valid_tk_configure_arguments))
  165. check_kwargs_empty(kwargs)
  166. def cget(self, attribute_name: str) -> any:
  167. if attribute_name == "fg_color":
  168. return self._fg_color
  169. else:
  170. return super().cget(attribute_name)
  171. def wm_iconbitmap(self, bitmap=None, default=None):
  172. self._iconbitmap_method_called = True
  173. super().wm_iconbitmap(bitmap, default)
  174. def iconbitmap(self, bitmap=None, default=None):
  175. self._iconbitmap_method_called = True
  176. super().wm_iconbitmap(bitmap, default)
  177. def _windows_set_titlebar_icon(self):
  178. try:
  179. # if not the user already called iconbitmap method, set icon
  180. if not self._iconbitmap_method_called:
  181. customtkinter_directory = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  182. self.iconbitmap(os.path.join(customtkinter_directory, "assets", "icons", "CustomTkinter_icon_Windows.ico"))
  183. except Exception:
  184. pass
  185. @classmethod
  186. def _enable_macos_dark_title_bar(cls):
  187. if sys.platform == "darwin" and not cls._deactivate_macos_window_header_manipulation: # macOS
  188. if version.parse(platform.python_version()) < version.parse("3.10"):
  189. if version.parse(tkinter.Tcl().call("info", "patchlevel")) >= version.parse("8.6.9"): # Tcl/Tk >= 8.6.9
  190. os.system("defaults write -g NSRequiresAquaSystemAppearance -bool No")
  191. # This command allows dark-mode for all programs
  192. @classmethod
  193. def _disable_macos_dark_title_bar(cls):
  194. if sys.platform == "darwin" and not cls._deactivate_macos_window_header_manipulation: # macOS
  195. if version.parse(platform.python_version()) < version.parse("3.10"):
  196. if version.parse(tkinter.Tcl().call("info", "patchlevel")) >= version.parse("8.6.9"): # Tcl/Tk >= 8.6.9
  197. os.system("defaults delete -g NSRequiresAquaSystemAppearance")
  198. # This command reverts the dark-mode setting for all programs.
  199. def _windows_set_titlebar_color(self, color_mode: str):
  200. """
  201. Set the titlebar color of the window to light or dark theme on Microsoft Windows.
  202. Credits for this function:
  203. https://stackoverflow.com/questions/23836000/can-i-change-the-title-bar-in-tkinter/70724666#70724666
  204. MORE INFO:
  205. https://docs.microsoft.com/en-us/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute
  206. """
  207. if sys.platform.startswith("win") and not self._deactivate_windows_window_header_manipulation:
  208. if self._window_exists:
  209. self._state_before_windows_set_titlebar_color = self.state()
  210. # print("window_exists -> state_before_windows_set_titlebar_color: ", self.state_before_windows_set_titlebar_color)
  211. if self._state_before_windows_set_titlebar_color != "iconic" or self._state_before_windows_set_titlebar_color != "withdrawn":
  212. self.focused_widget_before_widthdraw = self.focus_get()
  213. super().withdraw() # hide window so that it can be redrawn after the titlebar change so that the color change is visible
  214. else:
  215. # print("window dont exists -> withdraw and update")
  216. self.focused_widget_before_widthdraw = self.focus_get()
  217. super().withdraw()
  218. super().update()
  219. if color_mode.lower() == "dark":
  220. value = 1
  221. elif color_mode.lower() == "light":
  222. value = 0
  223. else:
  224. return
  225. try:
  226. hwnd = ctypes.windll.user32.GetParent(self.winfo_id())
  227. DWMWA_USE_IMMERSIVE_DARK_MODE = 20
  228. DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1 = 19
  229. # try with DWMWA_USE_IMMERSIVE_DARK_MODE
  230. if ctypes.windll.dwmapi.DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE,
  231. ctypes.byref(ctypes.c_int(value)),
  232. ctypes.sizeof(ctypes.c_int(value))) != 0:
  233. # try with DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20h1
  234. ctypes.windll.dwmapi.DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1,
  235. ctypes.byref(ctypes.c_int(value)),
  236. ctypes.sizeof(ctypes.c_int(value)))
  237. except Exception as err:
  238. print(err)
  239. if self._window_exists or True:
  240. # print("window_exists -> return to original state: ", self.state_before_windows_set_titlebar_color)
  241. if self._state_before_windows_set_titlebar_color == "normal":
  242. self.deiconify()
  243. elif self._state_before_windows_set_titlebar_color == "iconic":
  244. self.iconify()
  245. elif self._state_before_windows_set_titlebar_color == "zoomed":
  246. self.state("zoomed")
  247. else:
  248. self.state(self._state_before_windows_set_titlebar_color) # other states
  249. else:
  250. pass # wait for update or mainloop to be called
  251. if self.focused_widget_before_widthdraw is not None:
  252. self.after(1, self.focused_widget_before_widthdraw.focus)
  253. self.focused_widget_before_widthdraw = None
  254. def _set_appearance_mode(self, mode_string: str):
  255. super()._set_appearance_mode(mode_string)
  256. if sys.platform.startswith("win"):
  257. self._windows_set_titlebar_color(mode_string)
  258. super().configure(bg=self._apply_appearance_mode(self._fg_color))