FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

GitHub Viewer

"""Desktop preview runtime, the engine behind ``pn preview``. ``pn preview`` renders a PythonNative app in a real OS window using the Tkinter backend ([`pythonnative.native_views.desktop`][pythonnative.native_views.desktop]), with **instant Fast Refresh** on every file save. It exists to make the inner development loop fast: see your UI and iterate in seconds without booting a simulator or deploying to a device. Architecture ------------ - A single Tk window holds one *stage* frame. Every screen on the navigation stack gets its own child container inside the stage; the desktop view handlers create widgets under the active container. - [`DesktopApp`][pythonnative.preview.DesktopApp] owns the navigation stack of [`screen`][pythonnative.screen] hosts and the push/pop/reset primitives the declarative navigators call through ``host._push`` / ``host._pop``. - The Tk event loop runs on the main thread. A lightweight poll (`~60 Hz`) drains (a) UI work marshaled from the asyncio runtime thread via [`runtime.call_on_main_thread`][pythonnative.runtime.call_on_main_thread], (b) re-renders requested off-thread, and (c) file-change reloads. - A background [`FileWatcher`][pythonnative.hot_reload.FileWatcher] detects ``.py`` edits and enqueues a reload onto the main thread. This module imports ``tkinter`` and is only imported by the ``pn preview`` command, which sets ``PN_PLATFORM=desktop`` first. """ from __future__ import annotations import os import queue import sys import tkinter as tk import traceback from typing import Any, Callable, List, Optional, Tuple # iPhone-ish logical-point window so layouts that assume a phone-sized # viewport look right out of the box; resizable at runtime. DEFAULT_WIDTH = 390 DEFAULT_HEIGHT = 844 _POLL_INTERVAL_MS = 16 _WATCH_INTERVAL_S = 0.4 def _publish_desktop_color_scheme() -> None: """Publish the host OS appearance so `use_color_scheme` works in preview. ``PN_COLOR_SCHEME=light|dark`` forces a value (handy for testing both appearances); otherwise macOS is asked via ``defaults read`` (the key only exists when dark mode is on). Other platforms default to light. """ from . import appearance forced = os.environ.get("PN_COLOR_SCHEME") if forced in ("light", "dark"): appearance.set_system_color_scheme(forced) return if sys.platform == "darwin": import subprocess try: result = subprocess.run( ["defaults", "read", "-g", "AppleInterfaceStyle"], capture_output=True, text=True, timeout=2, ) is_dark = result.returncode == 0 and result.stdout.strip() == "Dark" appearance.set_system_color_scheme("dark" if is_dark else "light") except Exception: pass class DesktopApp: """Navigation-stack controller for the desktop preview window. One instance backs a preview session. It is handed to each [`screen`][pythonnative.screen] host as the ``native_instance`` so hosts can drive navigation (``push_screen`` / ``pop_screen`` / ``reset_to_root``), report the viewport size, and set the window title, mirroring the role a ``UIViewController`` / ``Activity`` plays on device. """ def __init__(self, root: Any, stage: Any, width: float, height: float) -> None: self._root = root self._stage = stage self._width = float(width) self._height = float(height) self._stack: List[Any] = [] self._error_widget: Any = None self._mount_failed = False self._component_path = "" # -- queried by the screen host ----------------------------------- def viewport_size(self) -> Tuple[float, float]: """Return the current stage size in points (host viewport).""" return (self._width, self._height) def set_title(self, title: str) -> None: """Set the preview window title (called from screen options).""" try: self._root.title(title) except Exception: pass # -- container management ----------------------------------------- def _new_container(self) -> Any: frame = tk.Frame(self._stage, highlightthickness=0, bd=0, background="#ffffff") frame.place(x=0, y=0, relwidth=1.0, relheight=1.0) frame.lift() return frame def _show_container(self, host: Any) -> None: container = getattr(host, "_pn_container", None) if container is not None: try: container.place(in_=self._stage, x=0, y=0, relwidth=1.0, relheight=1.0) container.lift() except Exception: pass def _forget_container(self, host: Any) -> None: container = getattr(host, "_pn_container", None) if container is not None: try: container.place_forget() except Exception: pass def _activate(self, host: Any) -> None: """Make ``host`` the rendering target and reflow it to the viewport.""" from .native_views import desktop as desktop_backend desktop_backend.set_root_container(getattr(host, "_pn_container", None)) self._show_container(host) # -- lifecycle ---------------------------------------------------- def _make_host(self, component_path: str, args: Optional[dict] = None) -> Any: from . import screen as screen_module from .native_views import desktop as desktop_backend container = self._new_container() desktop_backend.set_root_container(container) host = screen_module.create_screen(component_path, self) host._pn_container = container if args: host.set_args(args) return host def mount_root(self, component_path: str) -> None: """Mount the initial screen as the base of the navigation stack. Import-time failures (a missing dependency, a syntax error the developer is mid-fix on) are shown as an error overlay and flagged so the next successful reload remounts cleanly, rather than crashing the preview process. """ self._component_path = component_path self._clear_error() try: host = self._make_host(component_path) except Exception: self._mount_failed = True self._show_error(traceback.format_exc()) return self._stack.append(host) try: host.on_create() host.on_resume() self._mount_failed = False except Exception: self._mount_failed = True self._show_error(traceback.format_exc()) def push_screen(self, component_path: str, args: Optional[dict] = None) -> None: """Push a new screen, suspending the current one (declarative nav).""" if self._stack: current = self._stack[-1] try: current.on_pause() except Exception: pass self._forget_container(current) try: host = self._make_host(component_path, args) except Exception: self._show_error(traceback.format_exc()) return self._stack.append(host) try: host.on_create() host.on_resume() except Exception: self._show_error(traceback.format_exc()) def pop_screen(self) -> None: """Pop the top screen and restore the one beneath it.""" if len(self._stack) None: """Pop every screen above the root (declarative ``reset`` / tab root).""" while len(self._stack) > 1: self._teardown(self._stack.pop()) if self._stack: root_host = self._stack[0] self._activate(root_host) try: root_host.on_resume() root_host.set_viewport_size(self._width, self._height) except Exception: pass def _teardown(self, host: Any) -> None: try: host.on_pause() except Exception: pass # ``on_destroy`` unmounts the host's reconciler: effect cleanups # run, native widgets are destroyed, and event registrations are # released (see ``screen._destroy_host``). try: host.on_destroy() except Exception: pass container = getattr(host, "_pn_container", None) if container is not None: try: container.destroy() except Exception: pass def teardown_all(self) -> None: """Destroy every screen on the stack (preview window closing).""" while self._stack: self._teardown(self._stack.pop()) def back_pressed(self) -> None: """Route a desktop back gesture (Escape) like a hardware back press. ``use_back_handler`` subscribers on the active screen get the first chance to consume the event; otherwise the stack pops (matching Android's default back behavior). At the root the event is ignored. """ host = self.active_host() if host is None: return try: if host.on_back_pressed(): return except Exception: pass self.pop_screen() # -- viewport / resize -------------------------------------------- def resize(self, width: float, height: float) -> None: """Propagate a window resize to the active screen's reconciler.""" if width None: """Apply a hot reload across every mounted screen. If the initial mount failed (e.g. a syntax error the developer is now fixing), this re-attempts a fresh mount so the preview recovers without a restart. Otherwise each host on the stack performs Fast Refresh against the reloaded modules. """ from .native_views import desktop as desktop_backend if self._mount_failed or not self._stack: self._remount_root() return self._clear_error() for host in self._stack: desktop_backend.set_root_container(getattr(host, "_pn_container", None)) try: host.reload(changed_modules) except Exception: self._show_error(traceback.format_exc()) active = self.active_host() if active is not None: desktop_backend.set_root_container(getattr(active, "_pn_container", None)) def _remount_root(self) -> None: for host in list(self._stack): self._teardown(host) self._stack.clear() self.mount_root(self._component_path) # -- error overlay ------------------------------------------------ def _show_error(self, message: str) -> None: self._clear_error() widget = tk.Text( self._stage, wrap="word", background="#1c1c1e", foreground="#ff6b6b", insertbackground="#ffffff", borderwidth=0, highlightthickness=0, padx=16, pady=16, ) widget.insert("1.0", "PythonNative preview error\n\n" + message) widget.configure(state="disabled") widget.place(x=0, y=0, relwidth=1.0, relheight=1.0) widget.lift() self._error_widget = widget print(message, file=sys.stderr) def _clear_error(self) -> None: if self._error_widget is not None: try: self._error_widget.destroy() except Exception: pass self._error_widget = None def _resolve_paths(component_path: str, project_root: Optional[str], watch_dir: Optional[str]) -> Tuple[str, str]: """Return ``(project_root, watch_dir)`` with sensible defaults. The project root (which must be importable for ``component_path`` to resolve) is prepended to ``sys.path``; the watch dir defaults to the top-level package directory of ``component_path`` under the root. """ root = os.path.abspath(project_root or os.getcwd()) if root not in sys.path: sys.path.insert(0, root) if watch_dir is None: top_package = component_path.split(".", 1)[0] candidate = os.path.join(root, top_package) watch_dir = candidate if os.path.isdir(candidate) else root return root, os.path.abspath(watch_dir) def run_preview( component_path: str, *, project_root: Optional[str] = None, watch_dir: Optional[str] = None, width: int = DEFAULT_WIDTH, height: int = DEFAULT_HEIGHT, title: str = "PythonNative Preview", hot_reload: bool = True, ) -> None: """Open the preview window for ``component_path`` and run until closed. Args: component_path: Module path (``"app.main"`` → its ``App``) or a dotted ``module.Component`` path, same convention as [`create_screen`][pythonnative.create_screen]. project_root: Directory added to ``sys.path`` so the component imports. Defaults to the current working directory. watch_dir: Directory watched for ``.py`` changes. Defaults to the component's top-level package (e.g. ``app/``). width: Initial window width in points. height: Initial window height in points. title: Window title. hot_reload: Watch for file changes and Fast Refresh on save. Raises: RuntimeError: If ``PN_PLATFORM=desktop`` was not set before PythonNative was imported (``pn preview`` sets it for you). """ from . import runtime as runtime_module from .native_views import desktop as desktop_backend from .utils import IS_DESKTOP if not IS_DESKTOP: raise RuntimeError( "run_preview() requires the desktop backend. Set PN_PLATFORM=desktop " "before importing pythonnative (the `pn preview` command does this)." ) root_dir, watched = _resolve_paths(component_path, project_root, watch_dir) _publish_desktop_color_scheme() # The preview is inherently a development surface: turn on dev # diagnostics (validation warnings, hook-order checks, RedBox). from . import diagnostics diagnostics.set_dev_mode(True) root = tk.Tk() root.title(title) root.geometry(f"{int(width)}x{int(height)}") root.minsize(240, 320) stage = tk.Frame(root, background="#ffffff", highlightthickness=0, bd=0) stage.pack(fill="both", expand=True) desktop_backend.set_root_container(stage) app = DesktopApp(root, stage, width, height) # Marshal asyncio-thread UI work (animations, alerts) onto the Tk # main thread by funneling it through this queue, drained in _poll. main_queue: "queue.Queue[Callable[[], None]]" = queue.Queue() runtime_module.set_desktop_main_dispatch(main_queue.put) app.mount_root(component_path) def _on_configure(event: Any) -> None: if event.widget is stage: app.resize(event.width, event.height) stage.bind("", _on_configure) # Escape acts as the desktop stand-in for the hardware back button: # ``use_back_handler`` subscribers can intercept it, and otherwise # the navigation stack pops. root.bind("", lambda _event: app.back_pressed()) watcher = _build_watcher(watched, root_dir, app, main_queue) if hot_reload else None if watcher is not None: watcher.start() print(f"[pn preview] watching {watched} for changes", file=sys.stderr) from . import screen as screen_module def _poll() -> None: for _ in range(128): try: job = main_queue.get_nowait() except queue.Empty: break try: job() except Exception: traceback.print_exc() try: screen_module.drain_desktop_scheduled_renders() except Exception: traceback.print_exc() try: root.after(_POLL_INTERVAL_MS, _poll) except Exception: pass def _on_close() -> None: if watcher is not None: try: watcher.stop() except Exception: pass # Unmount every screen first so effect cleanups (timers, tasks, # subscriptions) run before the Tk interpreter goes away. try: app.teardown_all() except Exception: pass runtime_module.set_desktop_main_dispatch(None) desktop_backend.clear_root_container() try: root.destroy() except Exception: pass root.protocol("WM_DELETE_WINDOW", _on_close) root.after(_POLL_INTERVAL_MS, _poll) print(f"[pn preview] {component_path} ({int(width)}x{int(height)})", file=sys.stderr) try: root.mainloop() except KeyboardInterrupt: pass finally: if watcher is not None: try: watcher.stop() except Exception: pass runtime_module.set_desktop_main_dispatch(None) desktop_backend.clear_root_container() def _build_watcher( watch_dir: str, base_dir: str, app: DesktopApp, main_queue: "queue.Queue[Callable[[], None]]", ) -> Any: """Create a file watcher that enqueues reloads onto the main thread. The watcher runs on its own daemon thread; because Tkinter is not thread-safe, the ``on_change`` callback only *enqueues* the reload (translated from changed file paths into dotted module names), which [`run_preview`][pythonnative.preview.run_preview]'s poll loop runs on the Tk main thread. """ from .hot_reload import FileWatcher, ModuleReloader def _on_change(changed_files: List[str]) -> None: modules: List[str] = [] for path in changed_files: module = ModuleReloader.file_to_module(path, base_dir) if module: modules.append(module) def _apply() -> None: print(f"[pn preview] reloading: {', '.join(modules) or 'app'}", file=sys.stderr) app.reload(modules) main_queue.put(_apply) return FileWatcher(watch_dir, _on_change, interval=_WATCH_INTERVAL_S)

Back | FazBrowse Home | New Git URL