pyweft 0.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.
- pyweft/__init__.py +60 -0
- pyweft/__main__.py +4 -0
- pyweft/background.py +203 -0
- pyweft/binding.py +118 -0
- pyweft/cli.py +1006 -0
- pyweft/component.py +487 -0
- pyweft/desktop.py +606 -0
- pyweft/dialogs.py +123 -0
- pyweft/differ.py +195 -0
- pyweft/dommodel.py +63 -0
- pyweft/effects.py +191 -0
- pyweft/events.py +102 -0
- pyweft/expressions.py +184 -0
- pyweft/hotreload.py +122 -0
- pyweft/packaging.py +186 -0
- pyweft/parser.py +426 -0
- pyweft/prerender.py +121 -0
- pyweft/renderer.py +802 -0
- pyweft/routing.py +364 -0
- pyweft/static/loading.html +102 -0
- pyweft/static/runtime.js +341 -0
- pyweft/static/shell.html +693 -0
- pyweft/static/worker.js +89 -0
- pyweft/store.py +118 -0
- pyweft/styles.py +171 -0
- pyweft/vdom.py +57 -0
- pyweft-0.2.0.dist-info/METADATA +324 -0
- pyweft-0.2.0.dist-info/RECORD +31 -0
- pyweft-0.2.0.dist-info/WHEEL +4 -0
- pyweft-0.2.0.dist-info/entry_points.txt +2 -0
- pyweft-0.2.0.dist-info/licenses/LICENSE +21 -0
pyweft/desktop.py
ADDED
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
"""The desktop host: an app in a native window, Python running as ordinary
|
|
2
|
+
CPython on the user's machine (``pyweft run APP --desktop``).
|
|
3
|
+
|
|
4
|
+
The window is a webview (pywebview: WebView2 on Windows, WebKit on macOS,
|
|
5
|
+
GTK or Qt on Linux) showing a small page with the same runtime.js the
|
|
6
|
+
browser uses. This file is the bridge the architecture was built for: the
|
|
7
|
+
core's patch ops cross pywebview's JavaScript bridge as JSON, and events come
|
|
8
|
+
back the same way. Nothing in the core changes.
|
|
9
|
+
|
|
10
|
+
Threads. pywebview's GUI loop owns the main thread; its JavaScript-to-Python
|
|
11
|
+
calls arrive on threads of their own. Every piece of app code runs on one
|
|
12
|
+
asyncio loop thread instead, one thing at a time, as in the browser: the
|
|
13
|
+
bridge only hands work to that loop. Python then drives each cycle itself,
|
|
14
|
+
as shell.html does in the browser: render, push the ops (evaluate_js waits
|
|
15
|
+
until they're applied), settle effects, push focus requests.
|
|
16
|
+
|
|
17
|
+
Host-only, like cli.py: never shipped to the browser.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import concurrent.futures
|
|
24
|
+
import json
|
|
25
|
+
import re
|
|
26
|
+
import sys
|
|
27
|
+
import threading
|
|
28
|
+
import traceback
|
|
29
|
+
from html import escape
|
|
30
|
+
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, Callable
|
|
33
|
+
from urllib.parse import unquote
|
|
34
|
+
|
|
35
|
+
from . import dialogs, routing
|
|
36
|
+
from .component import load_component
|
|
37
|
+
from .differ import diff
|
|
38
|
+
from .hotreload import format_error, hot_swap
|
|
39
|
+
from .renderer import Renderer
|
|
40
|
+
from .styles import collect_styles
|
|
41
|
+
from .vdom import to_dicts
|
|
42
|
+
|
|
43
|
+
_STATIC = Path(__file__).parent / "static"
|
|
44
|
+
|
|
45
|
+
_PAGE = """<!DOCTYPE html>
|
|
46
|
+
<html lang="en">
|
|
47
|
+
<head>
|
|
48
|
+
<meta charset="utf-8" />
|
|
49
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
50
|
+
<title>__TITLE__</title>
|
|
51
|
+
<style>
|
|
52
|
+
#pyweft-dev-error {
|
|
53
|
+
position: fixed; left: 1rem; right: 1rem; bottom: 1rem; z-index: 2147483647;
|
|
54
|
+
max-height: 50vh; overflow: auto; padding: 0.9rem 1.1rem;
|
|
55
|
+
border-left: 4px solid #e5484d; border-radius: 8px;
|
|
56
|
+
background: #2b1213; color: #ffd8d8; white-space: pre-wrap;
|
|
57
|
+
font: 13px/1.5 ui-monospace, SFMono-Regular, Consolas, monospace;
|
|
58
|
+
}
|
|
59
|
+
</style>
|
|
60
|
+
__HEAD__
|
|
61
|
+
__CSS__
|
|
62
|
+
<style id="pyweft-component-styles"></style>
|
|
63
|
+
<script src="runtime.js"></script>
|
|
64
|
+
</head>
|
|
65
|
+
<body>
|
|
66
|
+
<div id="app"></div>
|
|
67
|
+
<script>
|
|
68
|
+
// The desktop bridge: events go to Python through pywebview's js_api;
|
|
69
|
+
// Python calls apply()/commands() back with evaluate_js.
|
|
70
|
+
const pyweftDesktop = (() => {
|
|
71
|
+
let sent = 0; // events handed to Python
|
|
72
|
+
let done = 0; // events Python has rendered
|
|
73
|
+
// Still typing: the input's latest keystrokes haven't come back yet.
|
|
74
|
+
PyWeftRuntime.options.typing = (el) => sent > done && el === document.activeElement;
|
|
75
|
+
|
|
76
|
+
async function start() {
|
|
77
|
+
const api = window.pywebview.api;
|
|
78
|
+
const here = location.hash.startsWith("#/") ? location.hash.slice(1) : "/";
|
|
79
|
+
const first = await api.mount(here);
|
|
80
|
+
setCss(first.css);
|
|
81
|
+
PyWeftRuntime.init(document.getElementById("app"), (id, event) => {
|
|
82
|
+
sent += 1;
|
|
83
|
+
api.dispatch(id, JSON.stringify(event), sent);
|
|
84
|
+
});
|
|
85
|
+
PyWeftRuntime.mount(first.tree);
|
|
86
|
+
window.addEventListener("hashchange", () => {
|
|
87
|
+
if (!location.hash.startsWith("#/")) return; // an in-page anchor
|
|
88
|
+
api.location(location.hash.slice(1));
|
|
89
|
+
window.scrollTo(0, 0);
|
|
90
|
+
});
|
|
91
|
+
await api.mounted();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function setCss(text) {
|
|
95
|
+
document.getElementById("pyweft-component-styles").textContent = text;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
window.addEventListener("pywebviewready", start);
|
|
99
|
+
return {
|
|
100
|
+
apply(ops, seq) {
|
|
101
|
+
PyWeftRuntime.applyOps(ops);
|
|
102
|
+
if (seq) done = Math.max(done, seq);
|
|
103
|
+
},
|
|
104
|
+
commands: (list) => PyWeftRuntime.runCommands(list),
|
|
105
|
+
css: setCss,
|
|
106
|
+
error(text) {
|
|
107
|
+
let el = document.getElementById("pyweft-dev-error");
|
|
108
|
+
if (!el) {
|
|
109
|
+
el = document.createElement("div");
|
|
110
|
+
el.id = "pyweft-dev-error";
|
|
111
|
+
document.body.appendChild(el);
|
|
112
|
+
}
|
|
113
|
+
el.textContent = text;
|
|
114
|
+
},
|
|
115
|
+
clearError: () => document.getElementById("pyweft-dev-error")?.remove(),
|
|
116
|
+
refreshCss(name) {
|
|
117
|
+
for (const link of document.querySelectorAll("link[rel=stylesheet]")) {
|
|
118
|
+
if (link.getAttribute("href").split("?")[0] === "app/" + name) {
|
|
119
|
+
link.href = "app/" + name + "?v=" + Date.now();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
})();
|
|
125
|
+
</script>
|
|
126
|
+
</body>
|
|
127
|
+
</html>
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class _Location:
|
|
132
|
+
"""routing's host: the location after the # in the window's address."""
|
|
133
|
+
|
|
134
|
+
def __init__(self, app: DesktopApp):
|
|
135
|
+
self.app = app
|
|
136
|
+
|
|
137
|
+
def current(self) -> str:
|
|
138
|
+
return self.app.here
|
|
139
|
+
|
|
140
|
+
def push(self, url: str) -> None:
|
|
141
|
+
self.app.here = url
|
|
142
|
+
self.app.js(f"location.hash = {json.dumps(url)}")
|
|
143
|
+
|
|
144
|
+
def replace(self, url: str) -> None:
|
|
145
|
+
self.app.here = url
|
|
146
|
+
self.app.js(f"history.replaceState(null, '', {json.dumps('#' + url)})")
|
|
147
|
+
|
|
148
|
+
def back(self) -> None:
|
|
149
|
+
self.app.js("history.back()") # comes back through hashchange
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class _Dialogs:
|
|
153
|
+
"""pyweft.dialogs in a window: the system's own dialogs. They wait for
|
|
154
|
+
the user, so they run off the loop thread and the app keeps going."""
|
|
155
|
+
|
|
156
|
+
def __init__(self, app: DesktopApp):
|
|
157
|
+
self.app = app
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def _file_types(types: list[str]) -> tuple[str, ...]:
|
|
161
|
+
if not types:
|
|
162
|
+
return ()
|
|
163
|
+
patterns = ";".join(f"*.{ext}" for ext in types)
|
|
164
|
+
return (f"{', '.join(types).upper()} files ({patterns})", "All files (*.*)")
|
|
165
|
+
|
|
166
|
+
async def open(self, types: list[str], multiple: bool) -> list[tuple]:
|
|
167
|
+
import webview
|
|
168
|
+
|
|
169
|
+
paths = await asyncio.to_thread(
|
|
170
|
+
self.app.window.create_file_dialog,
|
|
171
|
+
webview.FileDialog.OPEN,
|
|
172
|
+
allow_multiple=multiple,
|
|
173
|
+
file_types=self._file_types(types),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def read() -> list[tuple]:
|
|
177
|
+
return [(Path(p).name, Path(p).read_bytes(), str(p)) for p in paths or ()]
|
|
178
|
+
|
|
179
|
+
return await asyncio.to_thread(read)
|
|
180
|
+
|
|
181
|
+
async def save(self, name: str, data: bytes, types: list[str]) -> bool:
|
|
182
|
+
import webview
|
|
183
|
+
|
|
184
|
+
chosen = await asyncio.to_thread(
|
|
185
|
+
self.app.window.create_file_dialog,
|
|
186
|
+
webview.FileDialog.SAVE,
|
|
187
|
+
save_filename=name,
|
|
188
|
+
file_types=self._file_types(types),
|
|
189
|
+
)
|
|
190
|
+
if not chosen:
|
|
191
|
+
return False
|
|
192
|
+
path = chosen if isinstance(chosen, str) else chosen[0]
|
|
193
|
+
await asyncio.to_thread(Path(path).write_bytes, data)
|
|
194
|
+
return True
|
|
195
|
+
|
|
196
|
+
async def confirm(self, message: str, title: str) -> bool:
|
|
197
|
+
return bool(
|
|
198
|
+
await asyncio.to_thread(
|
|
199
|
+
self.app.window.create_confirmation_dialog, title or "Confirm", message
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class _Api:
|
|
205
|
+
"""What the page can call (window.pywebview.api.*). pywebview exposes
|
|
206
|
+
every public method, so this holds nothing else."""
|
|
207
|
+
|
|
208
|
+
def __init__(self, app: DesktopApp):
|
|
209
|
+
self._app = app
|
|
210
|
+
|
|
211
|
+
def mount(self, here: str) -> dict:
|
|
212
|
+
return self._app.call(self._app.mount, here)
|
|
213
|
+
|
|
214
|
+
def mounted(self) -> None:
|
|
215
|
+
self._app.call(self._app.settle)
|
|
216
|
+
|
|
217
|
+
def dispatch(self, handler_id: str, event_json: str, seq: int) -> None:
|
|
218
|
+
self._app.post(self._app.dispatch, handler_id, json.loads(event_json), seq)
|
|
219
|
+
|
|
220
|
+
def location(self, url: str) -> None:
|
|
221
|
+
self._app.post(self._app.location, url)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class DesktopApp:
|
|
225
|
+
"""One app, one window. ``window`` is anything with ``evaluate_js``:
|
|
226
|
+
a pywebview window, or a stand-in in tests."""
|
|
227
|
+
|
|
228
|
+
def __init__(self, app_dir: Path, window: Any = None):
|
|
229
|
+
self.app_dir = Path(app_dir).resolve()
|
|
230
|
+
self.window = window
|
|
231
|
+
self.here = "/"
|
|
232
|
+
self.tree: list = []
|
|
233
|
+
self.renderer: Renderer | None = None
|
|
234
|
+
self.cls: type | None = None
|
|
235
|
+
self._render_queued = False
|
|
236
|
+
self.loop = asyncio.new_event_loop()
|
|
237
|
+
self._thread = threading.Thread(target=self.loop.run_forever, daemon=True)
|
|
238
|
+
self.api = _Api(self)
|
|
239
|
+
|
|
240
|
+
# ---------------------------------------------------- the loop thread
|
|
241
|
+
|
|
242
|
+
def start(self) -> None:
|
|
243
|
+
self._thread.start()
|
|
244
|
+
self.call(self._load)
|
|
245
|
+
|
|
246
|
+
def stop(self) -> None:
|
|
247
|
+
self.loop.call_soon_threadsafe(self.loop.stop)
|
|
248
|
+
self._thread.join(timeout=5)
|
|
249
|
+
|
|
250
|
+
def call(self, fn: Callable, *args: Any) -> Any:
|
|
251
|
+
"""Run ``fn`` on the loop thread and wait for its result."""
|
|
252
|
+
future: concurrent.futures.Future = concurrent.futures.Future()
|
|
253
|
+
|
|
254
|
+
def run() -> None:
|
|
255
|
+
try:
|
|
256
|
+
future.set_result(fn(*args))
|
|
257
|
+
except BaseException as exc: # noqa: BLE001 - re-raised in the caller
|
|
258
|
+
future.set_exception(exc)
|
|
259
|
+
|
|
260
|
+
self.loop.call_soon_threadsafe(run)
|
|
261
|
+
return future.result()
|
|
262
|
+
|
|
263
|
+
def post(self, fn: Callable, *args: Any) -> None:
|
|
264
|
+
"""Run ``fn`` on the loop thread, without waiting."""
|
|
265
|
+
self.loop.call_soon_threadsafe(self._guarded, fn, *args)
|
|
266
|
+
|
|
267
|
+
def _guarded(self, fn: Callable, *args: Any) -> None:
|
|
268
|
+
try:
|
|
269
|
+
fn(*args)
|
|
270
|
+
except Exception as exc: # noqa: BLE001 - shown, the app keeps running
|
|
271
|
+
self.report(exc)
|
|
272
|
+
|
|
273
|
+
def js(self, script: str) -> Any:
|
|
274
|
+
if self.window is not None:
|
|
275
|
+
return self.window.evaluate_js(script)
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
# ------------------------------------------------------- the app cycle
|
|
279
|
+
|
|
280
|
+
def _load(self) -> None:
|
|
281
|
+
sys.path.insert(0, str(self.app_dir)) # the app's own modules
|
|
282
|
+
routing.set_host(_Location(self))
|
|
283
|
+
dialogs.set_host(_Dialogs(self))
|
|
284
|
+
self.cls = load_component(self.app_dir / "app.weft")
|
|
285
|
+
self.renderer = Renderer(self.cls())
|
|
286
|
+
# Already on the loop thread whenever app code changes state, except
|
|
287
|
+
# code that made its own thread: call_soon_threadsafe covers both.
|
|
288
|
+
self.renderer.on_invalidate = self._schedule_render
|
|
289
|
+
self.renderer.on_error = self.report
|
|
290
|
+
self.renderer.init_root()
|
|
291
|
+
|
|
292
|
+
def mount(self, here: str) -> dict:
|
|
293
|
+
"""The page (re)loaded: the whole current page, where it is now."""
|
|
294
|
+
routing.location_changed(here)
|
|
295
|
+
self.here = here
|
|
296
|
+
self.tree = self.renderer.render()
|
|
297
|
+
return {"tree": to_dicts(self.tree), "css": collect_styles(self.cls)}
|
|
298
|
+
|
|
299
|
+
def _render(self) -> list[dict]:
|
|
300
|
+
new = self.renderer.render()
|
|
301
|
+
ops = diff(self.tree, new)
|
|
302
|
+
self.tree = new
|
|
303
|
+
return ops
|
|
304
|
+
|
|
305
|
+
def _push(self, ops: list[dict], seq: int = 0) -> None:
|
|
306
|
+
if ops or seq:
|
|
307
|
+
self.js(f"pyweftDesktop.apply({json.dumps(ops)}, {seq})")
|
|
308
|
+
|
|
309
|
+
def settle(self) -> None:
|
|
310
|
+
"""What happens after every render is on the page: effects and
|
|
311
|
+
on_after_render (rendering again while they change state), then
|
|
312
|
+
focus and scroll requests."""
|
|
313
|
+
while self.renderer.after_render():
|
|
314
|
+
self._push(self._render())
|
|
315
|
+
commands = self.renderer.take_commands()
|
|
316
|
+
for command in commands:
|
|
317
|
+
# The page's <title> is set too, but the window keeps its own.
|
|
318
|
+
if command["op"] == "set_title" and hasattr(self.window, "set_title"):
|
|
319
|
+
self.window.set_title(command["text"])
|
|
320
|
+
if commands:
|
|
321
|
+
self.js(f"pyweftDesktop.commands({json.dumps(commands)})")
|
|
322
|
+
|
|
323
|
+
def dispatch(self, handler_id: str, event: dict, seq: int) -> None:
|
|
324
|
+
try:
|
|
325
|
+
self.renderer.dispatch(handler_id, event)
|
|
326
|
+
finally:
|
|
327
|
+
# Rendered even when the handler failed: it may have changed
|
|
328
|
+
# state first, and the page must learn the event was handled.
|
|
329
|
+
self._push(self._render(), seq)
|
|
330
|
+
self.settle()
|
|
331
|
+
|
|
332
|
+
def location(self, url: str) -> None:
|
|
333
|
+
self.here = url
|
|
334
|
+
routing.location_changed(url)
|
|
335
|
+
|
|
336
|
+
def _schedule_render(self) -> None:
|
|
337
|
+
# Many state changes in one go become one render.
|
|
338
|
+
if not self._render_queued:
|
|
339
|
+
self._render_queued = True
|
|
340
|
+
self.loop.call_soon_threadsafe(self._rerender)
|
|
341
|
+
|
|
342
|
+
def _rerender(self) -> None:
|
|
343
|
+
self._render_queued = False
|
|
344
|
+
if self.renderer.needs_render():
|
|
345
|
+
self._guarded(lambda: (self._push(self._render()), self.settle()))
|
|
346
|
+
|
|
347
|
+
def report(self, exc: BaseException) -> None:
|
|
348
|
+
summary = format_error(exc, root=str(self.app_dir))
|
|
349
|
+
print(f"pyweft: {summary}", file=sys.stderr)
|
|
350
|
+
traceback.print_exception(exc, file=sys.stderr)
|
|
351
|
+
self.js(f"pyweftDesktop.error({json.dumps(summary)})")
|
|
352
|
+
|
|
353
|
+
# ------------------------------------------------------- hot reload
|
|
354
|
+
|
|
355
|
+
def hot_reload(self) -> None:
|
|
356
|
+
"""A .weft file changed: recompile, move live components onto the
|
|
357
|
+
new classes keeping their state, and patch the page."""
|
|
358
|
+
new_cls = load_component(self.app_dir / "app.weft")
|
|
359
|
+
changed = hot_swap(self.renderer, new_cls)
|
|
360
|
+
ops = self._render()
|
|
361
|
+
self.cls = new_cls
|
|
362
|
+
self.js(f"pyweftDesktop.css({json.dumps(collect_styles(new_cls))})")
|
|
363
|
+
self._push(ops)
|
|
364
|
+
self.js("pyweftDesktop.clearError()")
|
|
365
|
+
self.settle()
|
|
366
|
+
names = [str(Path(f).relative_to(self.app_dir)) for f in changed]
|
|
367
|
+
print(f"pyweft: hot-reloaded {', '.join(names) or 'app'}")
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
# --------------------------------------------------------------- settings
|
|
371
|
+
|
|
372
|
+
# [tool.pyweft.desktop] in the app's pyproject.toml: setting -> (kind, default).
|
|
373
|
+
_WINDOW_SETTINGS: dict[str, tuple[str, Any]] = {
|
|
374
|
+
"width": ("pixels", 1100),
|
|
375
|
+
"height": ("pixels", 800),
|
|
376
|
+
"min_size": ("pair", None),
|
|
377
|
+
"resizable": ("bool", True),
|
|
378
|
+
"maximized": ("bool", False),
|
|
379
|
+
"fullscreen": ("bool", False),
|
|
380
|
+
"on_top": ("bool", False),
|
|
381
|
+
"frameless": ("bool", False),
|
|
382
|
+
"background_color": ("color", None),
|
|
383
|
+
}
|
|
384
|
+
_KINDS = {
|
|
385
|
+
"pixels": "a whole number of pixels, like 1200",
|
|
386
|
+
"pair": "two whole numbers, like [800, 600]",
|
|
387
|
+
"bool": "true or false",
|
|
388
|
+
"color": 'a colour like "#1e293b"',
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _fits(kind: str, value: Any) -> bool:
|
|
393
|
+
def pixels(v: Any) -> bool:
|
|
394
|
+
return isinstance(v, int) and not isinstance(v, bool) and v > 0
|
|
395
|
+
|
|
396
|
+
if kind == "pixels":
|
|
397
|
+
return pixels(value)
|
|
398
|
+
if kind == "pair":
|
|
399
|
+
return isinstance(value, list) and len(value) == 2 and all(pixels(v) for v in value)
|
|
400
|
+
if kind == "bool":
|
|
401
|
+
return isinstance(value, bool)
|
|
402
|
+
return isinstance(value, str) and re.fullmatch(r"#[0-9a-fA-F]{6}", value) is not None
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def window_options(app_dir: Path) -> dict[str, Any]:
|
|
406
|
+
"""``webview.create_window`` arguments from ``[tool.pyweft.desktop]``;
|
|
407
|
+
anything the app leaves out keeps its default. A setting that doesn't
|
|
408
|
+
exist or doesn't fit is a CliError naming it."""
|
|
409
|
+
from .cli import CliError, app_settings
|
|
410
|
+
|
|
411
|
+
settings = app_settings(app_dir).get("desktop", {})
|
|
412
|
+
unknown = sorted(set(settings) - set(_WINDOW_SETTINGS))
|
|
413
|
+
if unknown:
|
|
414
|
+
raise CliError(
|
|
415
|
+
f"pyproject.toml [tool.pyweft.desktop] has no setting {unknown[0]!r}; "
|
|
416
|
+
f"the settings are {', '.join(_WINDOW_SETTINGS)}"
|
|
417
|
+
)
|
|
418
|
+
options: dict[str, Any] = {}
|
|
419
|
+
for name, (kind, default) in _WINDOW_SETTINGS.items():
|
|
420
|
+
value = settings.get(name, default)
|
|
421
|
+
if value is None:
|
|
422
|
+
continue
|
|
423
|
+
if not _fits(kind, value):
|
|
424
|
+
raise CliError(
|
|
425
|
+
f"pyproject.toml [tool.pyweft.desktop] {name} must be {_KINDS[kind]}, "
|
|
426
|
+
f"not {value!r}"
|
|
427
|
+
)
|
|
428
|
+
options[name] = tuple(value) if kind == "pair" else value
|
|
429
|
+
return options
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def window_icon(app_dir: Path, icon: str | None) -> str | None:
|
|
433
|
+
"""The icon file for pywebview, where the platform can use it: an .ico
|
|
434
|
+
on Windows, any image on Linux. macOS takes the Dock icon from a packaged
|
|
435
|
+
app, so there it waits for packaging."""
|
|
436
|
+
if icon is None or sys.platform == "darwin":
|
|
437
|
+
return None
|
|
438
|
+
if sys.platform == "win32" and not icon.lower().endswith(".ico"):
|
|
439
|
+
print(f"pyweft: {icon} isn't an .ico file, which a Windows window icon needs; "
|
|
440
|
+
"the window keeps the default icon")
|
|
441
|
+
return None
|
|
442
|
+
return str((Path(app_dir) / icon).resolve())
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
# ---------------------------------------------------------------- serving
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def page(app_dir: Path) -> str:
|
|
449
|
+
from .cli import app_css_files, app_identity, head_extras, linked_app_css
|
|
450
|
+
|
|
451
|
+
head = head_extras(app_dir)
|
|
452
|
+
linked = linked_app_css(head)
|
|
453
|
+
css = "\n ".join(
|
|
454
|
+
f'<link rel="stylesheet" href="app/{name}" />'
|
|
455
|
+
for name in app_css_files(app_dir)
|
|
456
|
+
if name not in linked
|
|
457
|
+
)
|
|
458
|
+
html = _PAGE.replace("__CSS__", css)
|
|
459
|
+
# Last, in one pass: the app's own text is never taken for a placeholder.
|
|
460
|
+
user = {"__TITLE__": escape(app_identity(app_dir).title), "__HEAD__": head}
|
|
461
|
+
return re.sub("__(?:TITLE|HEAD)__", lambda m: user[m.group(0)], html)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def make_server(app_dir: Path) -> ThreadingHTTPServer:
|
|
465
|
+
"""The window's page, runtime.js, the app's CSS and assets, on a free
|
|
466
|
+
local port. Only those: nothing else on disk is reachable."""
|
|
467
|
+
from .cli import _asset_path, app_css_files, content_type
|
|
468
|
+
|
|
469
|
+
class Handler(SimpleHTTPRequestHandler):
|
|
470
|
+
def do_GET(self): # noqa: N802 (stdlib naming)
|
|
471
|
+
route = unquote(self.path.split("?", 1)[0])
|
|
472
|
+
if route in ("/", "/index.html"):
|
|
473
|
+
return self._send("text/html", page(app_dir).encode())
|
|
474
|
+
if route == "/runtime.js":
|
|
475
|
+
return self._send("text/javascript", (_STATIC / "runtime.js").read_bytes())
|
|
476
|
+
if route.startswith("/app/") and route[5:] in app_css_files(app_dir):
|
|
477
|
+
return self._send("text/css", (app_dir / route[5:]).read_bytes())
|
|
478
|
+
if route.startswith("/assets/"):
|
|
479
|
+
path = _asset_path(app_dir, route[len("/assets/") :])
|
|
480
|
+
if path is not None:
|
|
481
|
+
return self._send(content_type(path), path.read_bytes())
|
|
482
|
+
self.send_error(404)
|
|
483
|
+
|
|
484
|
+
def _send(self, ctype: str, body: bytes):
|
|
485
|
+
self.send_response(200)
|
|
486
|
+
self.send_header("Content-Type", ctype)
|
|
487
|
+
self.send_header("Content-Length", str(len(body)))
|
|
488
|
+
self.send_header("Cache-Control", "no-store")
|
|
489
|
+
self.end_headers()
|
|
490
|
+
self.wfile.write(body)
|
|
491
|
+
|
|
492
|
+
def log_message(self, fmt, *args):
|
|
493
|
+
pass
|
|
494
|
+
|
|
495
|
+
return ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _retitle(app: DesktopApp) -> None:
|
|
499
|
+
# The title can follow an edit; size and the other window settings are
|
|
500
|
+
# fixed when the window opens.
|
|
501
|
+
from .cli import CliError, app_identity
|
|
502
|
+
|
|
503
|
+
try:
|
|
504
|
+
title = app_identity(app.app_dir).title
|
|
505
|
+
except CliError as exc:
|
|
506
|
+
print(f"pyweft: {exc}", file=sys.stderr)
|
|
507
|
+
return
|
|
508
|
+
if app.window is not None:
|
|
509
|
+
app.window.set_title(title)
|
|
510
|
+
print("pyweft: window settings other than the title apply after a restart")
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def watch(app: DesktopApp, stop: threading.Event, interval: float = 0.3) -> None:
|
|
514
|
+
"""Hot reload for the window, from the same file snapshot as the
|
|
515
|
+
browser's dev server."""
|
|
516
|
+
from .cli import snapshot
|
|
517
|
+
|
|
518
|
+
current = snapshot(app.app_dir)
|
|
519
|
+
while not stop.wait(interval):
|
|
520
|
+
new = snapshot(app.app_dir)
|
|
521
|
+
changed = {k for k in current.keys() | new.keys() if current.get(k) != new.get(k)}
|
|
522
|
+
current = new
|
|
523
|
+
if not changed:
|
|
524
|
+
continue
|
|
525
|
+
if any(k.endswith(".py") or k.startswith("pyweft:") for k in changed):
|
|
526
|
+
# Imported Python stays cached in this process; only a restart
|
|
527
|
+
# picks up a changed module or a changed PyWeft.
|
|
528
|
+
names = ", ".join(sorted(k.removeprefix("pyweft:") for k in changed))
|
|
529
|
+
print(f"pyweft: {names} changed; restart `pyweft run --desktop` to use it")
|
|
530
|
+
continue
|
|
531
|
+
if "pyproject.toml" in changed:
|
|
532
|
+
_retitle(app)
|
|
533
|
+
weft = [k for k in changed if k.endswith(".weft")]
|
|
534
|
+
if weft:
|
|
535
|
+
app.post(app.hot_reload)
|
|
536
|
+
for name in sorted(k for k in changed if k.endswith(".css")):
|
|
537
|
+
app.js(f"pyweftDesktop.refreshCss({json.dumps(name)})")
|
|
538
|
+
if any(not k.endswith((".weft", ".css")) for k in changed):
|
|
539
|
+
app.js("location.reload()") # head.html, assets: the page remounts
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def run_desktop(app_dir: Path, dev: bool = True) -> None:
|
|
543
|
+
"""The app in a window until it closes. ``dev`` (``pyweft run``) adds
|
|
544
|
+
hot reload and terminal messages; a packaged app runs without them."""
|
|
545
|
+
from .cli import CliError, app_identity, app_pyweft_files
|
|
546
|
+
|
|
547
|
+
app_pyweft_files(app_dir) # fails fast without an app.weft
|
|
548
|
+
identity = app_identity(app_dir) # and on a mistake in the settings
|
|
549
|
+
options = window_options(app_dir)
|
|
550
|
+
try:
|
|
551
|
+
import webview
|
|
552
|
+
except ImportError:
|
|
553
|
+
raise CliError(
|
|
554
|
+
"the desktop window needs pywebview: "
|
|
555
|
+
'uv add "pyweft[desktop]" (or: pip install "pyweft[desktop]")'
|
|
556
|
+
) from None
|
|
557
|
+
|
|
558
|
+
app = DesktopApp(app_dir)
|
|
559
|
+
try:
|
|
560
|
+
app.start()
|
|
561
|
+
except Exception as exc: # noqa: BLE001 - shown as a CLI error
|
|
562
|
+
app.stop()
|
|
563
|
+
raise CliError(format_error(exc, root=str(Path(app_dir).resolve()))) from None
|
|
564
|
+
server = make_server(app_dir)
|
|
565
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
566
|
+
url = f"http://127.0.0.1:{server.server_address[1]}/"
|
|
567
|
+
app.window = webview.create_window(identity.title, url, js_api=app.api, **options)
|
|
568
|
+
stop = threading.Event()
|
|
569
|
+
if dev:
|
|
570
|
+
threading.Thread(target=watch, args=(app, stop), daemon=True).start()
|
|
571
|
+
print(f"pyweft: {app_dir} in a desktop window (close it to stop)")
|
|
572
|
+
print(" hot reload is on: saved .weft and .css files appear in the window")
|
|
573
|
+
icon = window_icon(app_dir, identity.icon)
|
|
574
|
+
try:
|
|
575
|
+
webview.start(**({"icon": icon} if icon else {})) # until the window closes
|
|
576
|
+
finally:
|
|
577
|
+
stop.set()
|
|
578
|
+
server.shutdown()
|
|
579
|
+
app.stop()
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
_STARTUP_ERROR = """<!DOCTYPE html><html><head><meta charset="utf-8"><title>{title}</title>
|
|
583
|
+
<style>body {{ font: 15px/1.5 system-ui, sans-serif; margin: 2rem; color: #1f2328; }}
|
|
584
|
+
pre {{ background: #f6f8fa; padding: 1rem; border-radius: 8px; white-space: pre-wrap; }}</style>
|
|
585
|
+
</head><body><h1>{title} couldn't start</h1><pre>{error}</pre></body></html>"""
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def run_packaged(app_dir: Path) -> None:
|
|
589
|
+
"""Entry point of an app built with ``pyweft build --desktop``. There's
|
|
590
|
+
no terminal to print to, so a failure to start shows in a window."""
|
|
591
|
+
try:
|
|
592
|
+
run_desktop(app_dir, dev=False)
|
|
593
|
+
except Exception as exc: # noqa: BLE001 - shown to the user instead
|
|
594
|
+
import webview
|
|
595
|
+
|
|
596
|
+
from .cli import CliError, app_identity
|
|
597
|
+
|
|
598
|
+
detail = str(exc) if isinstance(exc, CliError) else traceback.format_exc()
|
|
599
|
+
try:
|
|
600
|
+
title = escape(app_identity(app_dir).title)
|
|
601
|
+
except Exception: # noqa: BLE001 - the settings may be what failed
|
|
602
|
+
title = "The app"
|
|
603
|
+
webview.create_window(
|
|
604
|
+
"Couldn't start", html=_STARTUP_ERROR.format(title=title, error=escape(detail))
|
|
605
|
+
)
|
|
606
|
+
webview.start()
|