PyWebWinUI3 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. pywebwinui3/__init__.py +3 -0
  2. pywebwinui3/core.py +176 -0
  3. pywebwinui3/event.py +147 -0
  4. pywebwinui3/qt.py +887 -0
  5. pywebwinui3/type.py +362 -0
  6. pywebwinui3/util.py +194 -0
  7. pywebwinui3/web/Pretendard/PretendardVariable.subset.0.woff2 +0 -0
  8. pywebwinui3/web/Pretendard/PretendardVariable.subset.1.woff2 +0 -0
  9. pywebwinui3/web/Pretendard/PretendardVariable.subset.10.woff2 +0 -0
  10. pywebwinui3/web/Pretendard/PretendardVariable.subset.11.woff2 +0 -0
  11. pywebwinui3/web/Pretendard/PretendardVariable.subset.12.woff2 +0 -0
  12. pywebwinui3/web/Pretendard/PretendardVariable.subset.13.woff2 +0 -0
  13. pywebwinui3/web/Pretendard/PretendardVariable.subset.14.woff2 +0 -0
  14. pywebwinui3/web/Pretendard/PretendardVariable.subset.15.woff2 +0 -0
  15. pywebwinui3/web/Pretendard/PretendardVariable.subset.16.woff2 +0 -0
  16. pywebwinui3/web/Pretendard/PretendardVariable.subset.17.woff2 +0 -0
  17. pywebwinui3/web/Pretendard/PretendardVariable.subset.18.woff2 +0 -0
  18. pywebwinui3/web/Pretendard/PretendardVariable.subset.19.woff2 +0 -0
  19. pywebwinui3/web/Pretendard/PretendardVariable.subset.2.woff2 +0 -0
  20. pywebwinui3/web/Pretendard/PretendardVariable.subset.20.woff2 +0 -0
  21. pywebwinui3/web/Pretendard/PretendardVariable.subset.21.woff2 +0 -0
  22. pywebwinui3/web/Pretendard/PretendardVariable.subset.22.woff2 +0 -0
  23. pywebwinui3/web/Pretendard/PretendardVariable.subset.23.woff2 +0 -0
  24. pywebwinui3/web/Pretendard/PretendardVariable.subset.24.woff2 +0 -0
  25. pywebwinui3/web/Pretendard/PretendardVariable.subset.25.woff2 +0 -0
  26. pywebwinui3/web/Pretendard/PretendardVariable.subset.26.woff2 +0 -0
  27. pywebwinui3/web/Pretendard/PretendardVariable.subset.27.woff2 +0 -0
  28. pywebwinui3/web/Pretendard/PretendardVariable.subset.28.woff2 +0 -0
  29. pywebwinui3/web/Pretendard/PretendardVariable.subset.29.woff2 +0 -0
  30. pywebwinui3/web/Pretendard/PretendardVariable.subset.3.woff2 +0 -0
  31. pywebwinui3/web/Pretendard/PretendardVariable.subset.30.woff2 +0 -0
  32. pywebwinui3/web/Pretendard/PretendardVariable.subset.31.woff2 +0 -0
  33. pywebwinui3/web/Pretendard/PretendardVariable.subset.32.woff2 +0 -0
  34. pywebwinui3/web/Pretendard/PretendardVariable.subset.33.woff2 +0 -0
  35. pywebwinui3/web/Pretendard/PretendardVariable.subset.34.woff2 +0 -0
  36. pywebwinui3/web/Pretendard/PretendardVariable.subset.35.woff2 +0 -0
  37. pywebwinui3/web/Pretendard/PretendardVariable.subset.36.woff2 +0 -0
  38. pywebwinui3/web/Pretendard/PretendardVariable.subset.37.woff2 +0 -0
  39. pywebwinui3/web/Pretendard/PretendardVariable.subset.38.woff2 +0 -0
  40. pywebwinui3/web/Pretendard/PretendardVariable.subset.39.woff2 +0 -0
  41. pywebwinui3/web/Pretendard/PretendardVariable.subset.4.woff2 +0 -0
  42. pywebwinui3/web/Pretendard/PretendardVariable.subset.40.woff2 +0 -0
  43. pywebwinui3/web/Pretendard/PretendardVariable.subset.41.woff2 +0 -0
  44. pywebwinui3/web/Pretendard/PretendardVariable.subset.42.woff2 +0 -0
  45. pywebwinui3/web/Pretendard/PretendardVariable.subset.43.woff2 +0 -0
  46. pywebwinui3/web/Pretendard/PretendardVariable.subset.44.woff2 +0 -0
  47. pywebwinui3/web/Pretendard/PretendardVariable.subset.45.woff2 +0 -0
  48. pywebwinui3/web/Pretendard/PretendardVariable.subset.46.woff2 +0 -0
  49. pywebwinui3/web/Pretendard/PretendardVariable.subset.47.woff2 +0 -0
  50. pywebwinui3/web/Pretendard/PretendardVariable.subset.48.woff2 +0 -0
  51. pywebwinui3/web/Pretendard/PretendardVariable.subset.49.woff2 +0 -0
  52. pywebwinui3/web/Pretendard/PretendardVariable.subset.5.woff2 +0 -0
  53. pywebwinui3/web/Pretendard/PretendardVariable.subset.50.woff2 +0 -0
  54. pywebwinui3/web/Pretendard/PretendardVariable.subset.51.woff2 +0 -0
  55. pywebwinui3/web/Pretendard/PretendardVariable.subset.52.woff2 +0 -0
  56. pywebwinui3/web/Pretendard/PretendardVariable.subset.53.woff2 +0 -0
  57. pywebwinui3/web/Pretendard/PretendardVariable.subset.54.woff2 +0 -0
  58. pywebwinui3/web/Pretendard/PretendardVariable.subset.55.woff2 +0 -0
  59. pywebwinui3/web/Pretendard/PretendardVariable.subset.56.woff2 +0 -0
  60. pywebwinui3/web/Pretendard/PretendardVariable.subset.57.woff2 +0 -0
  61. pywebwinui3/web/Pretendard/PretendardVariable.subset.58.woff2 +0 -0
  62. pywebwinui3/web/Pretendard/PretendardVariable.subset.59.woff2 +0 -0
  63. pywebwinui3/web/Pretendard/PretendardVariable.subset.6.woff2 +0 -0
  64. pywebwinui3/web/Pretendard/PretendardVariable.subset.60.woff2 +0 -0
  65. pywebwinui3/web/Pretendard/PretendardVariable.subset.61.woff2 +0 -0
  66. pywebwinui3/web/Pretendard/PretendardVariable.subset.62.woff2 +0 -0
  67. pywebwinui3/web/Pretendard/PretendardVariable.subset.63.woff2 +0 -0
  68. pywebwinui3/web/Pretendard/PretendardVariable.subset.64.woff2 +0 -0
  69. pywebwinui3/web/Pretendard/PretendardVariable.subset.65.woff2 +0 -0
  70. pywebwinui3/web/Pretendard/PretendardVariable.subset.66.woff2 +0 -0
  71. pywebwinui3/web/Pretendard/PretendardVariable.subset.67.woff2 +0 -0
  72. pywebwinui3/web/Pretendard/PretendardVariable.subset.68.woff2 +0 -0
  73. pywebwinui3/web/Pretendard/PretendardVariable.subset.69.woff2 +0 -0
  74. pywebwinui3/web/Pretendard/PretendardVariable.subset.7.woff2 +0 -0
  75. pywebwinui3/web/Pretendard/PretendardVariable.subset.70.woff2 +0 -0
  76. pywebwinui3/web/Pretendard/PretendardVariable.subset.71.woff2 +0 -0
  77. pywebwinui3/web/Pretendard/PretendardVariable.subset.72.woff2 +0 -0
  78. pywebwinui3/web/Pretendard/PretendardVariable.subset.73.woff2 +0 -0
  79. pywebwinui3/web/Pretendard/PretendardVariable.subset.74.woff2 +0 -0
  80. pywebwinui3/web/Pretendard/PretendardVariable.subset.75.woff2 +0 -0
  81. pywebwinui3/web/Pretendard/PretendardVariable.subset.76.woff2 +0 -0
  82. pywebwinui3/web/Pretendard/PretendardVariable.subset.77.woff2 +0 -0
  83. pywebwinui3/web/Pretendard/PretendardVariable.subset.78.woff2 +0 -0
  84. pywebwinui3/web/Pretendard/PretendardVariable.subset.79.woff2 +0 -0
  85. pywebwinui3/web/Pretendard/PretendardVariable.subset.8.woff2 +0 -0
  86. pywebwinui3/web/Pretendard/PretendardVariable.subset.80.woff2 +0 -0
  87. pywebwinui3/web/Pretendard/PretendardVariable.subset.81.woff2 +0 -0
  88. pywebwinui3/web/Pretendard/PretendardVariable.subset.82.woff2 +0 -0
  89. pywebwinui3/web/Pretendard/PretendardVariable.subset.83.woff2 +0 -0
  90. pywebwinui3/web/Pretendard/PretendardVariable.subset.84.woff2 +0 -0
  91. pywebwinui3/web/Pretendard/PretendardVariable.subset.85.woff2 +0 -0
  92. pywebwinui3/web/Pretendard/PretendardVariable.subset.86.woff2 +0 -0
  93. pywebwinui3/web/Pretendard/PretendardVariable.subset.87.woff2 +0 -0
  94. pywebwinui3/web/Pretendard/PretendardVariable.subset.88.woff2 +0 -0
  95. pywebwinui3/web/Pretendard/PretendardVariable.subset.89.woff2 +0 -0
  96. pywebwinui3/web/Pretendard/PretendardVariable.subset.9.woff2 +0 -0
  97. pywebwinui3/web/Pretendard/PretendardVariable.subset.90.woff2 +0 -0
  98. pywebwinui3/web/Pretendard/PretendardVariable.subset.91.woff2 +0 -0
  99. pywebwinui3/web/Pretendard/Variable-dynamic-subset.min.css +927 -0
  100. pywebwinui3/web/SegoeFluentIcons.ttf +0 -0
  101. pywebwinui3/web/_app/env.js +1 -0
  102. pywebwinui3/web/_app/immutable/assets/2.wYBxhgNe.css +1 -0
  103. pywebwinui3/web/_app/immutable/chunks/BeVEiAlX.js +1 -0
  104. pywebwinui3/web/_app/immutable/chunks/C-ZTJ6AF.js +1 -0
  105. pywebwinui3/web/_app/immutable/chunks/D2X8AmeL.js +1 -0
  106. pywebwinui3/web/_app/immutable/chunks/DLxx5-LD.js +2 -0
  107. pywebwinui3/web/_app/immutable/chunks/DPxT1AeO.js +1 -0
  108. pywebwinui3/web/_app/immutable/chunks/DZb1s-qq.js +1 -0
  109. pywebwinui3/web/_app/immutable/chunks/DbLPBgK4.js +1 -0
  110. pywebwinui3/web/_app/immutable/chunks/d_V4Mktp.js +1 -0
  111. pywebwinui3/web/_app/immutable/chunks/pGvP_QvR.js +1 -0
  112. pywebwinui3/web/_app/immutable/entry/app.DgyHcM8N.js +2 -0
  113. pywebwinui3/web/_app/immutable/entry/start.D7vHaDGu.js +1 -0
  114. pywebwinui3/web/_app/immutable/nodes/0.DO1mLoyu.js +54 -0
  115. pywebwinui3/web/_app/immutable/nodes/1.DvyFB-Wy.js +1 -0
  116. pywebwinui3/web/_app/immutable/nodes/2.CrF9bZZd.js +94 -0
  117. pywebwinui3/web/_app/version.json +1 -0
  118. pywebwinui3/web/index.html +101 -0
  119. pywebwinui3-1.0.0.dist-info/METADATA +92 -0
  120. pywebwinui3-1.0.0.dist-info/RECORD +124 -0
  121. pywebwinui3-1.0.0.dist-info/WHEEL +5 -0
  122. pywebwinui3-1.0.0.dist-info/licenses/LICENSE +201 -0
  123. pywebwinui3-1.0.0.dist-info/licenses/NOTICE +13 -0
  124. pywebwinui3-1.0.0.dist-info/top_level.txt +1 -0
pywebwinui3/qt.py ADDED
@@ -0,0 +1,887 @@
1
+ from __future__ import annotations
2
+
3
+ import ctypes
4
+ import json
5
+ import logging
6
+ import os
7
+ import sys
8
+ import threading
9
+ from pathlib import Path
10
+ from ctypes import wintypes
11
+
12
+ from PySide6.QtCore import QEvent, QObject, QPoint, QRect, Qt, QTimer, QUrl, Signal, Slot
13
+ from PySide6.QtGui import QColor, QCursor, QDesktopServices, QIcon, QKeySequence, QShortcut
14
+ from PySide6.QtWebChannel import QWebChannel
15
+ from PySide6.QtWebEngineCore import QWebEngineContextMenuRequest, QWebEnginePage, QWebEngineSettings
16
+ from PySide6.QtWebEngineWidgets import QWebEngineView
17
+ from PySide6.QtWidgets import QApplication, QMainWindow, QWidget
18
+ import win32api
19
+ import win32con
20
+ import win32gui
21
+ import win32gui_struct
22
+
23
+ logger = logging.getLogger("pywebwinui3.qt")
24
+
25
+ _uxtheme = ctypes.windll.uxtheme
26
+ _get_proc_address = ctypes.windll.kernel32.GetProcAddress
27
+ _get_proc_address.restype = ctypes.c_void_p
28
+ _get_proc_address.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
29
+
30
+
31
+ def _get_ordinal_proc(module, ordinal: int, restype, argtypes):
32
+ address = _get_proc_address(module._handle, ctypes.c_void_p(ordinal))
33
+ if not address:
34
+ return None
35
+ return ctypes.WINFUNCTYPE(restype, *argtypes)(address)
36
+
37
+
38
+ _set_preferred_app_mode = _get_ordinal_proc(_uxtheme, 135, ctypes.c_int, [ctypes.c_int])
39
+ _flush_menu_themes = _get_ordinal_proc(_uxtheme, 136, None, [])
40
+ _allow_dark_mode_for_window = _get_ordinal_proc(_uxtheme, 133, wintypes.BOOL, [wintypes.HWND, wintypes.BOOL])
41
+
42
+
43
+ def _apply_native_menu_theme(dark: bool, hwnd: int | None = None):
44
+ if _set_preferred_app_mode is not None:
45
+ _set_preferred_app_mode(2 if dark else 3)
46
+ if hwnd is not None and _allow_dark_mode_for_window is not None:
47
+ _allow_dark_mode_for_window(hwnd, bool(dark))
48
+ if _flush_menu_themes is not None:
49
+ _flush_menu_themes()
50
+
51
+
52
+ def open_url(url: str | QUrl) -> bool:
53
+ if (raw := url if isinstance(url, str) else url.toString()):
54
+ if (parsed:=QUrl(raw)).isValid() and QDesktopServices.openUrl(parsed):
55
+ return True
56
+ try:
57
+ os.startfile(raw)
58
+ return True
59
+ except OSError:
60
+ logger.debug("Fallback shell open failed for %s", raw, exc_info=True)
61
+ return False
62
+
63
+ class MARGINS(ctypes.Structure):
64
+ _fields_ = [
65
+ ("cxLeftWidth", ctypes.c_int),
66
+ ("cxRightWidth", ctypes.c_int),
67
+ ("cyTopHeight", ctypes.c_int),
68
+ ("cyBottomHeight", ctypes.c_int),
69
+ ]
70
+
71
+
72
+ class MSG(ctypes.Structure):
73
+ _fields_ = [
74
+ ("hwnd", wintypes.HWND),
75
+ ("message", wintypes.UINT),
76
+ ("wParam", wintypes.WPARAM),
77
+ ("lParam", wintypes.LPARAM),
78
+ ("time", wintypes.DWORD),
79
+ ("pt", wintypes.POINT),
80
+ ]
81
+
82
+ def _read_resize_border_thickness():
83
+ return (
84
+ win32api.GetSystemMetrics(win32con.SM_CXSIZEFRAME)
85
+ + win32api.GetSystemMetrics(92)
86
+ ) / 2
87
+
88
+ class ExternalAwarePage(QWebEnginePage):
89
+ def _is_same_document_fragment(self, url: QUrl) -> bool:
90
+ current = self.url()
91
+ return (
92
+ bool(url.fragment())
93
+ and current.isValid()
94
+ and url.scheme() == current.scheme()
95
+ and url.host() == current.host()
96
+ and url.port() == current.port()
97
+ and url.path() == current.path()
98
+ and url.query() == current.query()
99
+ )
100
+
101
+ def acceptNavigationRequest(self, url, navigation_type, ismain_frame):
102
+ if (
103
+ ismain_frame
104
+ and navigation_type == QWebEnginePage.NavigationType.NavigationTypeLinkClicked
105
+ and url.scheme() not in {"about", "data", "file", "qrc"}
106
+ ):
107
+ if self._is_same_document_fragment(url):
108
+ return super().acceptNavigationRequest(url, navigation_type, ismain_frame)
109
+ open_url(url)
110
+ return False
111
+ return super().acceptNavigationRequest(url, navigation_type, ismain_frame)
112
+
113
+ def createWindow(self, _):
114
+ page = QWebEnginePage(self.profile(), self)
115
+
116
+ def open_and_cleanup(url):
117
+ open_url(url)
118
+ page.deleteLater()
119
+
120
+ page.urlChanged.connect(open_and_cleanup)
121
+ return page
122
+
123
+ class FramelessWindow(QMainWindow):
124
+ closed = Signal()
125
+
126
+ EDGE_MAP = [
127
+ "left",
128
+ "right",
129
+ "top",
130
+ "bottom",
131
+ "top-left",
132
+ "top-right",
133
+ "bottom-left",
134
+ "bottom-right",
135
+ ]
136
+ CURSOR_MAP = {
137
+ "left": Qt.CursorShape.SizeHorCursor,
138
+ "right": Qt.CursorShape.SizeHorCursor,
139
+ "top": Qt.CursorShape.SizeVerCursor,
140
+ "bottom": Qt.CursorShape.SizeVerCursor,
141
+ "top-left": Qt.CursorShape.SizeFDiagCursor,
142
+ "bottom-right": Qt.CursorShape.SizeFDiagCursor,
143
+ "top-right": Qt.CursorShape.SizeBDiagCursor,
144
+ "bottom-left": Qt.CursorShape.SizeBDiagCursor,
145
+ }
146
+
147
+ def __init__(self, api, page_path: Path, title: str, icon_path: Path | None, debug: bool = False):
148
+ super().__init__()
149
+ self.api = api
150
+ self.page_path = page_path
151
+ self._native_frame_ready = False
152
+ self._resize_edge_name: str | None = None
153
+ self._resize_origin: QPoint | None = None
154
+ self._resize_geometry: QRect | None = None
155
+ self._active_resize_cursor = None
156
+ self._resize_border = _read_resize_border_thickness()
157
+ self._debug_tools_view: QWebEngineView | None = None
158
+ self._debug_tools_page: QWebEnginePage | None = None
159
+
160
+ self.setWindowFlag(Qt.WindowType.FramelessWindowHint, True)
161
+ self.setWindowTitle(title)
162
+ self.resize(self.api.main.values.get("system_window_width"), self.api.main.values.get("system_window_height"))
163
+ self.setMouseTracking(True)
164
+
165
+ if icon_path and icon_path.exists():
166
+ self.setWindowIcon(QIcon(str(icon_path)))
167
+
168
+ self.view = QWebEngineView(self)
169
+ self.page = ExternalAwarePage(self.view)
170
+ self._apply_window_background()
171
+
172
+ settings = self.page.settings()
173
+ settings.setAttribute(QWebEngineSettings.WebAttribute.LocalContentCanAccessFileUrls, True)
174
+ settings.setAttribute(QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls, True)
175
+ settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptCanAccessClipboard, True)
176
+ settings.setAttribute(QWebEngineSettings.WebAttribute.Accelerated2dCanvasEnabled, True)
177
+ settings.setAttribute(QWebEngineSettings.WebAttribute.WebGLEnabled, True)
178
+
179
+ self.channel = QWebChannel(self.page)
180
+ self.channel.registerObject("backend", api)
181
+ self.page.setWebChannel(self.channel)
182
+
183
+ self.view.setPage(self.page)
184
+ self.setCentralWidget(self.view)
185
+ self.view.setMouseTracking(True)
186
+ self.view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
187
+ self.view.customContextMenuRequested.connect(self._show_context_menu)
188
+ self.page.loadFinished.connect(self._handle_load_finished)
189
+ QApplication.instance().installEventFilter(self)
190
+
191
+ if not self.page_path.is_file():
192
+ raise FileNotFoundError(f"Frontend entry not found: {self.page_path}")
193
+
194
+ if debug:
195
+ debug_shortcut = QShortcut(QKeySequence("F12"), self)
196
+ debug_shortcut.activated.connect(self.toggle_devtools)
197
+ inspector_shortcut = QShortcut(QKeySequence("Ctrl+Shift+I"), self)
198
+ inspector_shortcut.activated.connect(self.toggle_devtools)
199
+ self._debug_shortcuts = (debug_shortcut, inspector_shortcut)
200
+
201
+ self.view.setUrl(QUrl.fromLocalFile(str(self.page_path)))
202
+
203
+ def showEvent(self, event):
204
+ super().showEvent(event)
205
+ self._refresh_resize_border()
206
+ self._ensure_native_frame()
207
+
208
+ def resizeEvent(self, event):
209
+ super().resizeEvent(event)
210
+ self.api.main.values.set("system_window_width", self.width(), False)
211
+ self.api.main.values.set("system_window_height", self.height(), False)
212
+
213
+ def nativeEvent(self, event_type, message):
214
+ try:
215
+ msg = MSG.from_address(int(message))
216
+ except (TypeError, ValueError, OSError):
217
+ return super().nativeEvent(event_type, message)
218
+
219
+ if msg.message == win32con.WM_SETTINGCHANGE:
220
+ self.api.main.accent.refresh()
221
+
222
+ return super().nativeEvent(event_type, message)
223
+
224
+ def closeEvent(self, event):
225
+ app = QApplication.instance()
226
+ if app is not None:
227
+ app.removeEventFilter(self)
228
+ self._set_resize_cursor(None)
229
+ self.hide()
230
+ if self._debug_tools_view is not None:
231
+ self._debug_tools_view.close()
232
+ QTimer.singleShot(0, self.closed.emit)
233
+ super().closeEvent(event)
234
+
235
+ def _maximize_native(self):
236
+ self._ensure_native_frame()
237
+ win32gui.ShowWindow(self._hwnd(), win32con.SW_MAXIMIZE)
238
+
239
+ def _restore_native(self):
240
+ self._ensure_native_frame()
241
+ win32gui.ShowWindow(self._hwnd(), win32con.SW_RESTORE)
242
+
243
+ def _start_native_move(self):
244
+ handle = self.windowHandle()
245
+ if handle is not None and handle.startSystemMove():
246
+ return
247
+ win32gui.ReleaseCapture()
248
+ win32api.SendMessage(self._hwnd(), win32con.WM_NCLBUTTONDOWN, win32con.HTCAPTION, 0)
249
+
250
+ def _hwnd(self) -> int:
251
+ return int(self.winId())
252
+
253
+ def _ensure_native_frame(self):
254
+ if self._native_frame_ready:
255
+ return
256
+
257
+ hwnd = self._hwnd()
258
+ style = win32gui.GetWindowLong(hwnd, win32con.GWL_STYLE)
259
+ style |= (
260
+ win32con.WS_THICKFRAME
261
+ | win32con.WS_MINIMIZEBOX
262
+ | win32con.WS_MAXIMIZEBOX
263
+ | win32con.WS_SYSMENU
264
+ )
265
+ win32gui.SetWindowLong(hwnd, win32con.GWL_STYLE, style)
266
+
267
+ ex_style = win32gui.GetWindowLong(hwnd, win32con.GWL_EXSTYLE)
268
+ ex_style |= win32con.WS_EX_APPWINDOW
269
+ win32gui.SetWindowLong(hwnd, win32con.GWL_EXSTYLE, ex_style)
270
+
271
+ if sys.getwindowsversion().build >= 22000:
272
+ dark_mode = wintypes.BOOL(self._resolved_theme() == "dark")
273
+ ctypes.windll.dwmapi.DwmSetWindowAttribute(
274
+ hwnd,
275
+ 20,
276
+ ctypes.byref(dark_mode),
277
+ ctypes.sizeof(dark_mode),
278
+ )
279
+
280
+ disable_transitions = wintypes.BOOL(True)
281
+ ctypes.windll.dwmapi.DwmSetWindowAttribute(
282
+ hwnd,
283
+ 3,
284
+ ctypes.byref(disable_transitions),
285
+ ctypes.sizeof(disable_transitions),
286
+ )
287
+
288
+ corners = ctypes.c_int(2)
289
+ ctypes.windll.dwmapi.DwmSetWindowAttribute(
290
+ hwnd,
291
+ 33,
292
+ ctypes.byref(corners),
293
+ ctypes.sizeof(corners),
294
+ )
295
+
296
+ margins = MARGINS(1, 1, 1, 1)
297
+ ctypes.windll.dwmapi.DwmExtendFrameIntoClientArea(hwnd, ctypes.byref(margins))
298
+
299
+ win32gui.SetWindowPos(
300
+ hwnd,
301
+ 0,
302
+ 0,
303
+ 0,
304
+ 0,
305
+ 0,
306
+ win32con.SWP_NOMOVE
307
+ | win32con.SWP_NOSIZE
308
+ | win32con.SWP_NOZORDER
309
+ | win32con.SWP_NOACTIVATE
310
+ | win32con.SWP_FRAMECHANGED,
311
+ )
312
+ _apply_native_menu_theme(self._resolved_theme() == "dark", hwnd)
313
+ self._native_frame_ready = True
314
+
315
+ def _refresh_resize_border(self):
316
+ self._resize_border = _read_resize_border_thickness()
317
+
318
+ def _resolved_theme(self) -> str:
319
+ values = self.api.main.values
320
+ theme = values.get("system_theme") or "system"
321
+ if theme == "system":
322
+ theme = values.get("system_theme_resolved") or "dark"
323
+ return theme if theme in {"light", "dark"} else "dark"
324
+
325
+ def _apply_window_background(self):
326
+ self.page.setBackgroundColor(QColor("#f3f3f3"if self._resolved_theme() == "light" else "#202020"))
327
+
328
+ def _handle_load_finished(self, _ok: bool):
329
+ self._apply_window_background()
330
+
331
+ def _sync_view_geometry(self):
332
+ return
333
+
334
+ def _show_context_menu(self, pos: QPoint):
335
+ request = self.view.lastContextMenuRequest()
336
+ if request is not None and request.isContentEditable():
337
+ edit_flags = request.editFlags()
338
+ self._exec_native_menu(
339
+ self.view.mapToGlobal(pos),
340
+ [
341
+ (0x5000, "Undo", bool(edit_flags & QWebEngineContextMenuRequest.EditFlag.CanUndo), False, lambda: self.page.triggerAction(QWebEnginePage.WebAction.Undo)),
342
+ (0x5001, "Redo", bool(edit_flags & QWebEngineContextMenuRequest.EditFlag.CanRedo), False, lambda: self.page.triggerAction(QWebEnginePage.WebAction.Redo)),
343
+ None,
344
+ (0x5002, "Cut", bool(edit_flags & QWebEngineContextMenuRequest.EditFlag.CanCut), False, lambda: self.page.triggerAction(QWebEnginePage.WebAction.Cut)),
345
+ (0x5013, "Copy", bool(edit_flags & QWebEngineContextMenuRequest.EditFlag.CanCopy), False, lambda: self.page.triggerAction(QWebEnginePage.WebAction.Copy)),
346
+ (0x5014, "Paste", bool(edit_flags & QWebEngineContextMenuRequest.EditFlag.CanPaste), False, lambda: self.page.triggerAction(QWebEnginePage.WebAction.Paste)),
347
+ None,
348
+ (0x5015, "Select All", bool(edit_flags & QWebEngineContextMenuRequest.EditFlag.CanSelectAll), False, lambda: self.page.triggerAction(QWebEnginePage.WebAction.SelectAll)),
349
+ ],
350
+ )
351
+ return
352
+
353
+ self._exec_native_menu(
354
+ self.view.mapToGlobal(pos),
355
+ [
356
+ (0x5010, "Back", self.page.action(QWebEnginePage.WebAction.Back).isEnabled(), False, lambda: self.page.triggerAction(QWebEnginePage.WebAction.Back)),
357
+ (0x5011, "Forward", self.page.action(QWebEnginePage.WebAction.Forward).isEnabled(), False, lambda: self.page.triggerAction(QWebEnginePage.WebAction.Forward)),
358
+ (0x5012, "Reload", True, False, lambda: self.page.triggerAction(QWebEnginePage.WebAction.Reload)),
359
+ ],
360
+ )
361
+
362
+ def _exec_native_menu(self, global_pos: QPoint, items):
363
+ menu = win32gui.CreatePopupMenu()
364
+ callbacks = {}
365
+ try:
366
+ win32gui.SetMenuInfo(menu, win32gui_struct.PackMENUINFO(dwStyle=win32con.MNS_CHECKORBMP))
367
+ for item in items:
368
+ if item is None:
369
+ win32gui.AppendMenu(menu, win32con.MF_SEPARATOR, 0, "")
370
+ continue
371
+
372
+ command_id, label, enabled, checked, callback, *extra = item
373
+ bitmap = extra[0] if extra else None
374
+ flags = win32con.MF_STRING
375
+ if not enabled:
376
+ flags |= win32con.MF_GRAYED
377
+ if checked:
378
+ flags |= win32con.MF_CHECKED
379
+ win32gui.AppendMenu(menu, flags, command_id, label)
380
+ if bitmap is not None:
381
+ packed, _extras = win32gui_struct.PackMENUITEMINFO(hbmpItem=bitmap)
382
+ win32gui.SetMenuItemInfo(menu, command_id, False, packed)
383
+ callbacks[command_id] = callback
384
+
385
+ hwnd = self._hwnd()
386
+ win32gui.SetForegroundWindow(hwnd)
387
+ command = win32gui.TrackPopupMenu(
388
+ menu,
389
+ win32con.TPM_RETURNCMD | win32con.TPM_NONOTIFY | win32con.TPM_RIGHTBUTTON,
390
+ global_pos.x(),
391
+ global_pos.y(),
392
+ 0,
393
+ hwnd,
394
+ None,
395
+ )
396
+ win32gui.PostMessage(hwnd, win32con.WM_NULL, 0, 0)
397
+
398
+ callback = callbacks.get(command)
399
+ if command and callback is not None:
400
+ callback()
401
+ finally:
402
+ win32gui.DestroyMenu(menu)
403
+
404
+ def _open_devtools(self):
405
+ if self._debug_tools_view is None:
406
+ self._debug_tools_view = QWebEngineView()
407
+ self._debug_tools_page = QWebEnginePage(self.page.profile(), self._debug_tools_view)
408
+ self._debug_tools_view.setPage(self._debug_tools_page)
409
+ self.page.setDevToolsPage(self._debug_tools_page)
410
+ self._debug_tools_view.setWindowTitle(f"{self.windowTitle()} DevTools")
411
+ self._debug_tools_view.resize(600, 800)
412
+
413
+ self._debug_tools_view.show()
414
+ self._debug_tools_view.raise_()
415
+ self._debug_tools_view.activateWindow()
416
+
417
+ @Slot()
418
+ def toggle_devtools(self):
419
+ if self._debug_tools_view is None or not self._debug_tools_view.isVisible():
420
+ self._open_devtools()
421
+ return
422
+
423
+ self._debug_tools_view.hide()
424
+
425
+ def _belongs_to_window(self, watched) -> bool:
426
+ if watched in {self, self.view}:
427
+ return True
428
+ if isinstance(watched, QWidget):
429
+ return watched.window() is self
430
+ return False
431
+
432
+ def _is_native_maximized(self) -> bool:
433
+ try:
434
+ return bool(win32gui.IsZoomed(self._hwnd()))
435
+ except Exception:
436
+ return self.isMaximized()
437
+
438
+ def _is_effectively_maximized(self) -> bool:
439
+ state = self.windowState()
440
+ return bool(state & Qt.WindowState.WindowMaximized) or self._is_native_maximized()
441
+
442
+ def _edge_from_pos(self, pos: QPoint, size) -> str | None:
443
+ if self._is_effectively_maximized():
444
+ return None
445
+
446
+ border = self._resize_border
447
+ on_left = 0 <= pos.x() < border
448
+ on_right = size.width() - border <= pos.x() < size.width()
449
+ on_top = 0 <= pos.y() < border
450
+ on_bottom = size.height() - border <= pos.y() < size.height()
451
+
452
+ if on_top and on_left:
453
+ return "top-left"
454
+ if on_top and on_right:
455
+ return "top-right"
456
+ if on_bottom and on_left:
457
+ return "bottom-left"
458
+ if on_bottom and on_right:
459
+ return "bottom-right"
460
+ if on_left:
461
+ return "left"
462
+ if on_right:
463
+ return "right"
464
+ if on_top:
465
+ return "top"
466
+ if on_bottom:
467
+ return "bottom"
468
+ return None
469
+
470
+ def _set_resize_cursor(self, edge_name: str | None):
471
+ cursor = self.CURSOR_MAP.get(edge_name)
472
+ if cursor == self._active_resize_cursor:
473
+ return
474
+
475
+ app = QApplication.instance()
476
+ if app is None:
477
+ self._active_resize_cursor = cursor
478
+ return
479
+
480
+ if cursor is None:
481
+ if self._active_resize_cursor is not None:
482
+ app.restoreOverrideCursor()
483
+ self._active_resize_cursor = None
484
+ return
485
+
486
+ qcursor = QCursor(cursor)
487
+ if self._active_resize_cursor is None:
488
+ app.setOverrideCursor(qcursor)
489
+ else:
490
+ app.changeOverrideCursor(qcursor)
491
+ self._active_resize_cursor = cursor
492
+
493
+ def _begin_resize(self, edge_name: str, global_pos: QPoint):
494
+ self._refresh_resize_border()
495
+ self._resize_edge_name = edge_name
496
+ self._resize_origin = QPoint(global_pos)
497
+ self._resize_geometry = QRect(self.geometry())
498
+ self._set_resize_cursor(edge_name)
499
+ self.grabMouse()
500
+
501
+ def _apply_resize(self, global_pos: QPoint):
502
+ if self._resize_edge_name is None or self._resize_origin is None or self._resize_geometry is None:
503
+ return
504
+
505
+ self._set_resize_cursor(self._resize_edge_name)
506
+ dx = global_pos.x() - self._resize_origin.x()
507
+ dy = global_pos.y() - self._resize_origin.y()
508
+ geometry = QRect(self._resize_geometry)
509
+ minimum_width = max(self.minimumWidth(), 1)
510
+ minimum_height = max(self.minimumHeight(), 1)
511
+
512
+ if "left" in self._resize_edge_name:
513
+ max_left = geometry.right() - minimum_width + 1
514
+ geometry.setLeft(min(geometry.left() + dx, max_left))
515
+ if "right" in self._resize_edge_name:
516
+ min_right = geometry.left() + minimum_width - 1
517
+ geometry.setRight(max(geometry.right() + dx, min_right))
518
+ if "top" in self._resize_edge_name:
519
+ max_top = geometry.bottom() - minimum_height + 1
520
+ geometry.setTop(min(geometry.top() + dy, max_top))
521
+ if "bottom" in self._resize_edge_name:
522
+ min_bottom = geometry.top() + minimum_height - 1
523
+ geometry.setBottom(max(geometry.bottom() + dy, min_bottom))
524
+
525
+ if self.geometry() != geometry:
526
+ self.setGeometry(geometry)
527
+
528
+ def _end_resize(self):
529
+ if self._resize_edge_name is None:
530
+ return
531
+
532
+ self._resize_edge_name = None
533
+ self._resize_origin = None
534
+ self._resize_geometry = None
535
+ self.releaseMouse()
536
+ self._set_resize_cursor(None)
537
+
538
+ def eventFilter(self, watched:QObject, event:QEvent):
539
+ if event.type() not in {
540
+ QEvent.Type.MouseButtonPress,
541
+ QEvent.Type.MouseButtonRelease,
542
+ QEvent.Type.MouseMove,
543
+ }:
544
+ return super().eventFilter(watched, event)
545
+
546
+ if not hasattr(event, "globalPosition"):
547
+ return super().eventFilter(watched, event)
548
+
549
+ if self._resize_edge_name is None and not self._belongs_to_window(watched):
550
+ self._set_resize_cursor(None)
551
+ return super().eventFilter(watched, event)
552
+
553
+ global_pos = event.globalPosition().toPoint()
554
+ local_pos = self.mapFromGlobal(global_pos)
555
+
556
+ if self._resize_edge_name is None and not self.rect().contains(local_pos):
557
+ self._set_resize_cursor(None)
558
+ return super().eventFilter(watched, event)
559
+
560
+ edge_name = self._edge_from_pos(local_pos, self.size())
561
+
562
+ if event.type() == QEvent.Type.MouseButtonPress and event.button() == Qt.MouseButton.LeftButton and edge_name:
563
+ self._begin_resize(edge_name, global_pos)
564
+ event.accept()
565
+ return True
566
+
567
+ if event.type() == QEvent.Type.MouseMove:
568
+ if self._resize_edge_name is not None:
569
+ self._apply_resize(global_pos)
570
+ event.accept()
571
+ return True
572
+
573
+ self._set_resize_cursor(edge_name)
574
+ return False
575
+
576
+ if event.type() == QEvent.Type.MouseButtonRelease and self._resize_edge_name is not None:
577
+ self._end_resize()
578
+ event.accept()
579
+ return True
580
+
581
+ return super().eventFilter(watched, event)
582
+
583
+ @Slot()
584
+ def minimize(self):
585
+ self.showMinimized()
586
+
587
+ @Slot()
588
+ def close_window(self):
589
+ self.close()
590
+
591
+ @Slot(bool)
592
+ def set_on_top(self, state: bool):
593
+ self._ensure_native_frame()
594
+ win32gui.SetWindowPos(
595
+ self._hwnd(),
596
+ win32con.HWND_TOPMOST if state else win32con.HWND_NOTOPMOST,
597
+ 0,
598
+ 0,
599
+ 0,
600
+ 0,
601
+ win32con.SWP_NOMOVE
602
+ | win32con.SWP_NOSIZE
603
+ | win32con.SWP_NOOWNERZORDER
604
+ | win32con.SWP_NOACTIVATE,
605
+ )
606
+ if state:
607
+ self.raise_()
608
+
609
+ @Slot()
610
+ def start_window_drag(self):
611
+ self._ensure_native_frame()
612
+ self._start_native_move()
613
+
614
+ @Slot(str)
615
+ def start_window_resize(self, edge_name: str):
616
+ if edge_name not in self.EDGE_MAP:
617
+ return
618
+
619
+ self._begin_resize(edge_name, QCursor.pos())
620
+
621
+ @Slot()
622
+ def toggle_maximize(self):
623
+ if self._is_effectively_maximized():
624
+ self._restore_native()
625
+ return
626
+ self._maximize_native()
627
+
628
+ @Slot()
629
+ def show_window_menu(self):
630
+ pinned = bool(self.api.main.values.get("system_pin"))
631
+ maximized = self._is_effectively_maximized()
632
+ self._exec_native_menu(
633
+ QCursor.pos(),
634
+ [
635
+ (0x5020, "Pin", True, pinned, lambda: self.api.main.pin(not pinned)),
636
+ (win32con.SC_MINIMIZE, "Minimize", True, False, self.minimize, win32con.HBMMENU_POPUP_MINIMIZE),
637
+ (
638
+ win32con.SC_RESTORE if maximized else win32con.SC_MAXIMIZE,
639
+ "Restore" if maximized else "Maximize",
640
+ True,
641
+ False,
642
+ self.toggle_maximize,
643
+ win32con.HBMMENU_POPUP_RESTORE if maximized else win32con.HBMMENU_POPUP_MAXIMIZE,
644
+ ),
645
+ None,
646
+ (win32con.SC_CLOSE, "Close", True, False, self.close_window, win32con.HBMMENU_POPUP_CLOSE),
647
+ ],
648
+ )
649
+
650
+ @Slot(int, int)
651
+ def apply_window_size(self, width: int, height: int):
652
+ width = max(self.minimumWidth(), int(width))
653
+ height = max(self.minimumHeight(), int(height))
654
+
655
+ if self._is_effectively_maximized():
656
+ self._restore_native()
657
+
658
+ if self.width() == width and self.height() == height:
659
+ return
660
+
661
+ self.resize(width, height)
662
+
663
+ def apply_minimum_window_size(self, width: int, height: int):
664
+ width = max(1, int(width))
665
+ height = max(1, int(height))
666
+
667
+ if self.minimumWidth() == width and self.minimumHeight() == height:
668
+ return
669
+
670
+ self.setMinimumSize(width, height)
671
+ if not self._is_effectively_maximized():
672
+ self.resize(max(self.width(), width), max(self.height(), height))
673
+
674
+ class WebviewAPI(QObject):
675
+ WINDOW_SIZE_KEYS = frozenset({"system_window_width", "system_window_height"})
676
+
677
+ dispatch_sync_requested = Signal(str, "QVariant")
678
+ close_requested = Signal()
679
+ minimize_requested = Signal()
680
+ set_on_top_requested = Signal(bool)
681
+ resize_window_requested = Signal(int, int)
682
+ start_drag_requested = Signal()
683
+ start_resize_requested = Signal(str)
684
+ toggle_maximize_requested = Signal()
685
+ show_window_menu_requested = Signal()
686
+
687
+ def __init__(self, main_window, title: str, icon: str | None):
688
+ super().__init__()
689
+ self.main = main_window
690
+ self._title = title
691
+ self._icon = icon
692
+
693
+ self._app = None
694
+ self._owns_app = False
695
+ self._window = None
696
+ self._frontend_ready = False
697
+ self._setup_fired = False
698
+ self._sync_lock = threading.Lock()
699
+ self._pending_sync: dict[str, object] = {}
700
+ self.dispatch_sync_requested.connect(self._dispatch_sync_value, Qt.ConnectionType.QueuedConnection)
701
+
702
+ def ensure_runtime(self, debug: bool = False):
703
+ if self._window is not None:
704
+ return
705
+
706
+ app = QApplication.instance()
707
+ if app is None:
708
+ QApplication.setAttribute(Qt.ApplicationAttribute.AA_UseDesktopOpenGL, True)
709
+ QApplication.setAttribute(Qt.ApplicationAttribute.AA_ShareOpenGLContexts, True)
710
+ app = QApplication(sys.argv)
711
+ self._owns_app = True
712
+
713
+ self._app = app
714
+ resolved_theme = self.main.values.get("system_theme") or "system"
715
+ if resolved_theme == "system":
716
+ resolved_theme = self.main.values.get("system_theme_resolved") or "dark"
717
+ _apply_native_menu_theme(resolved_theme == "dark")
718
+
719
+ icon_path = self.main.resolve_path(self._icon)
720
+ title = self.main.values.get("system_title") or self._title
721
+
722
+ self._window = FramelessWindow(self, self.main.packagePath / "index.html", title, icon_path, debug=debug)
723
+ self._window.closed.connect(self.main.events.closed.set)
724
+ self.close_requested.connect(self._window.close_window)
725
+ self.minimize_requested.connect(self._window.minimize)
726
+ self.set_on_top_requested.connect(self._window.set_on_top)
727
+ self.resize_window_requested.connect(self._window.apply_window_size)
728
+ self.start_drag_requested.connect(self._window.start_window_drag)
729
+ self.start_resize_requested.connect(self._window.start_window_resize)
730
+ self.toggle_maximize_requested.connect(self._window.toggle_maximize)
731
+ self.show_window_menu_requested.connect(self._window.show_window_menu)
732
+
733
+ # self.main.sync_window_size(self._window.width(), self._window.height(), sync=False)
734
+ self.main.values.set("system_window_width", self._window.width(), False)
735
+ self.main.values.set("system_window_height", self._window.height(), False)
736
+
737
+ if self.main.values.get("system_pin"):
738
+ self._window.set_on_top(True)
739
+
740
+ self._window.show()
741
+
742
+ logger.debug("Window created")
743
+
744
+ def start(self, debug: bool = False):
745
+ self.ensure_runtime(debug=debug)
746
+ if self._owns_app:
747
+ self._app.exec()
748
+
749
+ def set_on_top(self, state: bool):
750
+ self.ensure_runtime()
751
+ self.set_on_top_requested.emit(state)
752
+
753
+ def set_window_minimum_size(self, width: int, height: int):
754
+ self.ensure_runtime()
755
+ if self._window is not None:
756
+ self._window.apply_minimum_window_size(width, height)
757
+
758
+ def queue_sync_value(self, key: str, value):
759
+ if key in self.WINDOW_SIZE_KEYS:
760
+ width, height = self.main.sync_window_size(
761
+ value if key == "system_window_width" else self.main.values.get("system_window_width"),
762
+ value if key == "system_window_height" else self.main.values.get("system_window_height"),
763
+ False,
764
+ )
765
+
766
+ if self._window is not None:
767
+ self.resize_window_requested.emit(width, height)
768
+
769
+ self._dispatch_or_defer_sync("system_window_width", width)
770
+ self._dispatch_or_defer_sync("system_window_height", height)
771
+ return
772
+
773
+ self._dispatch_or_defer_sync(key, value)
774
+
775
+ def _dispatch_or_defer_sync(self, key: str, value):
776
+ with self._sync_lock:
777
+ if not self._frontend_ready or self._window is None:
778
+ self._pending_sync[key] = value
779
+ return
780
+
781
+ self.dispatch_sync_requested.emit(key, value)
782
+
783
+ @Slot(result="QVariant")
784
+ def init(self):
785
+ return dict(self.main.values)
786
+
787
+ @Slot()
788
+ def frontendReady(self):
789
+ if self._frontend_ready:
790
+ return
791
+
792
+ self._frontend_ready = True
793
+
794
+ with self._sync_lock:
795
+ pending_sync = tuple(self._pending_sync.items())
796
+ self._pending_sync.clear()
797
+
798
+ for key, value in pending_sync:
799
+ self.dispatch_sync_requested.emit(key, value)
800
+
801
+ if not self._setup_fired:
802
+ self._setup_fired = True
803
+ self.main.events.windowReady.set()
804
+
805
+ @Slot(str, "QVariant")
806
+ def syncValue(self, key: str, value):
807
+ if key in self.WINDOW_SIZE_KEYS:
808
+ width, height = self.main.sync_window_size(
809
+ value if key == "system_window_width" else self.main.values.get("system_window_width"),
810
+ value if key == "system_window_height" else self.main.values.get("system_window_height"),
811
+ False,
812
+ )
813
+ self.resize_window_requested.emit(width, height)
814
+ self._dispatch_or_defer_sync("system_window_width", width)
815
+ self._dispatch_or_defer_sync("system_window_height", height)
816
+ return
817
+
818
+ self.main.syncValue(key, value)
819
+
820
+ @Slot("QVariantMap")
821
+ def syncValues(self, values):
822
+ width = values.get("system_window_width", self.main.values.get("system_window_width"))
823
+ height = values.get("system_window_height", self.main.values.get("system_window_height"))
824
+ has_window_size_update = any(key in self.WINDOW_SIZE_KEYS for key in values)
825
+
826
+ for key, value in values.items():
827
+ if key in self.WINDOW_SIZE_KEYS:
828
+ continue
829
+ self.main.syncValue(key, value)
830
+
831
+ if has_window_size_update:
832
+ resolved_width, resolved_height = self.main.sync_window_size(width, height, False)
833
+ self.resize_window_requested.emit(resolved_width, resolved_height)
834
+ self._dispatch_or_defer_sync("system_window_width", resolved_width)
835
+ self._dispatch_or_defer_sync("system_window_height", resolved_height)
836
+
837
+ @Slot(bool)
838
+ def pin(self, state: bool):
839
+ self.main.pin(state)
840
+
841
+ @Slot()
842
+ def minimize(self):
843
+ self.minimize_requested.emit()
844
+
845
+ @Slot()
846
+ def destroy(self):
847
+ self.close_requested.emit()
848
+
849
+ @Slot()
850
+ def startWindowDrag(self):
851
+ self.start_drag_requested.emit()
852
+
853
+ @Slot(str)
854
+ def startWindowResize(self, edge_name: str):
855
+ self.start_resize_requested.emit(edge_name)
856
+
857
+ @Slot()
858
+ def toggleMaximize(self):
859
+ self.toggle_maximize_requested.emit()
860
+
861
+ @Slot()
862
+ def showWindowMenu(self):
863
+ self.show_window_menu_requested.emit()
864
+
865
+ @Slot(str)
866
+ def openExternal(self, url: str):
867
+ open_url(url)
868
+
869
+ @Slot(str, result=str)
870
+ def resolveResource(self, value: str):
871
+ resolved = self.main.resolve_resource_url(value)
872
+ return "" if resolved is None else str(resolved)
873
+
874
+ @Slot(str, "QVariant")
875
+ def _dispatch_sync_value(self, key: str, value):
876
+ if self._window is None:
877
+ with self._sync_lock:
878
+ self._pending_sync[key] = value
879
+ return
880
+
881
+ if key == "system_title":
882
+ self._window.setWindowTitle(str(value or self._title))
883
+ if key in {"system_theme", "system_theme_resolved"}:
884
+ self._window._apply_window_background()
885
+
886
+ script = f"window.syncValue({json.dumps(key, ensure_ascii=False)}, {json.dumps(value, ensure_ascii=False)}, false)"
887
+ self._window.page.runJavaScript(script)