pythonnative 0.18.0__py3-none-any.whl → 0.19.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.
- pythonnative/__init__.py +1 -1
- pythonnative/cli/pn.py +107 -1
- pythonnative/native_views/__init__.py +18 -5
- pythonnative/native_views/desktop.py +1489 -0
- pythonnative/platform.py +17 -8
- pythonnative/preview.py +471 -0
- pythonnative/runtime.py +26 -1
- pythonnative/screen.py +184 -4
- pythonnative/utils.py +38 -2
- {pythonnative-0.18.0.dist-info → pythonnative-0.19.0.dist-info}/METADATA +2 -1
- {pythonnative-0.18.0.dist-info → pythonnative-0.19.0.dist-info}/RECORD +15 -13
- {pythonnative-0.18.0.dist-info → pythonnative-0.19.0.dist-info}/WHEEL +0 -0
- {pythonnative-0.18.0.dist-info → pythonnative-0.19.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.18.0.dist-info → pythonnative-0.19.0.dist-info}/licenses/LICENSE +0 -0
- {pythonnative-0.18.0.dist-info → pythonnative-0.19.0.dist-info}/top_level.txt +0 -0
pythonnative/platform.py
CHANGED
|
@@ -27,7 +27,7 @@ import os
|
|
|
27
27
|
import sys
|
|
28
28
|
from typing import Any, Dict, Optional
|
|
29
29
|
|
|
30
|
-
from .utils import IS_ANDROID, IS_IOS
|
|
30
|
+
from .utils import IS_ANDROID, IS_DESKTOP, IS_IOS
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
def _detect_os() -> str:
|
|
@@ -35,6 +35,8 @@ def _detect_os() -> str:
|
|
|
35
35
|
return "android"
|
|
36
36
|
if IS_IOS:
|
|
37
37
|
return "ios"
|
|
38
|
+
if IS_DESKTOP:
|
|
39
|
+
return "desktop"
|
|
38
40
|
return "test"
|
|
39
41
|
|
|
40
42
|
|
|
@@ -72,12 +74,13 @@ class Platform:
|
|
|
72
74
|
"""Platform-aware constants and the ``select`` dispatcher.
|
|
73
75
|
|
|
74
76
|
All attributes are read at import time. ``OS`` is one of
|
|
75
|
-
``"ios"``, ``"android"``,
|
|
76
|
-
off-device, e.g., in unit
|
|
77
|
+
``"ios"``, ``"android"``, ``"desktop"`` (the Tkinter preview
|
|
78
|
+
backend), or ``"test"`` (when running off-device, e.g., in unit
|
|
79
|
+
tests).
|
|
77
80
|
"""
|
|
78
81
|
|
|
79
82
|
OS: str = _detect_os()
|
|
80
|
-
"""``"ios"``, ``"android"``, or ``"test"``."""
|
|
83
|
+
"""``"ios"``, ``"android"``, ``"desktop"``, or ``"test"``."""
|
|
81
84
|
|
|
82
85
|
Version: str = _detect_version()
|
|
83
86
|
"""Best-effort OS version string (``"17.4"``, ``"14"``, ``"python-3.11"``)."""
|
|
@@ -88,6 +91,9 @@ class Platform:
|
|
|
88
91
|
is_android: bool = IS_ANDROID
|
|
89
92
|
"""``True`` when running inside an Android process."""
|
|
90
93
|
|
|
94
|
+
is_desktop: bool = IS_DESKTOP
|
|
95
|
+
"""``True`` when running the desktop (Tkinter) preview backend."""
|
|
96
|
+
|
|
91
97
|
is_test: bool = OS == "test"
|
|
92
98
|
"""``True`` when running off-device (no native runtime)."""
|
|
93
99
|
|
|
@@ -96,13 +102,14 @@ class Platform:
|
|
|
96
102
|
"""Pick the value matching the current platform.
|
|
97
103
|
|
|
98
104
|
Looks up ``spec[Platform.OS]``, then falls back to
|
|
99
|
-
``spec["native"]`` (matches
|
|
100
|
-
``spec["default"]``,
|
|
105
|
+
``spec["native"]`` (matches iOS and Android — *not* desktop,
|
|
106
|
+
which is a development surface), then to ``spec["default"]``,
|
|
107
|
+
then to the explicit ``default`` argument.
|
|
101
108
|
|
|
102
109
|
Args:
|
|
103
110
|
spec: Mapping from platform name to value. Recognized keys:
|
|
104
|
-
``"ios"``, ``"android"``, ``"
|
|
105
|
-
``"default"``.
|
|
111
|
+
``"ios"``, ``"android"``, ``"desktop"``, ``"test"``,
|
|
112
|
+
``"native"``, ``"default"``.
|
|
106
113
|
default: Value returned when ``spec`` has no matching key
|
|
107
114
|
and no ``"default"`` entry.
|
|
108
115
|
|
|
@@ -144,9 +151,11 @@ def _set_platform_for_test(name: Optional[str]) -> None:
|
|
|
144
151
|
Platform.OS = _detect_os()
|
|
145
152
|
Platform.is_ios = IS_IOS
|
|
146
153
|
Platform.is_android = IS_ANDROID
|
|
154
|
+
Platform.is_desktop = IS_DESKTOP
|
|
147
155
|
Platform.is_test = Platform.OS == "test"
|
|
148
156
|
return
|
|
149
157
|
Platform.OS = name
|
|
150
158
|
Platform.is_ios = name == "ios"
|
|
151
159
|
Platform.is_android = name == "android"
|
|
160
|
+
Platform.is_desktop = name == "desktop"
|
|
152
161
|
Platform.is_test = name == "test"
|
pythonnative/preview.py
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
"""Desktop preview runtime — the engine behind ``pn preview``.
|
|
2
|
+
|
|
3
|
+
``pn preview`` renders a PythonNative app in a real OS window using the
|
|
4
|
+
Tkinter backend ([`pythonnative.native_views.desktop`][pythonnative.native_views.desktop]),
|
|
5
|
+
with **instant Fast Refresh** on every file save. It exists to make the
|
|
6
|
+
inner development loop fast: see your UI and iterate in seconds without
|
|
7
|
+
booting a simulator or deploying to a device.
|
|
8
|
+
|
|
9
|
+
Architecture
|
|
10
|
+
------------
|
|
11
|
+
- A single Tk window holds one *stage* frame. Every screen on the
|
|
12
|
+
navigation stack gets its own child container inside the stage; the
|
|
13
|
+
desktop view handlers create widgets under the active container.
|
|
14
|
+
- [`DesktopApp`][pythonnative.preview.DesktopApp] owns the navigation
|
|
15
|
+
stack of [`screen`][pythonnative.screen] hosts and the push/pop/reset
|
|
16
|
+
primitives the declarative navigators call through ``host._push`` /
|
|
17
|
+
``host._pop``.
|
|
18
|
+
- The Tk event loop runs on the main thread. A lightweight poll
|
|
19
|
+
(`~60 Hz`) drains (a) UI work marshaled from the asyncio runtime
|
|
20
|
+
thread via [`runtime.call_on_main_thread`][pythonnative.runtime.call_on_main_thread],
|
|
21
|
+
(b) re-renders requested off-thread, and (c) file-change reloads.
|
|
22
|
+
- A background [`FileWatcher`][pythonnative.hot_reload.FileWatcher]
|
|
23
|
+
detects ``.py`` edits and enqueues a reload onto the main thread.
|
|
24
|
+
|
|
25
|
+
This module imports ``tkinter`` and is only imported by the
|
|
26
|
+
``pn preview`` command, which sets ``PN_PLATFORM=desktop`` first.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import os
|
|
32
|
+
import queue
|
|
33
|
+
import sys
|
|
34
|
+
import tkinter as tk
|
|
35
|
+
import traceback
|
|
36
|
+
from typing import Any, Callable, List, Optional, Tuple
|
|
37
|
+
|
|
38
|
+
# iPhone-ish logical-point window so layouts that assume a phone-sized
|
|
39
|
+
# viewport look right out of the box; resizable at runtime.
|
|
40
|
+
DEFAULT_WIDTH = 390
|
|
41
|
+
DEFAULT_HEIGHT = 844
|
|
42
|
+
_POLL_INTERVAL_MS = 16
|
|
43
|
+
_WATCH_INTERVAL_S = 0.4
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class DesktopApp:
|
|
47
|
+
"""Navigation-stack controller for the desktop preview window.
|
|
48
|
+
|
|
49
|
+
One instance backs a preview session. It is handed to each
|
|
50
|
+
[`screen`][pythonnative.screen] host as the ``native_instance`` so
|
|
51
|
+
hosts can drive navigation (``push_screen`` / ``pop_screen`` /
|
|
52
|
+
``reset_to_root``), report the viewport size, and set the window
|
|
53
|
+
title — mirroring the role a ``UIViewController`` / ``Activity``
|
|
54
|
+
plays on device.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, root: Any, stage: Any, width: float, height: float) -> None:
|
|
58
|
+
self._root = root
|
|
59
|
+
self._stage = stage
|
|
60
|
+
self._width = float(width)
|
|
61
|
+
self._height = float(height)
|
|
62
|
+
self._stack: List[Any] = []
|
|
63
|
+
self._error_widget: Any = None
|
|
64
|
+
self._mount_failed = False
|
|
65
|
+
self._component_path = ""
|
|
66
|
+
|
|
67
|
+
# -- queried by the screen host -----------------------------------
|
|
68
|
+
|
|
69
|
+
def viewport_size(self) -> Tuple[float, float]:
|
|
70
|
+
"""Return the current stage size in points (host viewport)."""
|
|
71
|
+
return (self._width, self._height)
|
|
72
|
+
|
|
73
|
+
def set_title(self, title: str) -> None:
|
|
74
|
+
"""Set the preview window title (called from screen options)."""
|
|
75
|
+
try:
|
|
76
|
+
self._root.title(title)
|
|
77
|
+
except Exception:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
# -- container management -----------------------------------------
|
|
81
|
+
|
|
82
|
+
def _new_container(self) -> Any:
|
|
83
|
+
frame = tk.Frame(self._stage, highlightthickness=0, bd=0, background="#ffffff")
|
|
84
|
+
frame.place(x=0, y=0, relwidth=1.0, relheight=1.0)
|
|
85
|
+
frame.lift()
|
|
86
|
+
return frame
|
|
87
|
+
|
|
88
|
+
def _show_container(self, host: Any) -> None:
|
|
89
|
+
container = getattr(host, "_pn_container", None)
|
|
90
|
+
if container is not None:
|
|
91
|
+
try:
|
|
92
|
+
container.place(in_=self._stage, x=0, y=0, relwidth=1.0, relheight=1.0)
|
|
93
|
+
container.lift()
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
def _forget_container(self, host: Any) -> None:
|
|
98
|
+
container = getattr(host, "_pn_container", None)
|
|
99
|
+
if container is not None:
|
|
100
|
+
try:
|
|
101
|
+
container.place_forget()
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
def _activate(self, host: Any) -> None:
|
|
106
|
+
"""Make ``host`` the rendering target and reflow it to the viewport."""
|
|
107
|
+
from .native_views import desktop as desktop_backend
|
|
108
|
+
|
|
109
|
+
desktop_backend.set_root_container(getattr(host, "_pn_container", None))
|
|
110
|
+
self._show_container(host)
|
|
111
|
+
|
|
112
|
+
# -- lifecycle ----------------------------------------------------
|
|
113
|
+
|
|
114
|
+
def _make_host(self, component_path: str, args: Optional[dict] = None) -> Any:
|
|
115
|
+
from . import screen as screen_module
|
|
116
|
+
from .native_views import desktop as desktop_backend
|
|
117
|
+
|
|
118
|
+
container = self._new_container()
|
|
119
|
+
desktop_backend.set_root_container(container)
|
|
120
|
+
host = screen_module.create_screen(component_path, self)
|
|
121
|
+
host._pn_container = container
|
|
122
|
+
if args:
|
|
123
|
+
host.set_args(args)
|
|
124
|
+
return host
|
|
125
|
+
|
|
126
|
+
def mount_root(self, component_path: str) -> None:
|
|
127
|
+
"""Mount the initial screen as the base of the navigation stack.
|
|
128
|
+
|
|
129
|
+
Import-time failures (a missing dependency, a syntax error the
|
|
130
|
+
developer is mid-fix on) are shown as an error overlay and flagged
|
|
131
|
+
so the next successful reload remounts cleanly, rather than
|
|
132
|
+
crashing the preview process.
|
|
133
|
+
"""
|
|
134
|
+
self._component_path = component_path
|
|
135
|
+
self._clear_error()
|
|
136
|
+
try:
|
|
137
|
+
host = self._make_host(component_path)
|
|
138
|
+
except Exception:
|
|
139
|
+
self._mount_failed = True
|
|
140
|
+
self._show_error(traceback.format_exc())
|
|
141
|
+
return
|
|
142
|
+
self._stack.append(host)
|
|
143
|
+
try:
|
|
144
|
+
host.on_create()
|
|
145
|
+
host.on_resume()
|
|
146
|
+
self._mount_failed = False
|
|
147
|
+
except Exception:
|
|
148
|
+
self._mount_failed = True
|
|
149
|
+
self._show_error(traceback.format_exc())
|
|
150
|
+
|
|
151
|
+
def push_screen(self, component_path: str, args: Optional[dict] = None) -> None:
|
|
152
|
+
"""Push a new screen, suspending the current one (declarative nav)."""
|
|
153
|
+
if self._stack:
|
|
154
|
+
current = self._stack[-1]
|
|
155
|
+
try:
|
|
156
|
+
current.on_pause()
|
|
157
|
+
except Exception:
|
|
158
|
+
pass
|
|
159
|
+
self._forget_container(current)
|
|
160
|
+
try:
|
|
161
|
+
host = self._make_host(component_path, args)
|
|
162
|
+
except Exception:
|
|
163
|
+
self._show_error(traceback.format_exc())
|
|
164
|
+
return
|
|
165
|
+
self._stack.append(host)
|
|
166
|
+
try:
|
|
167
|
+
host.on_create()
|
|
168
|
+
host.on_resume()
|
|
169
|
+
except Exception:
|
|
170
|
+
self._show_error(traceback.format_exc())
|
|
171
|
+
|
|
172
|
+
def pop_screen(self) -> None:
|
|
173
|
+
"""Pop the top screen and restore the one beneath it."""
|
|
174
|
+
if len(self._stack) <= 1:
|
|
175
|
+
return
|
|
176
|
+
top = self._stack.pop()
|
|
177
|
+
self._teardown(top)
|
|
178
|
+
restored = self._stack[-1]
|
|
179
|
+
self._activate(restored)
|
|
180
|
+
try:
|
|
181
|
+
restored.on_resume()
|
|
182
|
+
restored.set_viewport_size(self._width, self._height)
|
|
183
|
+
except Exception:
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
def reset_to_root(self) -> None:
|
|
187
|
+
"""Pop every screen above the root (declarative ``reset`` / tab root)."""
|
|
188
|
+
while len(self._stack) > 1:
|
|
189
|
+
self._teardown(self._stack.pop())
|
|
190
|
+
if self._stack:
|
|
191
|
+
root_host = self._stack[0]
|
|
192
|
+
self._activate(root_host)
|
|
193
|
+
try:
|
|
194
|
+
root_host.on_resume()
|
|
195
|
+
root_host.set_viewport_size(self._width, self._height)
|
|
196
|
+
except Exception:
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
def _teardown(self, host: Any) -> None:
|
|
200
|
+
try:
|
|
201
|
+
host.on_pause()
|
|
202
|
+
except Exception:
|
|
203
|
+
pass
|
|
204
|
+
try:
|
|
205
|
+
host.on_destroy()
|
|
206
|
+
except Exception:
|
|
207
|
+
pass
|
|
208
|
+
reconciler = getattr(host, "_reconciler", None)
|
|
209
|
+
tree = getattr(reconciler, "_tree", None) if reconciler is not None else None
|
|
210
|
+
if reconciler is not None and tree is not None:
|
|
211
|
+
try:
|
|
212
|
+
reconciler._destroy_tree(tree)
|
|
213
|
+
except Exception:
|
|
214
|
+
pass
|
|
215
|
+
container = getattr(host, "_pn_container", None)
|
|
216
|
+
if container is not None:
|
|
217
|
+
try:
|
|
218
|
+
container.destroy()
|
|
219
|
+
except Exception:
|
|
220
|
+
pass
|
|
221
|
+
|
|
222
|
+
# -- viewport / resize --------------------------------------------
|
|
223
|
+
|
|
224
|
+
def resize(self, width: float, height: float) -> None:
|
|
225
|
+
"""Propagate a window resize to the active screen's reconciler."""
|
|
226
|
+
if width <= 0 or height <= 0:
|
|
227
|
+
return
|
|
228
|
+
self._width = float(width)
|
|
229
|
+
self._height = float(height)
|
|
230
|
+
host = self.active_host()
|
|
231
|
+
if host is not None:
|
|
232
|
+
try:
|
|
233
|
+
host.set_viewport_size(self._width, self._height)
|
|
234
|
+
except Exception:
|
|
235
|
+
pass
|
|
236
|
+
|
|
237
|
+
def active_host(self) -> Any:
|
|
238
|
+
"""Return the top-of-stack host, or ``None`` if nothing is mounted."""
|
|
239
|
+
return self._stack[-1] if self._stack else None
|
|
240
|
+
|
|
241
|
+
# -- hot reload ---------------------------------------------------
|
|
242
|
+
|
|
243
|
+
def reload(self, changed_modules: Optional[List[str]] = None) -> None:
|
|
244
|
+
"""Apply a hot reload across every mounted screen.
|
|
245
|
+
|
|
246
|
+
If the initial mount failed (e.g. a syntax error the developer
|
|
247
|
+
is now fixing), this re-attempts a fresh mount so the preview
|
|
248
|
+
recovers without a restart. Otherwise each host on the stack
|
|
249
|
+
performs Fast Refresh against the reloaded modules.
|
|
250
|
+
"""
|
|
251
|
+
from .native_views import desktop as desktop_backend
|
|
252
|
+
|
|
253
|
+
if self._mount_failed or not self._stack:
|
|
254
|
+
self._remount_root()
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
self._clear_error()
|
|
258
|
+
for host in self._stack:
|
|
259
|
+
desktop_backend.set_root_container(getattr(host, "_pn_container", None))
|
|
260
|
+
try:
|
|
261
|
+
host.reload(changed_modules)
|
|
262
|
+
except Exception:
|
|
263
|
+
self._show_error(traceback.format_exc())
|
|
264
|
+
active = self.active_host()
|
|
265
|
+
if active is not None:
|
|
266
|
+
desktop_backend.set_root_container(getattr(active, "_pn_container", None))
|
|
267
|
+
|
|
268
|
+
def _remount_root(self) -> None:
|
|
269
|
+
for host in list(self._stack):
|
|
270
|
+
self._teardown(host)
|
|
271
|
+
self._stack.clear()
|
|
272
|
+
self.mount_root(self._component_path)
|
|
273
|
+
|
|
274
|
+
# -- error overlay ------------------------------------------------
|
|
275
|
+
|
|
276
|
+
def _show_error(self, message: str) -> None:
|
|
277
|
+
self._clear_error()
|
|
278
|
+
widget = tk.Text(
|
|
279
|
+
self._stage,
|
|
280
|
+
wrap="word",
|
|
281
|
+
background="#1c1c1e",
|
|
282
|
+
foreground="#ff6b6b",
|
|
283
|
+
insertbackground="#ffffff",
|
|
284
|
+
borderwidth=0,
|
|
285
|
+
highlightthickness=0,
|
|
286
|
+
padx=16,
|
|
287
|
+
pady=16,
|
|
288
|
+
)
|
|
289
|
+
widget.insert("1.0", "PythonNative preview error\n\n" + message)
|
|
290
|
+
widget.configure(state="disabled")
|
|
291
|
+
widget.place(x=0, y=0, relwidth=1.0, relheight=1.0)
|
|
292
|
+
widget.lift()
|
|
293
|
+
self._error_widget = widget
|
|
294
|
+
print(message, file=sys.stderr)
|
|
295
|
+
|
|
296
|
+
def _clear_error(self) -> None:
|
|
297
|
+
if self._error_widget is not None:
|
|
298
|
+
try:
|
|
299
|
+
self._error_widget.destroy()
|
|
300
|
+
except Exception:
|
|
301
|
+
pass
|
|
302
|
+
self._error_widget = None
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _resolve_paths(component_path: str, project_root: Optional[str], watch_dir: Optional[str]) -> Tuple[str, str]:
|
|
306
|
+
"""Return ``(project_root, watch_dir)`` with sensible defaults.
|
|
307
|
+
|
|
308
|
+
The project root (which must be importable for ``component_path`` to
|
|
309
|
+
resolve) is prepended to ``sys.path``; the watch dir defaults to the
|
|
310
|
+
top-level package directory of ``component_path`` under the root.
|
|
311
|
+
"""
|
|
312
|
+
root = os.path.abspath(project_root or os.getcwd())
|
|
313
|
+
if root not in sys.path:
|
|
314
|
+
sys.path.insert(0, root)
|
|
315
|
+
if watch_dir is None:
|
|
316
|
+
top_package = component_path.split(".", 1)[0]
|
|
317
|
+
candidate = os.path.join(root, top_package)
|
|
318
|
+
watch_dir = candidate if os.path.isdir(candidate) else root
|
|
319
|
+
return root, os.path.abspath(watch_dir)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def run_preview(
|
|
323
|
+
component_path: str,
|
|
324
|
+
*,
|
|
325
|
+
project_root: Optional[str] = None,
|
|
326
|
+
watch_dir: Optional[str] = None,
|
|
327
|
+
width: int = DEFAULT_WIDTH,
|
|
328
|
+
height: int = DEFAULT_HEIGHT,
|
|
329
|
+
title: str = "PythonNative Preview",
|
|
330
|
+
hot_reload: bool = True,
|
|
331
|
+
) -> None:
|
|
332
|
+
"""Open the preview window for ``component_path`` and run until closed.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
component_path: Module path (``"app.main"`` → its ``App``) or a
|
|
336
|
+
dotted ``module.Component`` path, same convention as
|
|
337
|
+
[`create_screen`][pythonnative.create_screen].
|
|
338
|
+
project_root: Directory added to ``sys.path`` so the component
|
|
339
|
+
imports. Defaults to the current working directory.
|
|
340
|
+
watch_dir: Directory watched for ``.py`` changes. Defaults to
|
|
341
|
+
the component's top-level package (e.g. ``app/``).
|
|
342
|
+
width: Initial window width in points.
|
|
343
|
+
height: Initial window height in points.
|
|
344
|
+
title: Window title.
|
|
345
|
+
hot_reload: Watch for file changes and Fast Refresh on save.
|
|
346
|
+
|
|
347
|
+
Raises:
|
|
348
|
+
RuntimeError: If ``PN_PLATFORM=desktop`` was not set before
|
|
349
|
+
PythonNative was imported (``pn preview`` sets it for you).
|
|
350
|
+
"""
|
|
351
|
+
from . import runtime as runtime_module
|
|
352
|
+
from .native_views import desktop as desktop_backend
|
|
353
|
+
from .utils import IS_DESKTOP
|
|
354
|
+
|
|
355
|
+
if not IS_DESKTOP:
|
|
356
|
+
raise RuntimeError(
|
|
357
|
+
"run_preview() requires the desktop backend. Set PN_PLATFORM=desktop "
|
|
358
|
+
"before importing pythonnative (the `pn preview` command does this)."
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
root_dir, watched = _resolve_paths(component_path, project_root, watch_dir)
|
|
362
|
+
|
|
363
|
+
root = tk.Tk()
|
|
364
|
+
root.title(title)
|
|
365
|
+
root.geometry(f"{int(width)}x{int(height)}")
|
|
366
|
+
root.minsize(240, 320)
|
|
367
|
+
stage = tk.Frame(root, background="#ffffff", highlightthickness=0, bd=0)
|
|
368
|
+
stage.pack(fill="both", expand=True)
|
|
369
|
+
desktop_backend.set_root_container(stage)
|
|
370
|
+
|
|
371
|
+
app = DesktopApp(root, stage, width, height)
|
|
372
|
+
|
|
373
|
+
# Marshal asyncio-thread UI work (animations, alerts) onto the Tk
|
|
374
|
+
# main thread by funneling it through this queue, drained in _poll.
|
|
375
|
+
main_queue: "queue.Queue[Callable[[], None]]" = queue.Queue()
|
|
376
|
+
runtime_module.set_desktop_main_dispatch(main_queue.put)
|
|
377
|
+
|
|
378
|
+
app.mount_root(component_path)
|
|
379
|
+
|
|
380
|
+
def _on_configure(event: Any) -> None:
|
|
381
|
+
if event.widget is stage:
|
|
382
|
+
app.resize(event.width, event.height)
|
|
383
|
+
|
|
384
|
+
stage.bind("<Configure>", _on_configure)
|
|
385
|
+
|
|
386
|
+
watcher = _build_watcher(watched, root_dir, app, main_queue) if hot_reload else None
|
|
387
|
+
if watcher is not None:
|
|
388
|
+
watcher.start()
|
|
389
|
+
print(f"[pn preview] watching {watched} for changes", file=sys.stderr)
|
|
390
|
+
|
|
391
|
+
from . import screen as screen_module
|
|
392
|
+
|
|
393
|
+
def _poll() -> None:
|
|
394
|
+
for _ in range(128):
|
|
395
|
+
try:
|
|
396
|
+
job = main_queue.get_nowait()
|
|
397
|
+
except queue.Empty:
|
|
398
|
+
break
|
|
399
|
+
try:
|
|
400
|
+
job()
|
|
401
|
+
except Exception:
|
|
402
|
+
traceback.print_exc()
|
|
403
|
+
try:
|
|
404
|
+
screen_module.drain_desktop_scheduled_renders()
|
|
405
|
+
except Exception:
|
|
406
|
+
traceback.print_exc()
|
|
407
|
+
try:
|
|
408
|
+
root.after(_POLL_INTERVAL_MS, _poll)
|
|
409
|
+
except Exception:
|
|
410
|
+
pass
|
|
411
|
+
|
|
412
|
+
def _on_close() -> None:
|
|
413
|
+
if watcher is not None:
|
|
414
|
+
try:
|
|
415
|
+
watcher.stop()
|
|
416
|
+
except Exception:
|
|
417
|
+
pass
|
|
418
|
+
runtime_module.set_desktop_main_dispatch(None)
|
|
419
|
+
desktop_backend.clear_root_container()
|
|
420
|
+
try:
|
|
421
|
+
root.destroy()
|
|
422
|
+
except Exception:
|
|
423
|
+
pass
|
|
424
|
+
|
|
425
|
+
root.protocol("WM_DELETE_WINDOW", _on_close)
|
|
426
|
+
root.after(_POLL_INTERVAL_MS, _poll)
|
|
427
|
+
print(f"[pn preview] {component_path} — {int(width)}x{int(height)}", file=sys.stderr)
|
|
428
|
+
try:
|
|
429
|
+
root.mainloop()
|
|
430
|
+
except KeyboardInterrupt:
|
|
431
|
+
pass
|
|
432
|
+
finally:
|
|
433
|
+
if watcher is not None:
|
|
434
|
+
try:
|
|
435
|
+
watcher.stop()
|
|
436
|
+
except Exception:
|
|
437
|
+
pass
|
|
438
|
+
runtime_module.set_desktop_main_dispatch(None)
|
|
439
|
+
desktop_backend.clear_root_container()
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _build_watcher(
|
|
443
|
+
watch_dir: str,
|
|
444
|
+
base_dir: str,
|
|
445
|
+
app: DesktopApp,
|
|
446
|
+
main_queue: "queue.Queue[Callable[[], None]]",
|
|
447
|
+
) -> Any:
|
|
448
|
+
"""Create a file watcher that enqueues reloads onto the main thread.
|
|
449
|
+
|
|
450
|
+
The watcher runs on its own daemon thread; because Tkinter is not
|
|
451
|
+
thread-safe, the ``on_change`` callback only *enqueues* the reload
|
|
452
|
+
(translated from changed file paths into dotted module names), which
|
|
453
|
+
[`run_preview`][pythonnative.preview.run_preview]'s poll loop runs
|
|
454
|
+
on the Tk main thread.
|
|
455
|
+
"""
|
|
456
|
+
from .hot_reload import FileWatcher, ModuleReloader
|
|
457
|
+
|
|
458
|
+
def _on_change(changed_files: List[str]) -> None:
|
|
459
|
+
modules: List[str] = []
|
|
460
|
+
for path in changed_files:
|
|
461
|
+
module = ModuleReloader.file_to_module(path, base_dir)
|
|
462
|
+
if module:
|
|
463
|
+
modules.append(module)
|
|
464
|
+
|
|
465
|
+
def _apply() -> None:
|
|
466
|
+
print(f"[pn preview] reloading: {', '.join(modules) or 'app'}", file=sys.stderr)
|
|
467
|
+
app.reload(modules)
|
|
468
|
+
|
|
469
|
+
main_queue.put(_apply)
|
|
470
|
+
|
|
471
|
+
return FileWatcher(watch_dir, _on_change, interval=_WATCH_INTERVAL_S)
|
pythonnative/runtime.py
CHANGED
|
@@ -290,6 +290,27 @@ def create_future() -> "asyncio.Future[Any]":
|
|
|
290
290
|
# bridge back when it needs to talk to native UI.
|
|
291
291
|
|
|
292
292
|
|
|
293
|
+
# Desktop (Tkinter) main-thread dispatcher, installed by
|
|
294
|
+
# ``pythonnative.preview`` while a ``pn preview`` session is live. Tk is
|
|
295
|
+
# not thread-safe, so UI work scheduled from the asyncio worker thread
|
|
296
|
+
# (animations, alerts) must hop onto the Tk main thread; the preview's
|
|
297
|
+
# poll loop drains whatever this dispatcher enqueues. When no preview is
|
|
298
|
+
# running (plain scripts / tests) the dispatcher stays ``None`` and work
|
|
299
|
+
# runs inline.
|
|
300
|
+
_desktop_main_dispatch: Optional[Callable[[Callable[[], None]], None]] = None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def set_desktop_main_dispatch(dispatch: Optional[Callable[[Callable[[], None]], None]]) -> None:
|
|
304
|
+
"""Install (or clear) the desktop main-thread dispatcher.
|
|
305
|
+
|
|
306
|
+
Called by ``pythonnative.preview`` with a
|
|
307
|
+
function that marshals ``fn`` onto the Tk main thread, and with
|
|
308
|
+
``None`` when the preview window closes.
|
|
309
|
+
"""
|
|
310
|
+
global _desktop_main_dispatch
|
|
311
|
+
_desktop_main_dispatch = dispatch
|
|
312
|
+
|
|
313
|
+
|
|
293
314
|
def call_on_main_thread(fn: Callable[[], None]) -> None:
|
|
294
315
|
"""Run ``fn()`` on the platform UI thread.
|
|
295
316
|
|
|
@@ -299,7 +320,9 @@ def call_on_main_thread(fn: Callable[[], None]) -> None:
|
|
|
299
320
|
``_ios_call_on_main`` comment block for why this matters).
|
|
300
321
|
- **Android**: posts a ``Runnable`` to
|
|
301
322
|
``Handler(Looper.getMainLooper())``.
|
|
302
|
-
- **Desktop
|
|
323
|
+
- **Desktop**: enqueues ``fn`` for the ``pn preview`` poll loop to
|
|
324
|
+
run on the Tk main thread (or runs inline if no preview is live).
|
|
325
|
+
- **Tests**: runs ``fn()`` inline.
|
|
303
326
|
|
|
304
327
|
Exceptions raised by ``fn`` are caught and printed; they must not
|
|
305
328
|
propagate into UIKit / the Android Looper. If you need to surface
|
|
@@ -317,6 +340,8 @@ def call_on_main_thread(fn: Callable[[], None]) -> None:
|
|
|
317
340
|
_ios_call_on_main(fn)
|
|
318
341
|
elif Platform.is_android:
|
|
319
342
|
_android_call_on_main(fn)
|
|
343
|
+
elif Platform.is_desktop and _desktop_main_dispatch is not None:
|
|
344
|
+
_desktop_main_dispatch(fn)
|
|
320
345
|
else:
|
|
321
346
|
fn()
|
|
322
347
|
|