PyWebWinUI3 1.0.3__py3-none-any.whl → 1.2.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.
- pywebwinui3/__init__.py +1 -1
- pywebwinui3/core.py +376 -61
- pywebwinui3/event.py +31 -45
- pywebwinui3/util.py +2 -14
- pywebwinui3/web/_app/immutable/assets/2.CgNWjm1R.css +1 -0
- pywebwinui3/web/_app/immutable/chunks/6pQH9Hkv.js +1 -0
- pywebwinui3/web/_app/immutable/chunks/{CEpW9fsN.js → B-XLOWlt.js} +1 -1
- pywebwinui3/web/_app/immutable/chunks/{CLgPPBq9.js → BF2c23Wg.js} +1 -1
- pywebwinui3/web/_app/immutable/chunks/CGGCsFu4.js +1 -0
- pywebwinui3/web/_app/immutable/chunks/CW0wlPPy.js +1 -0
- pywebwinui3/web/_app/immutable/chunks/CZPLFskb.js +1 -0
- pywebwinui3/web/_app/immutable/chunks/CZuXpvpo.js +2 -0
- pywebwinui3/web/_app/immutable/chunks/{DQYyt0Qv.js → DF1x2LYX.js} +1 -1
- pywebwinui3/web/_app/immutable/entry/app.C_Ap8SyA.js +2 -0
- pywebwinui3/web/_app/immutable/entry/start.BBET_QR9.js +1 -0
- pywebwinui3/web/_app/immutable/nodes/0.C49PHrK2.js +54 -0
- pywebwinui3/web/_app/immutable/nodes/1.gP9JsMWl.js +1 -0
- pywebwinui3/web/_app/immutable/nodes/2.SvYNzG6D.js +99 -0
- pywebwinui3/web/_app/version.json +1 -1
- pywebwinui3/web/index.html +17 -18
- {pywebwinui3-1.0.3.dist-info → pywebwinui3-1.2.0.dist-info}/METADATA +29 -25
- {pywebwinui3-1.0.3.dist-info → pywebwinui3-1.2.0.dist-info}/RECORD +26 -28
- pywebwinui3/qt.py +0 -887
- pywebwinui3/web/_app/immutable/assets/2.dPO3j65h.css +0 -1
- pywebwinui3/web/_app/immutable/chunks/B8k5Ccj7.js +0 -1
- pywebwinui3/web/_app/immutable/chunks/BHMqm3g9.js +0 -2
- pywebwinui3/web/_app/immutable/chunks/Cd1tg9Ma.js +0 -1
- pywebwinui3/web/_app/immutable/chunks/DNMsE6fi.js +0 -1
- pywebwinui3/web/_app/immutable/chunks/DbLPBgK4.js +0 -1
- pywebwinui3/web/_app/immutable/chunks/fGsU50sd.js +0 -1
- pywebwinui3/web/_app/immutable/entry/app.D0i2RG-S.js +0 -2
- pywebwinui3/web/_app/immutable/entry/start.dJKMwvgv.js +0 -1
- pywebwinui3/web/_app/immutable/nodes/0.CqNsePPe.js +0 -54
- pywebwinui3/web/_app/immutable/nodes/1.GqMcuUYV.js +0 -1
- pywebwinui3/web/_app/immutable/nodes/2.D0-SBIC3.js +0 -94
- {pywebwinui3-1.0.3.dist-info → pywebwinui3-1.2.0.dist-info}/WHEEL +0 -0
- {pywebwinui3-1.0.3.dist-info → pywebwinui3-1.2.0.dist-info}/licenses/LICENSE +0 -0
- {pywebwinui3-1.0.3.dist-info → pywebwinui3-1.2.0.dist-info}/licenses/NOTICE +0 -0
- {pywebwinui3-1.0.3.dist-info → pywebwinui3-1.2.0.dist-info}/top_level.txt +0 -0
pywebwinui3/__init__.py
CHANGED
pywebwinui3/core.py
CHANGED
|
@@ -1,24 +1,101 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import importlib
|
|
4
|
+
import json
|
|
4
5
|
import logging
|
|
6
|
+
import mimetypes
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
from http import HTTPStatus
|
|
12
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
5
13
|
from pathlib import Path
|
|
6
|
-
from
|
|
14
|
+
from urllib.parse import unquote, urlparse, urlunparse
|
|
7
15
|
|
|
8
|
-
|
|
16
|
+
import webview
|
|
17
|
+
from hPyT import title_bar
|
|
18
|
+
|
|
19
|
+
from .event import Event, PathEvent
|
|
9
20
|
from .type import Status
|
|
10
21
|
from .util import AccentColorWatcher, SyncDict, loadPage
|
|
11
22
|
|
|
12
23
|
logger = logging.getLogger("pywebwinui3")
|
|
13
|
-
|
|
14
|
-
from .qt import WebviewAPI
|
|
24
|
+
core_logger = logging.getLogger("pywebwinui3.core")
|
|
15
25
|
|
|
16
26
|
DEFAULT_WINDOW_WIDTH = 900
|
|
17
27
|
DEFAULT_WINDOW_HEIGHT = 600
|
|
18
28
|
DEFAULT_WINDOW_MIN_WIDTH = 100
|
|
19
29
|
DEFAULT_WINDOW_MIN_HEIGHT = 100
|
|
20
|
-
|
|
21
|
-
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _default_ui_origin_host(title: str) -> str:
|
|
33
|
+
slug = re.sub(r"[^a-z0-9]+", "-", str(title).casefold()).strip("-")
|
|
34
|
+
if not slug:
|
|
35
|
+
slug = "app"
|
|
36
|
+
slug = re.sub(r"-{2,}", "-", slug)[:48].strip("-")
|
|
37
|
+
if not slug:
|
|
38
|
+
slug = "app"
|
|
39
|
+
return f"{slug}.local"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _UiHttpServer(ThreadingHTTPServer):
|
|
43
|
+
daemon_threads = True
|
|
44
|
+
allow_reuse_address = True
|
|
45
|
+
|
|
46
|
+
def __init__(self, server_address, request_handler_class, main: "MainWindow"):
|
|
47
|
+
super().__init__(server_address, request_handler_class)
|
|
48
|
+
self.main = main
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _UiRequestHandler(BaseHTTPRequestHandler):
|
|
52
|
+
server: _UiHttpServer
|
|
53
|
+
|
|
54
|
+
def do_GET(self):
|
|
55
|
+
try:
|
|
56
|
+
path = self.server.main._resolve_server_path(urlparse(self.path).path)
|
|
57
|
+
if path is None or not path.is_file():
|
|
58
|
+
self._send_error("Not found.", HTTPStatus.NOT_FOUND)
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
body = path.read_bytes()
|
|
62
|
+
content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
|
63
|
+
self.send_response(HTTPStatus.OK)
|
|
64
|
+
self._send_common_headers(content_type)
|
|
65
|
+
self.send_header("Content-Length", str(len(body)))
|
|
66
|
+
self.end_headers()
|
|
67
|
+
self.wfile.write(body)
|
|
68
|
+
except Exception:
|
|
69
|
+
core_logger.debug("Failed to serve UI asset", exc_info=True)
|
|
70
|
+
self._send_error("Failed to serve UI asset.", HTTPStatus.INTERNAL_SERVER_ERROR)
|
|
71
|
+
|
|
72
|
+
def log_message(self, format, *args):
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
def _send_error(self, message: str, status: HTTPStatus):
|
|
76
|
+
body = message.encode("utf-8")
|
|
77
|
+
self.send_response(status)
|
|
78
|
+
self._send_common_headers("text/plain; charset=utf-8")
|
|
79
|
+
self.send_header("Content-Length", str(len(body)))
|
|
80
|
+
self.end_headers()
|
|
81
|
+
self.wfile.write(body)
|
|
82
|
+
|
|
83
|
+
def _send_common_headers(self, content_type: str):
|
|
84
|
+
self.send_header("Content-Type", content_type)
|
|
85
|
+
self.send_header("Cache-Control", "no-store")
|
|
86
|
+
self.send_header("Permissions-Policy", "display-capture=*")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class _JsApi:
|
|
90
|
+
def _init(self, main:MainWindow):
|
|
91
|
+
self.init = main._init_payload
|
|
92
|
+
self.frontendReady = main._frontend_ready_callback
|
|
93
|
+
self.syncValue = main.syncValue
|
|
94
|
+
self.syncValues = main._sync_values
|
|
95
|
+
self.pin = main.pin
|
|
96
|
+
self.minimize = main.minimize
|
|
97
|
+
self.destroy = main.destroy
|
|
98
|
+
self.resolveResource = main.resolveResource
|
|
22
99
|
|
|
23
100
|
|
|
24
101
|
class WindowEvents:
|
|
@@ -26,78 +103,97 @@ class WindowEvents:
|
|
|
26
103
|
self.windowReady = Event()
|
|
27
104
|
self.closed = Event()
|
|
28
105
|
self.accentColorChange = Event()
|
|
29
|
-
self.
|
|
30
|
-
self.valueChange = Event()
|
|
106
|
+
self.valueChange = PathEvent()
|
|
31
107
|
|
|
32
108
|
|
|
33
109
|
class MainWindow:
|
|
34
110
|
def __init__(self, title: str, icon: str | None = None):
|
|
35
|
-
self.rootPath = Path(
|
|
111
|
+
self.rootPath = Path(sys._getframe(1).f_code.co_filename).parent.resolve()
|
|
36
112
|
self.packagePath = Path(__file__).parent.resolve() / "web"
|
|
37
|
-
self._title = title
|
|
38
|
-
self._icon = icon
|
|
39
113
|
|
|
40
114
|
self.accent = AccentColorWatcher()
|
|
41
115
|
self.events = WindowEvents()
|
|
42
|
-
self.api =
|
|
116
|
+
self.api = _JsApi()
|
|
117
|
+
self._title = title
|
|
118
|
+
self._frontend_ready = False
|
|
119
|
+
self._setup_fired = False
|
|
120
|
+
self._minimum_width = DEFAULT_WINDOW_MIN_WIDTH
|
|
121
|
+
self._minimum_height = DEFAULT_WINDOW_MIN_HEIGHT
|
|
122
|
+
self._sync_lock = threading.Lock()
|
|
123
|
+
self._pending_sync: dict[str, object] = {}
|
|
124
|
+
self._ui_server: _UiHttpServer | None = None
|
|
125
|
+
self._ui_server_thread: threading.Thread | None = None
|
|
126
|
+
self._ui_origin = ""
|
|
127
|
+
self._ui_bind_host = "127.0.0.1"
|
|
128
|
+
self._ui_origin_host = getattr(self, "ui_origin_host", _default_ui_origin_host(title))
|
|
129
|
+
self._resource_roots: list[tuple[str, Path]] = [
|
|
130
|
+
("root", self.rootPath),
|
|
131
|
+
("package", self.packagePath),
|
|
132
|
+
]
|
|
43
133
|
|
|
44
134
|
self.values = SyncDict(
|
|
45
135
|
{
|
|
46
136
|
"system_title": title,
|
|
47
137
|
"system_icon": icon,
|
|
48
138
|
"system_theme": "system",
|
|
49
|
-
"system_theme_resolved": self.accent.theme,
|
|
50
139
|
"system_accent": self.accent.palette,
|
|
51
140
|
"system_pages": None,
|
|
52
141
|
"system_settings": None,
|
|
53
142
|
"system_nofication": [],
|
|
54
143
|
"system_pin": False,
|
|
55
|
-
"system_window_width": 900,
|
|
56
|
-
"system_window_height": 600,
|
|
57
144
|
}
|
|
58
145
|
)
|
|
59
|
-
self.values.sync = self.
|
|
146
|
+
self.values.sync = self.queue_sync_value
|
|
147
|
+
self._start_ui_server()
|
|
148
|
+
self._configure_webview2_origin_identity()
|
|
149
|
+
|
|
150
|
+
self._window = webview.create_window(
|
|
151
|
+
self._current_title(),
|
|
152
|
+
f"{self._ui_origin}/index.html",
|
|
153
|
+
js_api=self.api,
|
|
154
|
+
background_color="#202020",
|
|
155
|
+
text_select=True,
|
|
156
|
+
width=DEFAULT_WINDOW_WIDTH,
|
|
157
|
+
height=DEFAULT_WINDOW_HEIGHT,
|
|
158
|
+
min_size=(self._minimum_width, self._minimum_height),
|
|
159
|
+
on_top=bool(self.values.get("system_pin", False)),
|
|
160
|
+
)
|
|
161
|
+
self.show = self._window.show
|
|
162
|
+
self.restore = self._window.restore
|
|
163
|
+
self.hide = self._window.hide
|
|
164
|
+
self.destroy = self._window.destroy
|
|
165
|
+
self.minimize = self._window.minimize
|
|
166
|
+
self.api._init(self)
|
|
167
|
+
self._window.events.before_show += self._before_show
|
|
168
|
+
self._window.events.closed += self._on_closed
|
|
169
|
+
|
|
170
|
+
core_logger.debug("Window created")
|
|
60
171
|
|
|
61
172
|
self.events.accentColorChange = self.accent.event
|
|
62
|
-
self.events.themeChange = self.accent.theme_event
|
|
63
173
|
self.events.valueChange = self.values.event
|
|
64
174
|
self.events.accentColorChange += lambda palette: self.values.set("system_accent", palette)
|
|
65
|
-
self.events.themeChange += lambda theme: self.values.set("system_theme_resolved", theme)
|
|
66
|
-
|
|
67
|
-
def onValueChange(self, key):
|
|
68
|
-
def decorator(func):
|
|
69
|
-
self.events.valueChange += (key, func)
|
|
70
|
-
return func
|
|
71
175
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def onAccentColorChange(self):
|
|
176
|
+
def _event_decorator(self, event: Event | PathEvent, value=None):
|
|
75
177
|
def decorator(func):
|
|
76
|
-
|
|
178
|
+
if value is None:
|
|
179
|
+
event.__iadd__(func)
|
|
180
|
+
else:
|
|
181
|
+
event.__iadd__((value, func))
|
|
77
182
|
return func
|
|
78
183
|
|
|
79
184
|
return decorator
|
|
80
185
|
|
|
81
|
-
def
|
|
82
|
-
|
|
83
|
-
self.events.themeChange += func
|
|
84
|
-
return func
|
|
186
|
+
def onValueChange(self, key):
|
|
187
|
+
return self._event_decorator(self.events.valueChange, key)
|
|
85
188
|
|
|
86
|
-
|
|
189
|
+
def onAccentColorChange(self):
|
|
190
|
+
return self._event_decorator(self.events.accentColorChange)
|
|
87
191
|
|
|
88
192
|
def onSetup(self):
|
|
89
|
-
|
|
90
|
-
self.events.windowReady += func
|
|
91
|
-
return func
|
|
92
|
-
|
|
93
|
-
return decorator
|
|
193
|
+
return self._event_decorator(self.events.windowReady)
|
|
94
194
|
|
|
95
195
|
def onExit(self):
|
|
96
|
-
|
|
97
|
-
self.events.closed += func
|
|
98
|
-
return func
|
|
99
|
-
|
|
100
|
-
return decorator
|
|
196
|
+
return self._event_decorator(self.events.closed)
|
|
101
197
|
|
|
102
198
|
def notice(self, level: Status, title: str, description: str, item: dict | None = None):
|
|
103
199
|
self.values["system_nofication"] = [
|
|
@@ -105,14 +201,10 @@ class MainWindow:
|
|
|
105
201
|
[level, title, description, item],
|
|
106
202
|
]
|
|
107
203
|
|
|
108
|
-
def init(self) -> dict:
|
|
109
|
-
return dict(self.values)
|
|
110
|
-
|
|
111
204
|
def pin(self, state: bool):
|
|
112
205
|
state = bool(state)
|
|
113
|
-
self.values.set("system_pin", state
|
|
114
|
-
|
|
115
|
-
self.api.set_on_top(state)
|
|
206
|
+
self.values.set("system_pin", state)
|
|
207
|
+
self.set_on_top(state)
|
|
116
208
|
return state
|
|
117
209
|
|
|
118
210
|
def syncValue(self, key, value):
|
|
@@ -151,26 +243,249 @@ class MainWindow:
|
|
|
151
243
|
|
|
152
244
|
return root_candidate.resolve()
|
|
153
245
|
|
|
154
|
-
def
|
|
246
|
+
def _current_title(self):
|
|
247
|
+
return self.values.get("system_title", self._title)
|
|
248
|
+
|
|
249
|
+
def _entry_path(self) -> Path:
|
|
250
|
+
entry = (Path(self.packagePath) / "index.html").resolve()
|
|
251
|
+
if not entry.is_file():
|
|
252
|
+
raise FileNotFoundError(f"Frontend entry not found: {entry}")
|
|
253
|
+
return entry
|
|
254
|
+
|
|
255
|
+
def _start_ui_server(self):
|
|
256
|
+
self._ui_server = _UiHttpServer((self._ui_bind_host, 0), _UiRequestHandler, self)
|
|
257
|
+
self._ui_origin = f"http://{self._ui_origin_host}:{self._ui_server.server_port}"
|
|
258
|
+
self._ui_server_thread = threading.Thread(target=self._ui_server.serve_forever, daemon=True)
|
|
259
|
+
self._ui_server_thread.start()
|
|
260
|
+
|
|
261
|
+
def _configure_webview2_origin_identity(self):
|
|
262
|
+
if self._ui_origin_host == self._ui_bind_host:
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
parsed = urlparse(self._ui_origin)
|
|
266
|
+
if not parsed.scheme or parsed.port is None:
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
required_args = [
|
|
270
|
+
f'--host-resolver-rules="MAP {self._ui_origin_host} {self._ui_bind_host}"',
|
|
271
|
+
f"--unsafely-treat-insecure-origin-as-secure={parsed.scheme}://{self._ui_origin_host}:{parsed.port}",
|
|
272
|
+
]
|
|
273
|
+
existing = os.environ.get("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS", "").strip()
|
|
274
|
+
missing = [arg for arg in required_args if arg not in existing]
|
|
275
|
+
if missing:
|
|
276
|
+
os.environ["WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS"] = " ".join(
|
|
277
|
+
[value for value in [existing, *missing] if value]
|
|
278
|
+
).strip()
|
|
279
|
+
|
|
280
|
+
def _stop_ui_server(self):
|
|
281
|
+
if self._ui_server is None:
|
|
282
|
+
return
|
|
283
|
+
try:
|
|
284
|
+
self._ui_server.shutdown()
|
|
285
|
+
self._ui_server.server_close()
|
|
286
|
+
except Exception:
|
|
287
|
+
core_logger.debug("Failed to stop UI server", exc_info=True)
|
|
288
|
+
finally:
|
|
289
|
+
self._ui_server = None
|
|
290
|
+
self._ui_server_thread = None
|
|
291
|
+
|
|
292
|
+
def _resolve_server_path(self, raw_path: str) -> Path | None:
|
|
293
|
+
path = unquote(raw_path or "/")
|
|
294
|
+
if path in {"", "/"}:
|
|
295
|
+
return self._entry_path()
|
|
296
|
+
|
|
297
|
+
for name, base_path in self._resource_roots:
|
|
298
|
+
if name == "package":
|
|
299
|
+
continue
|
|
300
|
+
|
|
301
|
+
prefix = f"/__{name}__/"
|
|
302
|
+
if not path.startswith(prefix):
|
|
303
|
+
continue
|
|
304
|
+
|
|
305
|
+
relative = path[len(prefix) :]
|
|
306
|
+
candidate = (base_path / relative).resolve()
|
|
307
|
+
try:
|
|
308
|
+
candidate.relative_to(base_path)
|
|
309
|
+
except ValueError:
|
|
310
|
+
return None
|
|
311
|
+
return candidate
|
|
312
|
+
|
|
313
|
+
candidate = (self.packagePath / path.lstrip("/")).resolve()
|
|
314
|
+
try:
|
|
315
|
+
candidate.relative_to(self.packagePath)
|
|
316
|
+
except ValueError:
|
|
317
|
+
return None
|
|
318
|
+
return candidate
|
|
319
|
+
|
|
320
|
+
def _resource_url(self, path: Path) -> str:
|
|
321
|
+
path = path.resolve()
|
|
322
|
+
for name, base_path in self._resource_roots:
|
|
323
|
+
try:
|
|
324
|
+
relative = path.relative_to(base_path)
|
|
325
|
+
except ValueError:
|
|
326
|
+
continue
|
|
327
|
+
|
|
328
|
+
if name == "package":
|
|
329
|
+
return f"{self._ui_origin}/{relative.as_posix()}"
|
|
330
|
+
return f"{self._ui_origin}/__{name}__/{relative.as_posix()}"
|
|
331
|
+
|
|
332
|
+
raise ValueError(f"Path is outside of registered resource roots: {path}")
|
|
333
|
+
|
|
334
|
+
def _before_show(self):
|
|
335
|
+
try:
|
|
336
|
+
hwnd = self._window.native.Handle.ToInt64()
|
|
337
|
+
title_bar.hide(hwnd)
|
|
338
|
+
except Exception:
|
|
339
|
+
core_logger.debug("Failed to hide title bar", exc_info=True)
|
|
340
|
+
|
|
341
|
+
def _on_closed(self):
|
|
342
|
+
self._stop_ui_server()
|
|
343
|
+
self.events.closed.set()
|
|
344
|
+
|
|
345
|
+
def _dispatch_or_defer_sync(self, key: str, value):
|
|
346
|
+
with self._sync_lock:
|
|
347
|
+
if not self._frontend_ready:
|
|
348
|
+
self._pending_sync[key] = value
|
|
349
|
+
return
|
|
350
|
+
|
|
351
|
+
self._dispatch_sync_value(key, value)
|
|
352
|
+
|
|
353
|
+
def _dispatch_sync_value(self, key: str, value):
|
|
354
|
+
if key == "system_title":
|
|
355
|
+
try:
|
|
356
|
+
self._window.title = str(value or self._title)
|
|
357
|
+
except Exception:
|
|
358
|
+
core_logger.debug("Failed to update window title", exc_info=True)
|
|
359
|
+
|
|
360
|
+
script = f"window.syncValue({json.dumps(key, ensure_ascii=False)}, {json.dumps(value, ensure_ascii=False)}, false)"
|
|
361
|
+
try:
|
|
362
|
+
self._window.evaluate_js(script)
|
|
363
|
+
except Exception:
|
|
364
|
+
with self._sync_lock:
|
|
365
|
+
self._pending_sync[key] = value
|
|
366
|
+
core_logger.debug("Failed to sync value %s", key, exc_info=True)
|
|
367
|
+
|
|
368
|
+
def queue_sync_value(self, key: str, value):
|
|
369
|
+
self._dispatch_or_defer_sync(key, value)
|
|
370
|
+
|
|
371
|
+
def _init_payload(self):
|
|
372
|
+
return dict(self.values)
|
|
373
|
+
|
|
374
|
+
def _sync_values(self, values: dict[str, object]):
|
|
375
|
+
if not isinstance(values, dict):
|
|
376
|
+
return
|
|
377
|
+
|
|
378
|
+
for key, value in values.items():
|
|
379
|
+
self.syncValue(key, value)
|
|
380
|
+
|
|
381
|
+
def _frontend_ready_callback(self):
|
|
382
|
+
if self._frontend_ready:
|
|
383
|
+
return
|
|
384
|
+
|
|
385
|
+
self._frontend_ready = True
|
|
386
|
+
|
|
387
|
+
with self._sync_lock:
|
|
388
|
+
pending_sync = tuple(self._pending_sync.items())
|
|
389
|
+
self._pending_sync.clear()
|
|
390
|
+
|
|
391
|
+
for key, value in pending_sync:
|
|
392
|
+
self._dispatch_sync_value(key, value)
|
|
393
|
+
|
|
394
|
+
if not self._setup_fired:
|
|
395
|
+
self._setup_fired = True
|
|
396
|
+
self.events.windowReady.set()
|
|
397
|
+
|
|
398
|
+
def set_on_top(self, state: bool):
|
|
399
|
+
state = bool(state)
|
|
400
|
+
try:
|
|
401
|
+
native = getattr(self._window, "native", None)
|
|
402
|
+
if native is None:
|
|
403
|
+
self._window.on_top = state
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
action_type = getattr(importlib.import_module("System"), "Action")
|
|
407
|
+
|
|
408
|
+
def _apply():
|
|
409
|
+
self._window.on_top = state
|
|
410
|
+
|
|
411
|
+
if native.InvokeRequired:
|
|
412
|
+
native.BeginInvoke(action_type(_apply))
|
|
413
|
+
else:
|
|
414
|
+
_apply()
|
|
415
|
+
except Exception:
|
|
416
|
+
core_logger.debug("Failed to set window on top", exc_info=True)
|
|
417
|
+
|
|
418
|
+
def get_window_size(self) -> tuple[int, int]:
|
|
419
|
+
width = int(getattr(self._window, "initial_width", DEFAULT_WINDOW_WIDTH))
|
|
420
|
+
height = int(getattr(self._window, "initial_height", DEFAULT_WINDOW_HEIGHT))
|
|
421
|
+
|
|
422
|
+
try:
|
|
423
|
+
if self._window.events.shown.is_set():
|
|
424
|
+
width = int(self._window.width)
|
|
425
|
+
height = int(self._window.height)
|
|
426
|
+
except Exception:
|
|
427
|
+
core_logger.debug("Failed to read window size", exc_info=True)
|
|
428
|
+
|
|
429
|
+
return width, height
|
|
430
|
+
|
|
431
|
+
def resolveResource(self, value: str):
|
|
155
432
|
if not isinstance(value, (str, Path)):
|
|
156
|
-
return
|
|
433
|
+
return ""
|
|
157
434
|
|
|
158
435
|
raw_value = str(value).strip()
|
|
159
436
|
if not raw_value:
|
|
160
|
-
return
|
|
437
|
+
return ""
|
|
161
438
|
|
|
162
439
|
lowered = raw_value.lower()
|
|
163
|
-
if lowered.startswith(("http://", "https://", "file://", "data:", "
|
|
440
|
+
if lowered.startswith(("http://", "https://", "file://", "data:", "about:")):
|
|
164
441
|
return raw_value
|
|
165
442
|
|
|
166
|
-
|
|
167
|
-
|
|
443
|
+
parsed = urlparse(raw_value)
|
|
444
|
+
path_value = parsed.path or raw_value
|
|
445
|
+
resolved = self.resolve_path(path_value)
|
|
446
|
+
if resolved is None or not resolved.exists() or not resolved.is_file():
|
|
168
447
|
return raw_value
|
|
169
448
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
449
|
+
resource_url = self._resource_url(resolved)
|
|
450
|
+
if not parsed.scheme and (parsed.query or parsed.fragment):
|
|
451
|
+
resource_parts = urlparse(resource_url)
|
|
452
|
+
resource_url = urlunparse(
|
|
453
|
+
(
|
|
454
|
+
resource_parts.scheme,
|
|
455
|
+
resource_parts.netloc,
|
|
456
|
+
resource_parts.path,
|
|
457
|
+
resource_parts.params,
|
|
458
|
+
parsed.query,
|
|
459
|
+
parsed.fragment,
|
|
460
|
+
)
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
return resource_url
|
|
464
|
+
|
|
465
|
+
def start(
|
|
466
|
+
self,
|
|
467
|
+
debug: bool = False,
|
|
468
|
+
*,
|
|
469
|
+
hidden: bool = False,
|
|
470
|
+
on_top: bool | None = None,
|
|
471
|
+
width: int | None = None,
|
|
472
|
+
height: int | None = None,
|
|
473
|
+
min_width=900,
|
|
474
|
+
min_height=600,
|
|
475
|
+
):
|
|
175
476
|
self.accent.start()
|
|
176
|
-
self.
|
|
477
|
+
self._minimum_width = max(1, int(min_width))
|
|
478
|
+
self._minimum_height = max(1, int(min_height))
|
|
479
|
+
self._window.min_size = (self._minimum_width, self._minimum_height)
|
|
480
|
+
self._window.hidden = bool(hidden)
|
|
481
|
+
|
|
482
|
+
if width is not None and height is not None:
|
|
483
|
+
width = max(self._minimum_width, int(width))
|
|
484
|
+
height = max(self._minimum_height, int(height))
|
|
485
|
+
self._window.initial_width = width
|
|
486
|
+
self._window.initial_height = height
|
|
487
|
+
|
|
488
|
+
if on_top is not None:
|
|
489
|
+
self._window.on_top = bool(on_top)
|
|
490
|
+
|
|
491
|
+
webview.start(debug=debug, gui="edgechromium")
|
pywebwinui3/event.py
CHANGED
|
@@ -3,7 +3,6 @@ from __future__ import annotations
|
|
|
3
3
|
import re
|
|
4
4
|
import fnmatch
|
|
5
5
|
import logging
|
|
6
|
-
import traceback
|
|
7
6
|
from typing import Any, Callable
|
|
8
7
|
|
|
9
8
|
logger = logging.getLogger("pywebwinui3.eventmanager")
|
|
@@ -13,31 +12,31 @@ class Event:
|
|
|
13
12
|
self.items: list[Callable[..., Any]] = []
|
|
14
13
|
|
|
15
14
|
def set(self, *args: Any):
|
|
16
|
-
|
|
17
|
-
if not listeners:
|
|
18
|
-
return
|
|
19
|
-
|
|
20
|
-
for func in listeners:
|
|
15
|
+
for func in tuple(self.items):
|
|
21
16
|
try:
|
|
22
17
|
func(*args)
|
|
23
18
|
except Exception:
|
|
24
|
-
logger.
|
|
19
|
+
logger.exception("Event callback failed")
|
|
25
20
|
|
|
26
|
-
def
|
|
21
|
+
def _add(self, item: Callable[..., Any]):
|
|
27
22
|
self.items.append(item)
|
|
28
23
|
return self
|
|
29
24
|
|
|
30
|
-
def
|
|
25
|
+
def _remove(self, item: Callable[..., Any]):
|
|
31
26
|
self.items.remove(item)
|
|
32
27
|
return self
|
|
33
28
|
|
|
29
|
+
def __add__(self, item: Callable[..., Any]):
|
|
30
|
+
return self._add(item)
|
|
31
|
+
|
|
32
|
+
def __sub__(self, item: Callable[..., Any]):
|
|
33
|
+
return self._remove(item)
|
|
34
|
+
|
|
34
35
|
def __iadd__(self, item: Callable[..., Any]):
|
|
35
|
-
self.
|
|
36
|
-
return self
|
|
36
|
+
return self._add(item)
|
|
37
37
|
|
|
38
38
|
def __isub__(self, item: Callable[..., Any]):
|
|
39
|
-
self.
|
|
40
|
-
return self
|
|
39
|
+
return self._remove(item)
|
|
41
40
|
|
|
42
41
|
def __len__(self) -> int:
|
|
43
42
|
return len(self.items)
|
|
@@ -78,7 +77,7 @@ class PathEvent:
|
|
|
78
77
|
if self._compile_pattern(key).match(target):
|
|
79
78
|
matched_events.append(event)
|
|
80
79
|
except Exception:
|
|
81
|
-
logger.
|
|
80
|
+
logger.exception("Pattern match failed")
|
|
82
81
|
|
|
83
82
|
result = tuple(matched_events)
|
|
84
83
|
self._pattern_cache[target] = result
|
|
@@ -93,55 +92,42 @@ class PathEvent:
|
|
|
93
92
|
try:
|
|
94
93
|
exact_event.set(target, *args)
|
|
95
94
|
except Exception:
|
|
96
|
-
logger.
|
|
95
|
+
logger.exception("Exact path callback failed")
|
|
97
96
|
|
|
98
97
|
for event in self._get_pattern_events(target):
|
|
99
98
|
try:
|
|
100
99
|
event.set(target, *args)
|
|
101
100
|
except Exception:
|
|
102
|
-
logger.
|
|
101
|
+
logger.exception("Pattern path callback failed")
|
|
103
102
|
|
|
104
|
-
def
|
|
103
|
+
def _modify(self, item: list, remove: bool):
|
|
105
104
|
target, callback = item[0], item[1]
|
|
106
105
|
bucket = self._bucket(target)
|
|
107
|
-
bucket.setdefault(target, Event())
|
|
106
|
+
event = bucket.setdefault(target, Event())
|
|
108
107
|
|
|
109
|
-
if
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
return self
|
|
114
|
-
|
|
115
|
-
def __sub__(self, item: list):
|
|
116
|
-
target, callback = item[0], item[1]
|
|
117
|
-
bucket = self._bucket(target)
|
|
118
|
-
bucket.setdefault(target, Event()).__isub__(callback)
|
|
108
|
+
if remove:
|
|
109
|
+
event -= callback
|
|
110
|
+
else:
|
|
111
|
+
event += callback
|
|
119
112
|
|
|
120
113
|
if self._is_pattern(target):
|
|
114
|
+
if not remove:
|
|
115
|
+
self._compiled_patterns.setdefault(target, self._compile_pattern(target))
|
|
121
116
|
self._clear_pattern_cache()
|
|
122
117
|
|
|
123
118
|
return self
|
|
124
119
|
|
|
125
|
-
def
|
|
126
|
-
|
|
127
|
-
bucket = self._bucket(target)
|
|
128
|
-
bucket.setdefault(target, Event()).__iadd__(callback)
|
|
120
|
+
def __add__(self, item: list):
|
|
121
|
+
return self._modify(item, False)
|
|
129
122
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
self._clear_pattern_cache()
|
|
123
|
+
def __sub__(self, item: list):
|
|
124
|
+
return self._modify(item, True)
|
|
133
125
|
|
|
134
|
-
|
|
126
|
+
def __iadd__(self, item: list):
|
|
127
|
+
return self._modify(item, False)
|
|
135
128
|
|
|
136
129
|
def __isub__(self, item: list):
|
|
137
|
-
|
|
138
|
-
bucket = self._bucket(target)
|
|
139
|
-
bucket.setdefault(target, Event()).__isub__(callback)
|
|
140
|
-
|
|
141
|
-
if self._is_pattern(target):
|
|
142
|
-
self._clear_pattern_cache()
|
|
143
|
-
|
|
144
|
-
return self
|
|
130
|
+
return self._modify(item, True)
|
|
145
131
|
|
|
146
132
|
def __len__(self) -> int:
|
|
147
|
-
return len(self.exact_items) + len(self.pattern_items)
|
|
133
|
+
return len(self.exact_items) + len(self.pattern_items)
|
pywebwinui3/util.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import xml.etree.ElementTree
|
|
2
|
+
from functools import lru_cache
|
|
2
3
|
from typing import Any, Callable
|
|
3
4
|
import winreg
|
|
4
5
|
import logging
|
|
@@ -37,6 +38,7 @@ class SyncDict(dict):
|
|
|
37
38
|
self.sync = sync
|
|
38
39
|
|
|
39
40
|
@staticmethod
|
|
41
|
+
@lru_cache(maxsize=512)
|
|
40
42
|
def _parsePath(key:str):
|
|
41
43
|
if not isinstance(key, str) or "[" not in key:
|
|
42
44
|
return None
|
|
@@ -159,9 +161,7 @@ class SyncDict(dict):
|
|
|
159
161
|
class AccentColorWatcher:
|
|
160
162
|
def __init__(self, event:Event=None):
|
|
161
163
|
self.event = event or Event()
|
|
162
|
-
self.theme_event = Event()
|
|
163
164
|
self.palette = self.getSystemAccentColor()
|
|
164
|
-
self.theme = self.getSystemTheme()
|
|
165
165
|
|
|
166
166
|
@staticmethod
|
|
167
167
|
def getSystemAccentColor():
|
|
@@ -172,22 +172,10 @@ class AccentColorWatcher:
|
|
|
172
172
|
return DEFAULT_ACCENT_PALETTE.copy()
|
|
173
173
|
return [f"#{p[i]:02x}{p[i+1]:02x}{p[i+2]:02x}" for i in range(0,len(p),4)]
|
|
174
174
|
|
|
175
|
-
@staticmethod
|
|
176
|
-
def getSystemTheme():
|
|
177
|
-
try:
|
|
178
|
-
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize") as key:
|
|
179
|
-
t, _ = winreg.QueryValueEx(key, "AppsUseLightTheme")
|
|
180
|
-
except OSError:
|
|
181
|
-
return "dark"
|
|
182
|
-
return "light" if t else "dark"
|
|
183
|
-
|
|
184
175
|
def refresh(self):
|
|
185
176
|
if self.palette != (color := self.getSystemAccentColor()):
|
|
186
177
|
self.palette = color
|
|
187
178
|
self.event.set(self.palette)
|
|
188
|
-
if self.theme != (theme := self.getSystemTheme()):
|
|
189
|
-
self.theme = theme
|
|
190
|
-
self.theme_event.set(self.theme)
|
|
191
179
|
|
|
192
180
|
def start(self):
|
|
193
181
|
self.refresh()
|