zagred 0.1.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.
- zagred/__init__.py +17 -0
- zagred/app.py +530 -0
- zagred/assets.py +99 -0
- zagred/bridge.py +136 -0
- zagred/build.py +295 -0
- zagred/cli.py +352 -0
- zagred/manifest.py +136 -0
- zagred/runtime.py +33 -0
- zagred/scaffold.py +250 -0
- zagred/templates.py +637 -0
- zagred-0.1.0.dist-info/METADATA +467 -0
- zagred-0.1.0.dist-info/RECORD +15 -0
- zagred-0.1.0.dist-info/WHEEL +4 -0
- zagred-0.1.0.dist-info/entry_points.txt +3 -0
- zagred-0.1.0.dist-info/licenses/LICENSE +21 -0
zagred/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Zagred — Python backends, web frontends, native desktop apps.
|
|
2
|
+
|
|
3
|
+
Public surface:
|
|
4
|
+
|
|
5
|
+
- :class:`zagred.App` — window, lifecycle and the JS bridge.
|
|
6
|
+
- :class:`zagred.Manifest` — parsed ``zagred.toml``.
|
|
7
|
+
- :func:`zagred.load_manifest` — read ``zagred.toml`` from a project root.
|
|
8
|
+
|
|
9
|
+
The CLI lives in :mod:`zagred.cli` and is not imported here, so ``import zagred``
|
|
10
|
+
stays cheap and never drags in build tooling.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .app import App
|
|
14
|
+
from .manifest import Manifest, load_manifest
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
__all__ = ["App", "Manifest", "load_manifest", "__version__"]
|
zagred/app.py
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
"""Application object: window creation, lifecycle hooks, and the JS bridge.
|
|
2
|
+
|
|
3
|
+
``zagred.toml`` only carries build metadata. Everything the window does at
|
|
4
|
+
runtime — size, chrome, visibility, lifecycle — is declared here in Python so
|
|
5
|
+
there is a single source of truth.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
import threading
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Callable
|
|
15
|
+
|
|
16
|
+
from .assets import resolve_ui
|
|
17
|
+
from .bridge import SHIM, Bridge
|
|
18
|
+
|
|
19
|
+
# Printed when no webview engine can be imported. Keyed by ``sys.platform``;
|
|
20
|
+
# the Linux text also explains the uv/`python3-gi` split, which is the single
|
|
21
|
+
# most common setup failure.
|
|
22
|
+
WEBVIEW_HINT = {
|
|
23
|
+
"linux": (
|
|
24
|
+
"Zagred needs GTK bindings that this interpreter can import.\n"
|
|
25
|
+
" sudo apt install python3-gi gir1.2-webkit2-4.1\n"
|
|
26
|
+
"uv's Python cannot see the distro's python3-gi, so recreate the venv:\n"
|
|
27
|
+
" rm -rf .venv && uv venv --python /usr/bin/python3 --system-site-packages && uv sync"
|
|
28
|
+
),
|
|
29
|
+
"win32": (
|
|
30
|
+
"Zagred needs the WebView2 runtime:\n"
|
|
31
|
+
" https://developer.microsoft.com/microsoft-edge/webview2/"
|
|
32
|
+
),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class App:
|
|
37
|
+
"""A Zagred application: one window, one bridge, one lifecycle.
|
|
38
|
+
|
|
39
|
+
Typical use::
|
|
40
|
+
|
|
41
|
+
import zagred
|
|
42
|
+
|
|
43
|
+
app = zagred.App(title="Notes", size=(1024, 700))
|
|
44
|
+
|
|
45
|
+
@app.expose
|
|
46
|
+
def greet(name: str) -> str:
|
|
47
|
+
return f"Hello, {name}!"
|
|
48
|
+
|
|
49
|
+
@app.on_start
|
|
50
|
+
def boot() -> None:
|
|
51
|
+
print("ready")
|
|
52
|
+
|
|
53
|
+
Only ``@app.expose`` functions are reachable from JavaScript. Everything
|
|
54
|
+
else on this class is Python-side control (window manipulation, events,
|
|
55
|
+
hooks) and is never exposed to the UI.
|
|
56
|
+
|
|
57
|
+
The window itself only exists between :meth:`run` and the user closing it.
|
|
58
|
+
Calling a control method such as :meth:`minimize` before ``run`` — or from
|
|
59
|
+
a hook that fires after close — raises ``RuntimeError``.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
title: str = "Zagred",
|
|
65
|
+
size: tuple[int, int] = (1024, 700),
|
|
66
|
+
min_size: tuple[int, int] | None = None,
|
|
67
|
+
position: tuple[int, int] | None = None,
|
|
68
|
+
resizable: bool = True,
|
|
69
|
+
fullscreen: bool = False,
|
|
70
|
+
maximized: bool = False,
|
|
71
|
+
minimized: bool = False,
|
|
72
|
+
frameless: bool = False,
|
|
73
|
+
easy_drag: bool = True,
|
|
74
|
+
on_top: bool = False,
|
|
75
|
+
focus: bool = True,
|
|
76
|
+
shadow: bool = False,
|
|
77
|
+
confirm_close: bool = False,
|
|
78
|
+
transparent: bool = False,
|
|
79
|
+
text_select: bool = False,
|
|
80
|
+
zoomable: bool = False,
|
|
81
|
+
vibrancy: bool = False,
|
|
82
|
+
background_color: str = "#ffffff",
|
|
83
|
+
icon: str | None = None,
|
|
84
|
+
menu: list | None = None,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Configure the window that :meth:`run` will open.
|
|
87
|
+
|
|
88
|
+
All options are read once, when the window is created. Mutating the
|
|
89
|
+
attribute afterwards (``app.title = "x"``) does *not* update a running
|
|
90
|
+
window — use the matching control method instead, e.g.
|
|
91
|
+
:meth:`set_title`.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
title: Window title, also shown in the taskbar or dock.
|
|
95
|
+
size: ``(width, height)`` in logical pixels. Initial window size.
|
|
96
|
+
min_size: ``(width, height)`` lower bound when the user resizes.
|
|
97
|
+
Defaults to ``(200, 100)`` when omitted.
|
|
98
|
+
position: ``(x, y)`` screen coordinates for the top-left corner.
|
|
99
|
+
``None`` lets the window manager place the window.
|
|
100
|
+
resizable: Whether the user may resize the window. Set ``False``
|
|
101
|
+
for fixed-size dialogs and splash windows.
|
|
102
|
+
fullscreen: Start borderless and covering the screen.
|
|
103
|
+
maximized: Start maximized but still decorated. Mutually exclusive
|
|
104
|
+
with ``fullscreen`` in practice — pick one.
|
|
105
|
+
minimized: Start minimized (or hidden in the taskbar on some Linux
|
|
106
|
+
window managers).
|
|
107
|
+
frameless: Draw the window without a title bar or borders. Pair with
|
|
108
|
+
``easy_drag`` and draw your own controls in the UI.
|
|
109
|
+
easy_drag: When ``frameless`` is set, let the user drag the window by
|
|
110
|
+
its background. No effect on decorated windows.
|
|
111
|
+
on_top: Keep the window above other windows. Useful for tools and
|
|
112
|
+
floating palettes; avoid for normal apps.
|
|
113
|
+
focus: Take keyboard focus on open. Set ``False`` for background or
|
|
114
|
+
notification-style windows.
|
|
115
|
+
shadow: Draw a drop shadow around a ``frameless`` window.
|
|
116
|
+
confirm_close: Ask the user to confirm before closing. Combine with
|
|
117
|
+
:meth:`on_closing` when you need to save state first.
|
|
118
|
+
transparent: Allow transparent pixels behind the webview. Requires a
|
|
119
|
+
transparent background in your CSS as well.
|
|
120
|
+
text_select: Allow selecting text. Defaults to ``False``, which
|
|
121
|
+
matches native app behaviour; enable it for document-style UIs.
|
|
122
|
+
zoomable: Enable pinch/ctrl-wheel zooming of the page.
|
|
123
|
+
vibrancy: macOS only — blurred, translucent background material.
|
|
124
|
+
Ignored on Linux and Windows.
|
|
125
|
+
background_color: Color painted before the page renders, as a CSS
|
|
126
|
+
color string. Set it to match your UI to avoid a flash of white.
|
|
127
|
+
icon: Path to the window icon (PNG on Linux/Windows, ICNS or PNG on
|
|
128
|
+
macOS). Also used by ``zagred build`` when ``[app] icon`` is set.
|
|
129
|
+
menu: Platform menu definition passed straight to pywebview. Empty
|
|
130
|
+
means "no custom menu".
|
|
131
|
+
|
|
132
|
+
Raises:
|
|
133
|
+
Nothing. Invalid paths and unsupported options surface when
|
|
134
|
+
:meth:`run` creates the window.
|
|
135
|
+
"""
|
|
136
|
+
self.title = title
|
|
137
|
+
self.size = size
|
|
138
|
+
self.min_size = min_size
|
|
139
|
+
self.position = position
|
|
140
|
+
self.resizable = resizable
|
|
141
|
+
self.fullscreen = fullscreen
|
|
142
|
+
self.maximized = maximized
|
|
143
|
+
self.minimized = minimized
|
|
144
|
+
self.frameless = frameless
|
|
145
|
+
self.easy_drag = easy_drag
|
|
146
|
+
self.on_top = on_top
|
|
147
|
+
self.focus = focus
|
|
148
|
+
self.shadow = shadow
|
|
149
|
+
self.confirm_close = confirm_close
|
|
150
|
+
self.transparent = transparent
|
|
151
|
+
self.text_select = text_select
|
|
152
|
+
self.zoomable = zoomable
|
|
153
|
+
self.vibrancy = vibrancy
|
|
154
|
+
self.background_color = background_color
|
|
155
|
+
self.icon = icon
|
|
156
|
+
self.menu = menu or []
|
|
157
|
+
|
|
158
|
+
self._exposed: dict[str, Callable] = {}
|
|
159
|
+
self._start_hooks: list[Callable] = []
|
|
160
|
+
self._loaded_hooks: list[Callable] = []
|
|
161
|
+
self._closing_hooks: list[Callable] = []
|
|
162
|
+
self._closed_hooks: list[Callable] = []
|
|
163
|
+
self._window = None
|
|
164
|
+
|
|
165
|
+
# -- decorators -----------------------------------------------------
|
|
166
|
+
|
|
167
|
+
def expose(self, fn: Callable) -> Callable:
|
|
168
|
+
"""Make ``fn`` callable from JavaScript.
|
|
169
|
+
|
|
170
|
+
The function is registered under its ``__name__``, which is the string
|
|
171
|
+
the UI passes to ``zagred.call(...)``. Exposing two functions with the
|
|
172
|
+
same name keeps the last one silently.
|
|
173
|
+
|
|
174
|
+
Arguments arrive as keyword arguments, and the return value must be
|
|
175
|
+
JSON-serialisable — that is checked before the value leaves Python, so a
|
|
176
|
+
mistake becomes an error in the UI instead of a blank window.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
fn: The function to expose. May be a coroutine function; coroutines
|
|
180
|
+
are awaited on the bridge's worker thread.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
``fn`` unchanged, so this works as a decorator.
|
|
184
|
+
|
|
185
|
+
Example:
|
|
186
|
+
>>> @app.expose
|
|
187
|
+
... def greet(name: str) -> str:
|
|
188
|
+
... return f"Hello, {name}!"
|
|
189
|
+
"""
|
|
190
|
+
self._exposed[fn.__name__] = fn
|
|
191
|
+
return fn
|
|
192
|
+
|
|
193
|
+
def on_start(self, fn: Callable) -> Callable:
|
|
194
|
+
"""Register a hook that runs once the GUI loop is up.
|
|
195
|
+
|
|
196
|
+
Fires before the page finishes loading, so it is the right place to
|
|
197
|
+
start background work whose results you will ``emit`` later. Runs on a
|
|
198
|
+
worker thread, not the UI thread.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
fn: Zero-argument callable.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
``fn`` unchanged, so this works as a decorator.
|
|
205
|
+
"""
|
|
206
|
+
self._start_hooks.append(fn)
|
|
207
|
+
return fn
|
|
208
|
+
|
|
209
|
+
def on_loaded(self, fn: Callable) -> Callable:
|
|
210
|
+
"""Register a hook that runs when the page has finished loading.
|
|
211
|
+
|
|
212
|
+
Runs on a worker thread. Use it to push initial state to the UI; the
|
|
213
|
+
bridge is live by this point.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
fn: Zero-argument callable.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
``fn`` unchanged, so this works as a decorator.
|
|
220
|
+
"""
|
|
221
|
+
self._loaded_hooks.append(fn)
|
|
222
|
+
return fn
|
|
223
|
+
|
|
224
|
+
def on_closing(self, fn: Callable) -> Callable:
|
|
225
|
+
"""Register a hook that runs when the user tries to close the window.
|
|
226
|
+
|
|
227
|
+
Return ``False`` from the hook to cancel the close. Returning ``None``
|
|
228
|
+
or any other value lets the close proceed. If several hooks are
|
|
229
|
+
registered, the first one returning ``False`` cancels it.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
fn: Zero-argument callable returning ``bool | None``.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
``fn`` unchanged, so this works as a decorator.
|
|
236
|
+
|
|
237
|
+
Example:
|
|
238
|
+
>>> @app.on_closing
|
|
239
|
+
... def guard():
|
|
240
|
+
... return save_unsaved_changes() or False
|
|
241
|
+
"""
|
|
242
|
+
self._closing_hooks.append(fn)
|
|
243
|
+
return fn
|
|
244
|
+
|
|
245
|
+
def on_closed(self, fn: Callable) -> Callable:
|
|
246
|
+
"""Register a hook that runs after the window is gone.
|
|
247
|
+
|
|
248
|
+
The window object is already invalid here, so window control methods
|
|
249
|
+
will raise. Use it for cleanup only.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
fn: Zero-argument callable.
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
``fn`` unchanged, so this works as a decorator.
|
|
256
|
+
"""
|
|
257
|
+
self._closed_hooks.append(fn)
|
|
258
|
+
return fn
|
|
259
|
+
|
|
260
|
+
# -- events ---------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
def emit(self, name: str, payload=None) -> None:
|
|
263
|
+
"""Push an event to the UI, to be received by ``zagred.on(name, fn)``.
|
|
264
|
+
|
|
265
|
+
Uses ``run_js`` with a JSON literal rather than ``evaluate_js``, so it
|
|
266
|
+
keeps working under a strict Content-Security-Policy that forbids
|
|
267
|
+
``unsafe-eval``.
|
|
268
|
+
|
|
269
|
+
Silent no-op before the window exists, which makes ``emit`` safe to call
|
|
270
|
+
from hooks and background threads without null checks.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
name: Event name. Must match the string the UI subscribed to.
|
|
274
|
+
payload: Any JSON-serialisable value. Sent as-is to every listener.
|
|
275
|
+
|
|
276
|
+
Raises:
|
|
277
|
+
TypeError: If ``payload`` is not JSON-serialisable (raised by
|
|
278
|
+
``json.dumps``; nothing is sent in that case).
|
|
279
|
+
"""
|
|
280
|
+
if self._window is None:
|
|
281
|
+
return
|
|
282
|
+
script = "window.zagred&&window.zagred._dispatch(%s,%s)" % (
|
|
283
|
+
json.dumps(name),
|
|
284
|
+
json.dumps(payload),
|
|
285
|
+
)
|
|
286
|
+
self._window.run_js(script)
|
|
287
|
+
|
|
288
|
+
# -- window control -------------------------------------------------
|
|
289
|
+
|
|
290
|
+
def _win(self):
|
|
291
|
+
"""Return the live pywebview window, or fail with a useful message."""
|
|
292
|
+
if self._window is None:
|
|
293
|
+
raise RuntimeError("the window is not running yet")
|
|
294
|
+
return self._window
|
|
295
|
+
|
|
296
|
+
def minimize(self) -> None:
|
|
297
|
+
"""Minimize the window to the taskbar or dock."""
|
|
298
|
+
self._win().minimize()
|
|
299
|
+
|
|
300
|
+
def maximize(self) -> None:
|
|
301
|
+
"""Maximize the window to fill the screen, keeping decorations."""
|
|
302
|
+
self._win().maximize()
|
|
303
|
+
|
|
304
|
+
def restore(self) -> None:
|
|
305
|
+
"""Undo a minimize or maximize."""
|
|
306
|
+
self._win().restore()
|
|
307
|
+
|
|
308
|
+
def toggle_fullscreen(self) -> None:
|
|
309
|
+
"""Switch between fullscreen and the previous decorated state."""
|
|
310
|
+
self._win().toggle_fullscreen()
|
|
311
|
+
|
|
312
|
+
def resize(self, width: int, height: int) -> None:
|
|
313
|
+
"""Resize the window and remember the new size.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
width: New width in logical pixels.
|
|
317
|
+
height: New height in logical pixels.
|
|
318
|
+
"""
|
|
319
|
+
self.size = (width, height)
|
|
320
|
+
self._win().resize(width, height)
|
|
321
|
+
|
|
322
|
+
def move(self, x: int, y: int) -> None:
|
|
323
|
+
"""Move the window's top-left corner and remember the new position.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
x: Screen x coordinate in logical pixels.
|
|
327
|
+
y: Screen y coordinate in logical pixels.
|
|
328
|
+
"""
|
|
329
|
+
self.position = (x, y)
|
|
330
|
+
self._win().move(x, y)
|
|
331
|
+
|
|
332
|
+
def set_title(self, title: str) -> None:
|
|
333
|
+
"""Change the window title and remember it.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
title: New title text.
|
|
337
|
+
"""
|
|
338
|
+
self.title = title
|
|
339
|
+
self._win().set_title(title)
|
|
340
|
+
|
|
341
|
+
def show(self) -> None:
|
|
342
|
+
"""Show a window that was hidden with :meth:`hide`."""
|
|
343
|
+
self._win().show()
|
|
344
|
+
|
|
345
|
+
def hide(self) -> None:
|
|
346
|
+
"""Hide the window without closing it."""
|
|
347
|
+
self._win().hide()
|
|
348
|
+
|
|
349
|
+
def destroy(self) -> None:
|
|
350
|
+
"""Close the window programmatically, running the usual close hooks."""
|
|
351
|
+
self._win().destroy()
|
|
352
|
+
|
|
353
|
+
def load_url(self, url: str) -> None:
|
|
354
|
+
"""Navigate the current window to another URL.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
url: Absolute URL, or a path resolvable by the asset resolver.
|
|
358
|
+
"""
|
|
359
|
+
self._win().load_url(url)
|
|
360
|
+
|
|
361
|
+
def evaluate_js(self, script: str):
|
|
362
|
+
"""Run JavaScript in the page and return its last expression.
|
|
363
|
+
|
|
364
|
+
Blocks until the page answers, so call it from a worker thread rather
|
|
365
|
+
than from a blocking window event. Requires the page to allow ``eval``
|
|
366
|
+
unless you use ``window.run_js`` semantics.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
script: JavaScript source. A raw string is recommended for scripts
|
|
370
|
+
containing backslashes.
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
The last evaluated expression converted to Python types: JS objects
|
|
374
|
+
become ``dict``, arrays become ``list``, ``undefined`` becomes
|
|
375
|
+
``None``.
|
|
376
|
+
"""
|
|
377
|
+
return self._win().evaluate_js(script)
|
|
378
|
+
|
|
379
|
+
def create_file_dialog(
|
|
380
|
+
self,
|
|
381
|
+
allow_multiple: bool = False,
|
|
382
|
+
directory: str = "",
|
|
383
|
+
save_filename: str = "",
|
|
384
|
+
file_types: tuple = (),
|
|
385
|
+
) -> tuple | None:
|
|
386
|
+
"""Open the operating system's file picker.
|
|
387
|
+
|
|
388
|
+
Blocks until the user answers, so call it from inside an ``@app.expose``
|
|
389
|
+
function (which already runs on a worker thread) rather than from a hook
|
|
390
|
+
that must stay responsive.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
allow_multiple: Let the user select more than one file.
|
|
394
|
+
directory: Directory the dialog opens in. Empty means the OS default.
|
|
395
|
+
save_filename: Pre-filled file name, for save-style dialogs.
|
|
396
|
+
file_types: Tuple of ``"Description (*.ext;*.ext)"`` strings used to
|
|
397
|
+
filter the list. Empty allows all files.
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
A tuple of absolute paths, or ``None`` if the user cancelled.
|
|
401
|
+
"""
|
|
402
|
+
import webview
|
|
403
|
+
|
|
404
|
+
return self._win().create_file_dialog(
|
|
405
|
+
webview.FileDialog.OPEN,
|
|
406
|
+
directory=directory,
|
|
407
|
+
allow_multiple=allow_multiple,
|
|
408
|
+
save_filename=save_filename,
|
|
409
|
+
file_types=file_types,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
# -- run ------------------------------------------------------------
|
|
413
|
+
|
|
414
|
+
@property
|
|
415
|
+
def window(self):
|
|
416
|
+
"""The underlying pywebview window, or ``None`` when not running."""
|
|
417
|
+
return self._window
|
|
418
|
+
|
|
419
|
+
def _window_kwargs(self) -> dict:
|
|
420
|
+
"""Translate the constructor options into pywebview's keyword names.
|
|
421
|
+
|
|
422
|
+
Returns:
|
|
423
|
+
Keyword arguments for ``webview.create_window``. ``x`` and ``y`` are
|
|
424
|
+
only included when a position was given, so the window manager keeps
|
|
425
|
+
control otherwise.
|
|
426
|
+
"""
|
|
427
|
+
kwargs = {
|
|
428
|
+
"width": self.size[0],
|
|
429
|
+
"height": self.size[1],
|
|
430
|
+
"min_size": self.min_size or (200, 100),
|
|
431
|
+
"resizable": self.resizable,
|
|
432
|
+
"fullscreen": self.fullscreen,
|
|
433
|
+
"maximized": self.maximized,
|
|
434
|
+
"minimized": self.minimized,
|
|
435
|
+
"frameless": self.frameless,
|
|
436
|
+
"easy_drag": self.easy_drag,
|
|
437
|
+
"on_top": self.on_top,
|
|
438
|
+
"focus": self.focus,
|
|
439
|
+
"shadow": self.shadow,
|
|
440
|
+
"confirm_close": self.confirm_close,
|
|
441
|
+
"transparent": self.transparent,
|
|
442
|
+
"text_select": self.text_select,
|
|
443
|
+
"zoomable": self.zoomable,
|
|
444
|
+
"vibrancy": self.vibrancy,
|
|
445
|
+
"background_color": self.background_color,
|
|
446
|
+
"menu": self.menu,
|
|
447
|
+
}
|
|
448
|
+
if self.position:
|
|
449
|
+
kwargs["x"], kwargs["y"] = self.position
|
|
450
|
+
return kwargs
|
|
451
|
+
|
|
452
|
+
def run(self, ui: str | Path | None = None, debug: bool = False) -> None:
|
|
453
|
+
"""Create the window, start the GUI loop, and block until it closes.
|
|
454
|
+
|
|
455
|
+
The GUI loop owns the calling thread, so ``run`` is normally the last
|
|
456
|
+
statement of your entry module. ``zagred dev`` and ``zagred build`` both
|
|
457
|
+
call this for you.
|
|
458
|
+
|
|
459
|
+
Args:
|
|
460
|
+
ui: What to load. An ``http(s)://`` URL is used as-is (the dev
|
|
461
|
+
server). Anything else is resolved as a path — either a
|
|
462
|
+
directory containing ``index.html`` or an HTML file — searching
|
|
463
|
+
the working tree, the standalone ``.dist`` folder, and the
|
|
464
|
+
onefile extraction directory.
|
|
465
|
+
debug: Open the WebView inspector and stop suppressing the right-click
|
|
466
|
+
context menu. Development only; ``zagred build --debug`` bakes
|
|
467
|
+
this into a packaged app.
|
|
468
|
+
|
|
469
|
+
Raises:
|
|
470
|
+
FileNotFoundError: If ``ui`` cannot be resolved to an existing file.
|
|
471
|
+
SystemExit: If no webview engine is available. The platform-specific
|
|
472
|
+
fix is printed to stderr first.
|
|
473
|
+
"""
|
|
474
|
+
import webview
|
|
475
|
+
|
|
476
|
+
# pywebview ships OPEN_DEVTOOLS_IN_DEBUG=True, so any debug run pops the
|
|
477
|
+
# inspector window. Tie it to the explicit flag instead of global state.
|
|
478
|
+
webview.settings["OPEN_DEVTOOLS_IN_DEBUG"] = debug
|
|
479
|
+
|
|
480
|
+
target = resolve_ui(ui)
|
|
481
|
+
window = webview.create_window(
|
|
482
|
+
self.title,
|
|
483
|
+
url=target,
|
|
484
|
+
js_api=Bridge(self._exposed),
|
|
485
|
+
**self._window_kwargs(),
|
|
486
|
+
)
|
|
487
|
+
self._window = window
|
|
488
|
+
|
|
489
|
+
def inject(*_args):
|
|
490
|
+
"""Inject the JS shim off-thread, because this event blocks."""
|
|
491
|
+
|
|
492
|
+
# before_load is a *blocking* event on the main thread. Calling
|
|
493
|
+
# evaluate_js() there deadlocks: the renderer waits for this handler
|
|
494
|
+
# to return, so the page stays blank for seconds. Run it off-thread.
|
|
495
|
+
def work():
|
|
496
|
+
"""Evaluate the shim on a worker thread and ignore late failures."""
|
|
497
|
+
|
|
498
|
+
try:
|
|
499
|
+
window.evaluate_js(SHIM)
|
|
500
|
+
except Exception: # noqa: BLE001 - the page may already be gone
|
|
501
|
+
pass
|
|
502
|
+
|
|
503
|
+
threading.Thread(target=work, daemon=True).start()
|
|
504
|
+
|
|
505
|
+
def closing(*_args) -> bool:
|
|
506
|
+
"""Let any ``on_closing`` hook veto the close by returning False."""
|
|
507
|
+
for hook in self._closing_hooks:
|
|
508
|
+
if hook() is False:
|
|
509
|
+
return False
|
|
510
|
+
return True
|
|
511
|
+
|
|
512
|
+
window.events.before_load += inject
|
|
513
|
+
window.events.loaded += lambda *_: _run_all(self._loaded_hooks)
|
|
514
|
+
window.events.closing += closing
|
|
515
|
+
window.events.closed += lambda *_: _run_all(self._closed_hooks)
|
|
516
|
+
|
|
517
|
+
try:
|
|
518
|
+
webview.start(debug=debug, func=_run_all, args=(self._start_hooks,), icon=self.icon)
|
|
519
|
+
except webview.errors.WebViewException as exc:
|
|
520
|
+
print(f"\n{exc}\n", file=sys.stderr)
|
|
521
|
+
hint = WEBVIEW_HINT.get(sys.platform) or "Install GTK or Qt bindings for your system."
|
|
522
|
+
print(hint, file=sys.stderr)
|
|
523
|
+
raise SystemExit(1) from exc
|
|
524
|
+
self._window = None
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _run_all(hooks: list[Callable]) -> None:
|
|
528
|
+
"""Call every hook in order, letting exceptions propagate to the caller."""
|
|
529
|
+
for hook in hooks:
|
|
530
|
+
hook()
|
zagred/assets.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Locating the frontend in every place it can end up.
|
|
2
|
+
|
|
3
|
+
The same app must find ``ui/index.html`` in three very different layouts:
|
|
4
|
+
|
|
5
|
+
1. **Development** — plain files in the working tree (``web/index.html``).
|
|
6
|
+
2. **Nuitka standalone** — next to the binary, in ``my-app.dist/ui/``.
|
|
7
|
+
3. **Nuitka onefile** — inside a temporary directory that Nuitka unpacks at
|
|
8
|
+
startup. ``__compiled__.containing_dir`` still points at the *binary* there,
|
|
9
|
+
while ``__file__`` points inside the extraction directory, which is why the
|
|
10
|
+
search walks up from this module instead of trusting either alone.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _search_dirs() -> list[Path]:
|
|
21
|
+
"""Directories to try, most specific first.
|
|
22
|
+
|
|
23
|
+
Order:
|
|
24
|
+
|
|
25
|
+
1. ``$ZAGRED_ASSETS`` — explicit override, useful for tests and for hosts
|
|
26
|
+
that unpack assets somewhere unusual.
|
|
27
|
+
2. This module's directory, then its parent and grandparent. In a onefile
|
|
28
|
+
build this covers ``<tmp>/zagred/assets.py`` → ``<tmp>/ui``; in a
|
|
29
|
+
standalone build it covers ``.../my-app.dist/zagred/assets.py`` →
|
|
30
|
+
``.../my-app.dist/ui``.
|
|
31
|
+
3. The directory holding the executable (``sys.argv[0]``), for layouts where
|
|
32
|
+
the binary sits beside its assets.
|
|
33
|
+
4. The current working directory, which is what makes ``zagred dev`` work
|
|
34
|
+
from a project root.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Candidate directories with duplicates removed, keeping first
|
|
38
|
+
occurrences so the most specific location still wins.
|
|
39
|
+
"""
|
|
40
|
+
dirs: list[Path] = []
|
|
41
|
+
override = os.environ.get("ZAGRED_ASSETS")
|
|
42
|
+
if override:
|
|
43
|
+
dirs.append(Path(override))
|
|
44
|
+
|
|
45
|
+
here = Path(__file__).resolve().parent
|
|
46
|
+
dirs.extend([here, here.parent, here.parent.parent])
|
|
47
|
+
dirs.append(Path(sys.argv[0]).resolve().parent)
|
|
48
|
+
dirs.append(Path.cwd())
|
|
49
|
+
|
|
50
|
+
seen: set[Path] = set()
|
|
51
|
+
unique = []
|
|
52
|
+
for directory in dirs:
|
|
53
|
+
if directory not in seen:
|
|
54
|
+
seen.add(directory)
|
|
55
|
+
unique.append(directory)
|
|
56
|
+
return unique
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def resolve_ui(ui: str | Path | None) -> str:
|
|
60
|
+
"""Turn a UI specification into something pywebview can load.
|
|
61
|
+
|
|
62
|
+
Accepts a dev server URL, a directory (``index.html`` is appended), or a
|
|
63
|
+
direct path to an HTML file. Relative paths are searched across every
|
|
64
|
+
candidate from :func:`_search_dirs`, so the same call works in development
|
|
65
|
+
and inside a compiled binary.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
ui: ``http(s)://`` URL, directory, or HTML file. ``None`` is a
|
|
69
|
+
programming error and raises.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
An absolute URL (passed through unchanged) or an absolute filesystem
|
|
73
|
+
path to an existing HTML file.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
ValueError: If ``ui`` is ``None``.
|
|
77
|
+
FileNotFoundError: If no candidate location contains the file. The
|
|
78
|
+
message lists every directory that was searched, which is usually
|
|
79
|
+
enough to spot a missing ``--include-data-dir`` in a build.
|
|
80
|
+
"""
|
|
81
|
+
if ui is None:
|
|
82
|
+
raise ValueError("no UI given: pass a path, or set [ui] in zagred.toml")
|
|
83
|
+
|
|
84
|
+
text = str(ui)
|
|
85
|
+
if text.startswith(("http://", "https://")):
|
|
86
|
+
return text
|
|
87
|
+
|
|
88
|
+
path = Path(text)
|
|
89
|
+
candidates = [path] if path.is_absolute() else [d / text for d in _search_dirs()]
|
|
90
|
+
for candidate in candidates:
|
|
91
|
+
target = candidate / "index.html" if candidate.is_dir() else candidate
|
|
92
|
+
if target.is_file():
|
|
93
|
+
return str(target)
|
|
94
|
+
|
|
95
|
+
raise FileNotFoundError(
|
|
96
|
+
f"UI not found: {text!r}\n"
|
|
97
|
+
f"searched: {', '.join(str(p) for p in candidates)}\n"
|
|
98
|
+
"If this is a built binary, the frontend assets were not bundled."
|
|
99
|
+
)
|