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.
Files changed (39) hide show
  1. pywebwinui3/__init__.py +1 -1
  2. pywebwinui3/core.py +376 -61
  3. pywebwinui3/event.py +31 -45
  4. pywebwinui3/util.py +2 -14
  5. pywebwinui3/web/_app/immutable/assets/2.CgNWjm1R.css +1 -0
  6. pywebwinui3/web/_app/immutable/chunks/6pQH9Hkv.js +1 -0
  7. pywebwinui3/web/_app/immutable/chunks/{CEpW9fsN.js → B-XLOWlt.js} +1 -1
  8. pywebwinui3/web/_app/immutable/chunks/{CLgPPBq9.js → BF2c23Wg.js} +1 -1
  9. pywebwinui3/web/_app/immutable/chunks/CGGCsFu4.js +1 -0
  10. pywebwinui3/web/_app/immutable/chunks/CW0wlPPy.js +1 -0
  11. pywebwinui3/web/_app/immutable/chunks/CZPLFskb.js +1 -0
  12. pywebwinui3/web/_app/immutable/chunks/CZuXpvpo.js +2 -0
  13. pywebwinui3/web/_app/immutable/chunks/{DQYyt0Qv.js → DF1x2LYX.js} +1 -1
  14. pywebwinui3/web/_app/immutable/entry/app.C_Ap8SyA.js +2 -0
  15. pywebwinui3/web/_app/immutable/entry/start.BBET_QR9.js +1 -0
  16. pywebwinui3/web/_app/immutable/nodes/0.C49PHrK2.js +54 -0
  17. pywebwinui3/web/_app/immutable/nodes/1.gP9JsMWl.js +1 -0
  18. pywebwinui3/web/_app/immutable/nodes/2.SvYNzG6D.js +99 -0
  19. pywebwinui3/web/_app/version.json +1 -1
  20. pywebwinui3/web/index.html +17 -18
  21. {pywebwinui3-1.0.3.dist-info → pywebwinui3-1.2.0.dist-info}/METADATA +29 -25
  22. {pywebwinui3-1.0.3.dist-info → pywebwinui3-1.2.0.dist-info}/RECORD +26 -28
  23. pywebwinui3/qt.py +0 -887
  24. pywebwinui3/web/_app/immutable/assets/2.dPO3j65h.css +0 -1
  25. pywebwinui3/web/_app/immutable/chunks/B8k5Ccj7.js +0 -1
  26. pywebwinui3/web/_app/immutable/chunks/BHMqm3g9.js +0 -2
  27. pywebwinui3/web/_app/immutable/chunks/Cd1tg9Ma.js +0 -1
  28. pywebwinui3/web/_app/immutable/chunks/DNMsE6fi.js +0 -1
  29. pywebwinui3/web/_app/immutable/chunks/DbLPBgK4.js +0 -1
  30. pywebwinui3/web/_app/immutable/chunks/fGsU50sd.js +0 -1
  31. pywebwinui3/web/_app/immutable/entry/app.D0i2RG-S.js +0 -2
  32. pywebwinui3/web/_app/immutable/entry/start.dJKMwvgv.js +0 -1
  33. pywebwinui3/web/_app/immutable/nodes/0.CqNsePPe.js +0 -54
  34. pywebwinui3/web/_app/immutable/nodes/1.GqMcuUYV.js +0 -1
  35. pywebwinui3/web/_app/immutable/nodes/2.D0-SBIC3.js +0 -94
  36. {pywebwinui3-1.0.3.dist-info → pywebwinui3-1.2.0.dist-info}/WHEEL +0 -0
  37. {pywebwinui3-1.0.3.dist-info → pywebwinui3-1.2.0.dist-info}/licenses/LICENSE +0 -0
  38. {pywebwinui3-1.0.3.dist-info → pywebwinui3-1.2.0.dist-info}/licenses/NOTICE +0 -0
  39. {pywebwinui3-1.0.3.dist-info → pywebwinui3-1.2.0.dist-info}/top_level.txt +0 -0
pywebwinui3/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  __all__ = ["core", "event", "util", "type"]
2
2
 
3
- __version__ = '1.0.3'
3
+ __version__ = '1.2.0'
pywebwinui3/core.py CHANGED
@@ -1,24 +1,101 @@
1
1
  from __future__ import annotations
2
2
 
3
- import inspect
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 typing import TYPE_CHECKING
14
+ from urllib.parse import unquote, urlparse, urlunparse
7
15
 
8
- from .event import Event
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
- ABSOLUTE_WINDOW_MIN_WIDTH = 1
21
- ABSOLUTE_WINDOW_MIN_HEIGHT = 1
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.themeChange = Event()
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(inspect.currentframe().f_back.f_code.co_filename).parent.resolve()
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 = WebviewAPI(self, self._title, self._icon)
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.api.queue_sync_value
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
- return decorator
73
-
74
- def onAccentColorChange(self):
176
+ def _event_decorator(self, event: Event | PathEvent, value=None):
75
177
  def decorator(func):
76
- self.events.accentColorChange += func
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 onThemeChange(self):
82
- def decorator(func):
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
- return decorator
189
+ def onAccentColorChange(self):
190
+ return self._event_decorator(self.events.accentColorChange)
87
191
 
88
192
  def onSetup(self):
89
- def decorator(func):
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
- def decorator(func):
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, self.api is not None)
114
- if self.api is not None:
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 resolve_resource_url(self, value):
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 value
433
+ return ""
157
434
 
158
435
  raw_value = str(value).strip()
159
436
  if not raw_value:
160
- return value
437
+ return ""
161
438
 
162
439
  lowered = raw_value.lower()
163
- if lowered.startswith(("http://", "https://", "file://", "data:", "qrc://", "qrc:", "about:")):
440
+ if lowered.startswith(("http://", "https://", "file://", "data:", "about:")):
164
441
  return raw_value
165
442
 
166
- resolved = self.resolve_path(raw_value)
167
- if resolved is None or not resolved.exists():
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
- return resolved.as_uri()
171
-
172
- def start(self, debug: bool = False, min_width=900, min_height=600):
173
- # if self.api is not None and getattr(self.api, "_window", None) is not None:
174
- self.api.set_window_minimum_size(min_width, min_height)
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.api.start(debug=debug)
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
- listeners = tuple(self.items)
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.error(traceback.format_exc())
19
+ logger.exception("Event callback failed")
25
20
 
26
- def __add__(self, item: Callable[..., Any]):
21
+ def _add(self, item: Callable[..., Any]):
27
22
  self.items.append(item)
28
23
  return self
29
24
 
30
- def __sub__(self, item: Callable[..., Any]):
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.items.append(item)
36
- return self
36
+ return self._add(item)
37
37
 
38
38
  def __isub__(self, item: Callable[..., Any]):
39
- self.items.remove(item)
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.error(traceback.format_exc())
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.error(traceback.format_exc())
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.error(traceback.format_exc())
101
+ logger.exception("Pattern path callback failed")
103
102
 
104
- def __add__(self, item: list):
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()).__iadd__(callback)
106
+ event = bucket.setdefault(target, Event())
108
107
 
109
- if self._is_pattern(target):
110
- self._compiled_patterns.setdefault(target, self._compile_pattern(target))
111
- self._clear_pattern_cache()
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 __iadd__(self, item: list):
126
- target, callback = item[0], item[1]
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
- if self._is_pattern(target):
131
- self._compiled_patterns.setdefault(target, self._compile_pattern(target))
132
- self._clear_pattern_cache()
123
+ def __sub__(self, item: list):
124
+ return self._modify(item, True)
133
125
 
134
- return self
126
+ def __iadd__(self, item: list):
127
+ return self._modify(item, False)
135
128
 
136
129
  def __isub__(self, item: list):
137
- target, callback = item[0], item[1]
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()