diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b79159..6162124 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,15 @@ # CHANGELOG +## v0.19.0 (2026-06-02) + +### Features + +- **native_views,cli**: Add desktop preview backend and pn preview + ([#8](https://github.com/pythonnative/pythonnative/pull/8), + [`3228f11`](https://github.com/pythonnative/pythonnative/commit/3228f11b4fc70f92c8fede4b03639f8eb51bc24c)) + + ## v0.18.0 (2026-05-31) ### Features diff --git a/README.md b/README.md index 7a91aeb..652aaff 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ PythonNative is a cross-platform toolkit for building native Android and iOS app - **Direct native bindings:** Python calls platform APIs directly through Chaquopy and rubicon-objc, with no JavaScript bridge. - **Custom-component SDK:** Wrap any platform widget as a first-class element with type-checked props via `pythonnative.sdk` (`Props`, `@native_component`, `element_factory`). Plugins distributed on PyPI auto-register through the `pythonnative.handlers` entry-point group. - **CLI scaffolding:** `pn init` creates a ready-to-run project; `pn run android` and `pn run ios` build and launch your app. +- **Instant desktop preview:** `pn preview` renders your app in a native desktop window via Tkinter with Fast Refresh on every save — iterate on layout, state, and navigation in milliseconds without booting a simulator or device. The reconciler, hooks, layout engine, and navigation are the same code that ships to the phone. - **Native-backed navigation:** Declarative `Stack`, `Tab`, and `Drawer` navigators inspired by React Navigation. The root stack drives the platform's native navigation controller (`UINavigationController` on iOS, AndroidX Navigation Component on Android), so transitions, back gestures, and the hardware back button match what users expect. - **Fast Refresh hot reload:** `pn run --hot-reload` watches `app/` and patches edits into the running app on save, preserving component state across most changes. - **Bundled templates:** Android Gradle and iOS Xcode templates are included, so scaffolding requires no network access. diff --git a/docs/api/cli.md b/docs/api/cli.md index f04e4e8..34200b6 100644 --- a/docs/api/cli.md +++ b/docs/api/cli.md @@ -8,6 +8,10 @@ the documented behavior never drifts from the code. - `pn init [name]`: scaffold a new project (creates `app/`, `pythonnative.json`, `requirements.txt`, `.gitignore`). +- `pn preview [component]`: render the app in a desktop (Tkinter) window + with Fast Refresh — the fastest way to iterate on UI. Flags: + `--width`, `--height`, `--title`, `--no-hot-reload`. See the + [Desktop preview guide](../guides/desktop-preview.md). - `pn run android|ios`: build and run on a connected device or simulator. Flags: `--prepare-only`, `--hot-reload`, `--no-logs`. - `pn clean`: remove the local `build/` directory. diff --git a/docs/concepts/components.md b/docs/concepts/components.md index 6f3406b..0b00d36 100644 --- a/docs/concepts/components.md +++ b/docs/concepts/components.md @@ -324,9 +324,19 @@ if pn.Platform.is_ios: margin = 16 ``` -`pn.Platform.OS` is `"ios"`, `"android"`, or `"test"` (the latter -when running off-device, e.g., in unit tests). The lower-level -`utils.IS_ANDROID` / `utils.IS_IOS` constants are still available. +`pn.Platform.OS` is `"ios"`, `"android"`, `"desktop"` (the `pn preview` +backend — see the [Desktop preview guide](../guides/desktop-preview.md)), +or `"test"` (off-device, e.g. in unit tests). The lower-level +`utils.IS_ANDROID` / `utils.IS_IOS` / `utils.IS_DESKTOP` constants are +still available. + +`Platform.select` matches on the exact key; a `"native"` key is shared +by iOS **and** Android (but not desktop), and a `"default"` key catches +anything unmatched: + +```python +pad = pn.Platform.select({"native": 16, "desktop": 12, "default": 8}) +``` ## Next steps diff --git a/docs/concepts/native-views.md b/docs/concepts/native-views.md index 1a2877d..56b0e91 100644 --- a/docs/concepts/native-views.md +++ b/docs/concepts/native-views.md @@ -66,9 +66,13 @@ is a dict-like object that maps element type strings to handler populates the registry with Chaquopy-backed handlers. - On iOS, `pythonnative.native_views.ios.register_handlers` does the same with rubicon-objc handlers. -- On the desktop (during tests), the registry is replaced with a mock - via [`set_registry`][pythonnative.native_views.set_registry] before - any element is rendered. +- On the desktop (`pn preview`, with `PN_PLATFORM=desktop`), + `pythonnative.native_views.desktop.register_handlers` populates the + registry with Tkinter-backed handlers. See the + [Desktop preview guide](../guides/desktop-preview.md). +- Off-device under `pytest`, the registry is replaced with a mock via + [`set_registry`][pythonnative.native_views.set_registry] before any + element is rendered. Custom widgets follow the same pattern: register a handler under a unique type string, then construct elements with that type and the diff --git a/docs/examples.md b/docs/examples.md index 10fcf5f..79b52a1 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -20,12 +20,16 @@ project scaffolded with `pn init`. pn init my-app cd my-app # Edit app/main.py and paste any of the snippets below. +pn preview # fast desktop preview with Fast Refresh pn run android # or: pn run ios ``` The `app/main.py` that `pn init` writes already returns a small counter; replace it with one of the snippets to try a different -example. +example. The quickest way to iterate is +[`pn preview`](guides/desktop-preview.md), which renders the app in a +desktop window and reloads on every save; use `pn run` when you want it +on a real device or simulator. ## Snippets diff --git a/docs/getting-started.md b/docs/getting-started.md index 7873673..da54820 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -66,6 +66,38 @@ Key ideas: When the root `Stack.Navigator` is rendered inside the host's first screen, `navigate(...)` and `go_back()` drive the **native** navigation controller (UINavigationController on iOS, AndroidX Navigation Component on Android). Each pushed screen runs in its own reconciler host, so state on the previous screen is preserved by the platform stack. +## Preview on your desktop + +The fastest way to iterate is `pn preview`, which renders your app in a +desktop window and **Fast Refreshes on every save** — no simulator, no +device build: + +```bash +pn preview +``` + +This opens a phone-sized window, mounts your project's `App`, and +watches `app/` for changes. Edit a component, hit save, and the window +updates in place while keeping component state (counters, form input, +scroll position). Navigation, hooks, async, and the flex layout engine +all run exactly as they do on device, because the desktop backend reuses +the same reconciler and layout engine — only the leaf widgets differ +(Tkinter instead of UIKit / Android views). + +```bash +pn preview # preview the project's entry point (app/main.py → App) +pn preview app.main.Detail # preview a specific component +pn preview --width 768 --height 1024 # tablet-sized window +pn preview --no-hot-reload # disable file watching +``` + +The preview needs Tkinter, which ships with most Python installs. If +it's missing, install it (`brew install python-tk` on macOS, +`sudo apt-get install python3-tk` on Debian/Ubuntu). The desktop backend +is a **development** surface for layout and logic — some visual chrome is +approximated, and there's no desktop packaging. Ship to devices with +`pn run`. See the [Desktop preview guide](guides/desktop-preview.md). + ## Run on a platform ```bash diff --git a/docs/guides/desktop-preview.md b/docs/guides/desktop-preview.md new file mode 100644 index 0000000..dfeeeca --- /dev/null +++ b/docs/guides/desktop-preview.md @@ -0,0 +1,173 @@ +# Desktop preview + +`pn preview` renders your app in a native desktop window with **instant +Fast Refresh** on every save. It's the fastest way to build UI in +PythonNative: edit a component, hit save, and see the result in a second +— no simulator boot, no device deploy, no rebuild. + +```bash +pn preview +``` + +The preview reuses the **same reconciler, hooks, navigation, async +runtime, and pure-Python flex layout engine** that run on device. Only +the leaf widgets differ: the desktop backend draws with +[Tkinter](https://docs.python.org/3/library/tkinter.html) instead of +UIKit / Android views. So behavior and layout match the device closely, +and your iteration loop collapses from minutes to seconds. + +## Quick start + +From a project directory (one created by `pn init`, with an `app/main.py` +that defines `App`): + +```bash +pn preview +``` + +`pn preview` runs your real app code, so install your project's +dependencies in the same environment first (for example +`pip install -r requirements.txt`). If an import fails, the preview shows +the traceback in the window instead of crashing — install the missing +package or fix the code and save to recover. + +This opens a phone-sized window, mounts your `App`, and starts watching +`app/` for changes. Edit any component and save — the window updates in +place while preserving component state (counters, text input, scroll +position, navigation stack). + +```bash +pn preview # the project entry point (app/main.py → App) +pn preview app.screens.home # a module whose App attribute to mount +pn preview app.main.DetailScreen # a specific dotted component +pn preview --width 768 --height 1024 # tablet-sized window +pn preview --title "My App" +pn preview --no-hot-reload # mount once, don't watch files +``` + +## Requirements + +The preview uses Tkinter, Python's standard GUI toolkit, which ships +with most Python installations. If `pn preview` reports that Tkinter is +missing: + +- **macOS** (Homebrew Python): `brew install python-tk` +- **Debian / Ubuntu**: `sudo apt-get install python3-tk` +- **Windows**: re-run the Python installer and enable the + "tcl/tk and IDLE" optional feature. + +No other dependencies are required — the desktop backend is pure Python. + +## How it works + +`pn preview` sets `PN_PLATFORM=desktop` and starts +`pythonnative.preview.run_preview`, which: + +1. Opens a single Tk window with one *stage* frame. +2. Selects the Tkinter + [native-view registry][pythonnative.native_views.get_registry], so + every `pn.Text`, `pn.Button`, … maps to a Tk widget. +3. Mounts your `App` through a normal + [`Reconciler`][pythonnative.reconciler.Reconciler] and pushes the + window size in as the layout viewport. +4. Runs the Tk event loop on the main thread, polling ~60×/second to + apply renders requested from the async runtime thread and to drain + file-change reloads. +5. Watches `app/` with a + [`FileWatcher`][pythonnative.hot_reload.FileWatcher] and Fast + Refreshes on every save. + +Layout is owned by the engine, not the widgets: the +[flex layout engine](../concepts/layout.md) computes an absolute frame +for every element, and the backend positions each Tk widget with that +frame. This is the same contract the iOS and Android backends follow, so +a column that lays out correctly on a phone lays out the same way in the +preview. + +### Navigation + +Root navigators (`create_stack_navigator`, tabs, drawer) drive a real +in-process stack of screen hosts in the preview, the same way they drive +`UINavigationController` / AndroidX Navigation on device. `navigate(...)` +pushes a new screen host (preserving the previous screen's state); +`go_back()` pops it. Each screen runs in its own reconciler host. + +## Fast Refresh + +Saving a `.py` file under `app/` triggers a reload. The preview prefers +**Fast Refresh**: the changed modules are reloaded and the live VNode +tree's function references are swapped in place, so the next render +reuses existing hook state. Edits to a component body keep your +counters, form values, and scroll positions. When a clean swap isn't +possible (structural edits, a raised exception), the preview falls back +to a full remount so you're never stuck with a stale tree. + +If your component raises at import time (a syntax error you're mid-fix +on), the preview shows the traceback as an overlay and recovers +automatically on your next successful save — no restart needed. + +See the [Hot reload guide](hot-reload.md) for the underlying mechanics; +the desktop preview shares the same Fast Refresh engine. + +## Branching on the platform + +When the preview is running, [`Platform.OS`][pythonnative.Platform] is +`"desktop"`: + +```python +import pythonnative as pn + +pad = pn.Platform.select({"desktop": 12, "ios": 16, "android": 16, "default": 12}) +``` + +Note that `Platform.select`'s `"native"` key matches iOS and Android +only — desktop is a development surface, so use an explicit `"desktop"` +key (or `"default"`) for it. You can also check +[`Platform.is_desktop`][pythonnative.Platform] or the +`pythonnative.utils.IS_DESKTOP` flag directly. + +## What's faithful, and what's approximated + +The preview is a **development tool**, optimized for fidelity of layout +and logic rather than pixel-perfect chrome. + +Faithful: + +- Flex layout, sizing, padding, spacing, absolute positioning. +- Component lifecycle, hooks, effects, context, error boundaries. +- Navigation (stack push/pop, tabs, drawer) and per-screen state. +- The async runtime, `use_query` / `use_mutation`, timers, and + state-driven updates. +- Text wrapping and intrinsic sizing (measured with the same font the + widget renders). + +Approximated or omitted (Tkinter can't express these cheaply): + +- Rounded corners, shadows, gradients, and per-widget opacity. +- Overflow **clipping** — a `ScrollView`'s content renders but isn't + clipped to the viewport, and there's no interactive scrolling. +- Animations show their **end state** rather than smooth interpolation + (translations are applied; scale/rotate/opacity are skipped). +- `Image` loads local PNG/GIF files; network URLs and JPEG fall back to + a labeled placeholder. `WebView` shows a placeholder. + +When the chrome matters, verify on device with `pn run`. + +## When to use device builds instead + +`pn preview` is for fast UI/logic iteration. Reach for `pn run` when you +need: + +- Pixel-perfect native chrome and platform behaviors. +- Real device APIs (camera, location, notifications, biometrics, …). +- To test packaging, permissions, or store builds. + +There is no desktop packaging target — ship to devices with +`pn run android` / `pn run ios`. + +## Next steps + +- Reference: [`pn` CLI](../api/cli.md). +- Mechanics shared with device hot reload: [Hot reload](hot-reload.md). +- How layout is computed: [Layout engine](../concepts/layout.md). +- Platform branching: [Platform & accessibility](platform-accessibility.md). diff --git a/docs/index.md b/docs/index.md index f108bf4..c99d026 100644 --- a/docs/index.md +++ b/docs/index.md @@ -53,6 +53,10 @@ produce identical frames on both platforms. - **Fast Refresh hot reload.** `pn run --hot-reload` watches `app/` and patches the running app in place, preserving component state across most edits. +- **Instant desktop preview.** `pn preview` renders your app in a + desktop window with Fast Refresh, so you can iterate on UI, state, + and navigation in milliseconds — no simulator boot required. See the + [Desktop preview guide](guides/desktop-preview.md). - **An extension SDK.** [`pythonnative.sdk`](api/sdk.md) lets you wrap any platform widget as a first-class element with type-checked props, and PyPI plugins auto-register through the @@ -63,6 +67,8 @@ produce identical frames on both platforms. ## Quick links - New here? Start with [Getting started](getting-started.md). +- Want to see it run right now? Try the + [Desktop preview](guides/desktop-preview.md). - Want the bigger picture? Read [Mental model](concepts/mental-model.md). - Looking up an API? [Package overview](api/pythonnative.md). - Wrapping a custom widget? Read diff --git a/docs/meta/faq.md b/docs/meta/faq.md index c7c1a99..0fad515 100644 --- a/docs/meta/faq.md +++ b/docs/meta/faq.md @@ -46,12 +46,22 @@ a small Python factory that returns ## Does PythonNative work on the desktop? -The core (components, hooks, reconciler) is platform-agnostic and runs -on the desktop with a [mock registry](../guides/testing.md#a-minimal-mock-registry). -That's how the test suite works. There is no built-in *desktop* widget -backend; if you need one, plug a Tk or Qt-backed -[`NativeViewRegistry`][pythonnative.native_views.NativeViewRegistry] -in. +Yes — for **previewing**. `pn preview` renders your app in a native +desktop window using a built-in Tkinter backend, with instant Fast +Refresh on every save. It's the fastest inner-loop: see your real UI +and iterate in seconds without booting a simulator or deploying to a +device. The same flex layout engine and reconciler drive it, so what +you see closely matches the device. See the +[Desktop preview guide](../guides/desktop-preview.md). + +The desktop backend is a **development tool**, not a production target: +chrome like rounded corners, shadows, and overflow clipping are +approximated, and there's no app packaging for desktop. Ship to devices +with `pn run android` / `pn run ios`. + +The core (components, hooks, reconciler) is also platform-agnostic and +runs headless with a [mock registry](../guides/testing.md#a-minimal-mock-registry) — +that's how the unit-test suite works. ## How do I package and distribute my app? diff --git a/examples/hello-world/README.md b/examples/hello-world/README.md index e69de29..b31f4fa 100644 --- a/examples/hello-world/README.md +++ b/examples/hello-world/README.md @@ -0,0 +1,30 @@ +# Hello World + +The smallest PythonNative app: a counter with navigation to a detail +screen. + +## Preview it on your desktop (fastest) + +From this directory, install the example's dependencies (the preview +imports your real app code), then launch it: + +```bash +pip install -r requirements.txt +pn preview +``` + +A desktop window opens running `app/main.py`'s `App`. Edit any component +under `app/`, save, and the window Fast Refreshes in place — no +simulator or device needed. See the +[Desktop preview guide](../../docs/guides/desktop-preview.md). + +## Run on a device or simulator + +```bash +pn run ios +# or +pn run android +``` + +Add `--hot-reload` to push edits to the running app without a full +rebuild. diff --git a/mkdocs.yml b/mkdocs.yml index 1d20fd7..2ef02b7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -77,6 +77,7 @@ nav: - Guides: - Android: guides/android.md - iOS: guides/ios.md + - Desktop Preview: guides/desktop-preview.md - Navigation: guides/navigation.md - Styling: guides/styling.md - Animations: guides/animations.md diff --git a/pyproject.toml b/pyproject.toml index 07c4e56..2f17e49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pythonnative" -version = "0.18.0" +version = "0.19.0" description = "Cross-platform native UI toolkit for Android and iOS" authors = [ { name = "Owen Carey" } @@ -71,6 +71,7 @@ Documentation = "https://docs.pythonnative.com/" + [tool.setuptools.packages.find] where = ["src"] @@ -117,6 +118,7 @@ ignore = ["D107", "D105", "D203", "D213"] # docstrings would only add boilerplate that repeats the ABC. "src/pythonnative/native_views/android.py" = ["D101", "D102"] "src/pythonnative/native_views/ios.py" = ["D101", "D102"] +"src/pythonnative/native_views/desktop.py" = ["D101", "D102"] [tool.ruff.lint.pydocstyle] convention = "google" diff --git a/src/pythonnative/__init__.py b/src/pythonnative/__init__.py index aeb343e..c58a5bb 100644 --- a/src/pythonnative/__init__.py +++ b/src/pythonnative/__init__.py @@ -51,7 +51,7 @@ def App(): ``` """ -__version__ = "0.18.0" +__version__ = "0.19.0" from . import runtime, sdk from .alerts import Alert diff --git a/src/pythonnative/cli/pn.py b/src/pythonnative/cli/pn.py index d9e2977..bc7685b 100644 --- a/src/pythonnative/cli/pn.py +++ b/src/pythonnative/cli/pn.py @@ -1,9 +1,12 @@ """`pn` CLI: scaffold, run, and clean PythonNative projects. The console script `pn` (declared in `pyproject.toml` under -`[project.scripts]`) dispatches to one of three subcommands: +`[project.scripts]`) dispatches to one of four subcommands: - `pn init [name]`: scaffold a new project in the current directory. +- `pn preview [component]`: render the app in a desktop (Tkinter) + window with instant Fast Refresh — the fast inner dev loop, no + device or simulator required. - `pn run android|ios`: stage code into a native template, build it, install it, and stream logs back to the terminal. - `pn clean`: remove the local `build/` directory. @@ -1119,6 +1122,90 @@ def on_change(changed_files: List[str]) -> None: print("\n[hot-reload] Stopped.") +def _entrypoint_to_module(entry_point: str) -> str: + """Convert a config ``entryPoint`` path into an importable module path. + + ``"app/main.py"`` → ``"app.main"``. Returns ``"app.main"`` for + empty / unusable input so ``pn preview`` always has a sane default. + """ + normalized = entry_point.strip().replace("\\", "/") + if normalized.endswith(".py"): + normalized = normalized[:-3] + normalized = normalized.strip("/").replace("/", ".") + return normalized or "app.main" + + +def preview_project(args: argparse.Namespace) -> None: + """Render the project in a desktop preview window (Tkinter). + + Sets ``PN_PLATFORM=desktop`` (so PythonNative selects the Tkinter + backend) and hands off to ``pythonnative.preview.run_preview``, + which opens a window, mounts the app, and Fast Refreshes on every + file save until the window is closed. + + Args: + args: Parsed argparse namespace. Recognized attributes: + + - `component` (`str`, optional): Module path like + ``"app.main"`` (its ``App`` is used) or a dotted + ``module.Component`` path. Defaults to the project's + configured ``entryPoint``. + - `width` / `height` (`int`): Initial window size in points. + - `title` (`str`): Window title. + - `no_hot_reload` (`bool`): Disable file watching. + """ + # The desktop backend is selected at *import time* from the + # ``PN_PLATFORM`` environment variable (see ``pythonnative.utils`` and + # the host selection in ``pythonnative.screen``). Because the ``pn`` + # console entry point lives inside the ``pythonnative`` package, + # importing it already loaded the package under the default, + # non-desktop platform before this handler ever runs. Re-exec a fresh + # interpreter with the variable set so every module binds to the + # Tkinter backend; the re-execed child sees ``PN_PLATFORM=desktop`` and + # skips this branch, so there is no exec loop. + if os.environ.get("PN_PLATFORM") != "desktop": + try: + completed = subprocess.run( + [sys.executable, "-m", "pythonnative.cli.pn", *sys.argv[1:]], + env={**os.environ, "PN_PLATFORM": "desktop"}, + ) + except KeyboardInterrupt: + sys.exit(130) + sys.exit(completed.returncode) + + project_dir = os.getcwd() + component: Optional[str] = getattr(args, "component", None) + if not component: + config = _read_project_config() + component = _entrypoint_to_module(config.get("entryPoint", "app/main.py")) + + try: + from pythonnative.preview import run_preview + except Exception as exc: # pragma: no cover - environment dependent + print(f"Error: could not start the desktop preview: {exc}") + print( + "The desktop preview needs Tkinter (Python's standard GUI toolkit).\n" + "On macOS: brew install python-tk\n" + "On Debian/Ubuntu: sudo apt-get install python3-tk\n" + "On Windows: reinstall Python with the 'tcl/tk' option checked." + ) + sys.exit(1) + + print(f"Starting PythonNative preview for {component} (Ctrl+C or close the window to stop).") + try: + run_preview( + component, + project_root=project_dir, + width=getattr(args, "width", 390), + height=getattr(args, "height", 844), + title=getattr(args, "title", "PythonNative Preview"), + hot_reload=not getattr(args, "no_hot_reload", False), + ) + except RuntimeError as exc: + print(f"Error: {exc}") + sys.exit(1) + + def clean_project(args: argparse.Namespace) -> None: """Remove the local `build/` directory. @@ -1152,6 +1239,25 @@ def main() -> None: parser_init.add_argument("--force", action="store_true", help="Overwrite existing files if present") parser_init.set_defaults(func=init_project) + # Create a new command 'preview' that calls preview_project + parser_preview = subparsers.add_parser("preview") + parser_preview.add_argument( + "component", + nargs="?", + help="Module path (e.g. app.main) or dotted component path; defaults to the project entry point", + ) + parser_preview.add_argument("--width", type=int, default=390, help="Initial window width in points (default: 390)") + parser_preview.add_argument( + "--height", type=int, default=844, help="Initial window height in points (default: 844)" + ) + parser_preview.add_argument("--title", default="PythonNative Preview", help="Preview window title") + parser_preview.add_argument( + "--no-hot-reload", + action="store_true", + help="Disable file watching / Fast Refresh", + ) + parser_preview.set_defaults(func=preview_project) + # Create a new command 'run' that calls run_project parser_run = subparsers.add_parser("run") parser_run.add_argument("platform", choices=["android", "ios"]) diff --git a/src/pythonnative/native_views/__init__.py b/src/pythonnative/native_views/__init__.py index 93c7cd8..465a678 100644 --- a/src/pythonnative/native_views/__init__.py +++ b/src/pythonnative/native_views/__init__.py @@ -238,18 +238,31 @@ def measure_intrinsic( def _active_platform_name() -> str: - """Return ``"android"`` or ``"ios"`` for the active runtime.""" - from ..utils import IS_ANDROID + """Return ``"android"``, ``"desktop"``, or ``"ios"`` for the active runtime.""" + from ..utils import IS_ANDROID, IS_DESKTOP - return "android" if IS_ANDROID else "ios" + if IS_ANDROID: + return "android" + if IS_DESKTOP: + return "desktop" + return "ios" def _register_builtin_handlers(registry: NativeViewRegistry) -> None: - """Register every built-in handler for the active platform.""" - from ..utils import IS_ANDROID + """Register every built-in handler for the active platform. + + The desktop (Tkinter) backend is selected when ``pn preview`` sets + ``PN_PLATFORM=desktop``; otherwise this picks Android (on device) or + iOS (the default off-device path, exercised by the iOS templates and + by tests that install the ``[ios]`` extra). Off-device unit tests + typically inject a mock registry via ``set_registry`` instead. + """ + from ..utils import IS_ANDROID, IS_DESKTOP if IS_ANDROID: from .android import register_handlers + elif IS_DESKTOP: + from .desktop import register_handlers else: from .ios import register_handlers register_handlers(registry) diff --git a/src/pythonnative/native_views/desktop.py b/src/pythonnative/native_views/desktop.py new file mode 100644 index 0000000..9145282 --- /dev/null +++ b/src/pythonnative/native_views/desktop.py @@ -0,0 +1,1489 @@ +"""Desktop native-view handlers (Tkinter). + +The desktop backend renders a PythonNative app in a real OS window so +the inner development loop doesn't require a device build. It is driven +by ``pn preview`` (which sets ``PN_PLATFORM=desktop``) and powers the +in-process Fast Refresh loop in ``pythonnative.preview``. + +Like the iOS and Android backends, **layout is owned by the pure-Python +flex engine** in [`pythonnative.layout`][pythonnative.layout]: the +reconciler computes each view's ``(x, y, width, height)`` in points and +[`set_frame`][pythonnative.native_views.desktop.DesktopViewHandler.set_frame] +applies it. Handlers therefore only deal with *visual* props (text, +colors, fonts, callbacks) and ignore everything in +[`LAYOUT_STYLE_KEYS`][pythonnative.layout.LAYOUT_STYLE_KEYS]. + +Placement strategy +------------------ +Tkinter fixes a widget's master at construction time, but the +reconciler creates a view *before* it knows the parent (``create`` then +``add_child``). To bridge that, every widget is created under a single +shared *stage* frame (see +[`set_root_container`][pythonnative.native_views.desktop.set_root_container]) +and positioned with ``place(in_=parent, ...)``. Tk's ``-in`` option +composes coordinates through nested parents, so the engine's +parent-relative frames render correctly without reparenting. + +Scope +----- +This is a **preview** backend, not a production desktop target. It +favors fidelity of layout and behavior over pixel-perfect chrome: +rounded corners, shadows, per-widget opacity, and overflow clipping are +approximated or omitted (Tkinter can't express them cheaply). Every one +of the 25 built-in element types is handled so any app renders without +errors. + +This module imports ``tkinter`` at import time, so it is only imported +when ``PN_PLATFORM=desktop``. Off-device unit tests inject a mock +registry via [`set_registry`][pythonnative.native_views.set_registry] +and never trigger this path. +""" + +from __future__ import annotations + +import math +import re +import tkinter as tk +from tkinter import font as tkfont +from tkinter import ttk +from typing import Any, Dict, List, Optional, Tuple + +from .base import ViewHandler + +# ====================================================================== +# Stage / root container +# ====================================================================== +# +# Every Tk widget the backend creates is a child of this single frame. +# ``pn preview`` installs it before mounting the app; the placement +# logic (``_place``) positions widgets *inside* their logical parent via +# Tk's ``-in`` option, which only works when both windows share a +# top-level — guaranteed by the single-stage design. + +_ROOT_CONTAINER: Any = None +_DEFAULT_FONT_SIZE = 15 + + +def set_root_container(container: Any) -> None: + """Install the stage frame that every desktop view is created under. + + Called by ``pythonnative.preview`` before the + first screen is mounted. ``container`` must be a Tk widget (a + ``Frame`` filling the preview window). + """ + global _ROOT_CONTAINER + _ROOT_CONTAINER = container + + +def get_root_container() -> Any: + """Return the installed stage frame, or ``None`` if unset.""" + return _ROOT_CONTAINER + + +def clear_root_container() -> None: + """Forget the stage frame (used when the preview window closes).""" + global _ROOT_CONTAINER + _ROOT_CONTAINER = None + + +def _master() -> Any: + """Return the master widget new views should be constructed under.""" + if _ROOT_CONTAINER is not None: + return _ROOT_CONTAINER + # Fall back to Tk's default root so the handlers stay usable in a + # bare REPL / test that created a Tk root but no explicit stage. + return tk._get_default_root() + + +# ====================================================================== +# Color + font helpers +# ====================================================================== + +_NAMED_PASSTHROUGH = re.compile(r"^[A-Za-z][A-Za-z0-9 ]*$") +_BOLD_WORDS = frozenset({"bold", "semibold", "black", "heavy", "extrabold", "extra_bold", "semi_bold"}) + + +def _tk_color(value: Any) -> Optional[str]: + """Convert a PythonNative color into a Tk color string. + + Accepts ``#rgb`` / ``#rrggbb`` / ``#aarrggbb`` hex (alpha is + dropped — Tk has no per-color alpha), ``rgb()`` / ``rgba()`` + functional notation, ``(r, g, b)`` tuples, packed integers, and + named colors (passed through for Tk to resolve). Returns ``None`` + for ``transparent`` / unparseable values so callers can leave the + widget's default background untouched. + """ + if value is None or isinstance(value, bool): + return None + if isinstance(value, int): + return "#%06x" % (value & 0xFFFFFF) + if isinstance(value, (tuple, list)) and len(value) >= 3: + try: + r, g, b = (int(value[0]) & 255, int(value[1]) & 255, int(value[2]) & 255) + return "#%02x%02x%02x" % (r, g, b) + except (TypeError, ValueError): + return None + s = str(value).strip() + if not s: + return None + low = s.lower() + if low in ("transparent", "clear", "none"): + return None + if s.startswith("#"): + hexd = s[1:] + if len(hexd) == 3: + return "#" + "".join(c * 2 for c in hexd) + if len(hexd) == 4: # #rgba -> drop alpha + return "#" + "".join(c * 2 for c in hexd[:3]) + if len(hexd) == 6: + return "#" + hexd + if len(hexd) == 8: # #aarrggbb -> drop leading alpha + return "#" + hexd[2:] + return None + if low.startswith("rgb"): + nums = re.findall(r"[\d.]+", s) + if len(nums) >= 3: + try: + r, g, b = (int(float(nums[0])) & 255, int(float(nums[1])) & 255, int(float(nums[2])) & 255) + return "#%02x%02x%02x" % (r, g, b) + except ValueError: + return None + return None + if _NAMED_PASSTHROUGH.match(s): + return s + return None + + +def _is_bold(props: Dict[str, Any]) -> bool: + """Return whether the merged props imply a bold weight.""" + if props.get("bold"): + return True + weight = props.get("font_weight") + if isinstance(weight, str): + return weight.lower() in _BOLD_WORDS + if isinstance(weight, (int, float)) and not isinstance(weight, bool): + return float(weight) >= 600 + return False + + +def _make_font(props: Dict[str, Any]) -> Any: + """Build a ``tkinter.font.Font`` from the merged style props. + + Sizes are passed as negative values (Tk's convention for *pixels*) + so the rendered text and ``measure_intrinsic`` agree with the + layout engine's pixel coordinate space. + """ + size = props.get("font_size") + try: + px = int(round(float(size))) if size is not None else _DEFAULT_FONT_SIZE + except (TypeError, ValueError): + px = _DEFAULT_FONT_SIZE + px = max(1, px) + kwargs: Dict[str, Any] = { + "size": -px, + "weight": "bold" if _is_bold(props) else "normal", + "slant": "italic" if props.get("italic") else "roman", + } + family = props.get("font_family") + if family: + kwargs["family"] = str(family) + decoration = props.get("text_decoration") + if decoration == "underline": + kwargs["underline"] = 1 + elif decoration == "line_through": + kwargs["overstrike"] = 1 + try: + return tkfont.Font(**kwargs) + except Exception: + return tkfont.Font(size=-px) + + +def _measure_text(font: Any, text: str, max_width: float) -> Tuple[float, float]: + """Return the ``(width, height)`` a string occupies in ``font``. + + Honors explicit newlines and greedily word-wraps paragraphs wider + than ``max_width`` (``math.inf`` means no wrap) so multi-line + ``Text`` measures the same height the engine will lay out. + """ + try: + line_h = float(font.metrics("linespace")) + except Exception: + line_h = float(_DEFAULT_FONT_SIZE + 4) + if not text: + return (0.0, line_h) + bounded = math.isfinite(max_width) and max_width > 0 + longest = 0.0 + lines = 0 + for paragraph in text.split("\n"): + if not paragraph: + lines += 1 + continue + para_w = float(font.measure(paragraph)) + if not bounded or para_w <= max_width: + lines += 1 + longest = max(longest, para_w) + continue + current = "" + for word in paragraph.split(" "): + trial = word if not current else current + " " + word + if not current or font.measure(trial) <= max_width: + current = trial + else: + lines += 1 + longest = max(longest, float(font.measure(current))) + current = word + lines += 1 + longest = max(longest, float(font.measure(current))) + width = min(longest, max_width) if bounded else longest + return (math.ceil(width), math.ceil(lines * line_h)) + + +def _finite(value: Any, default: float = 0.0) -> float: + """Coerce ``value`` to a finite float, clamping NaN/inf to ``default``.""" + try: + f = float(value) + except (TypeError, ValueError): + return default + return f if math.isfinite(f) else default + + +# ====================================================================== +# Placement (ordering-independent) +# ====================================================================== + + +def _merge_props(widget: Any, props: Dict[str, Any]) -> Dict[str, Any]: + """Accumulate ``props`` onto the widget so partial updates stay coherent. + + The reconciler delivers only *changed* keys on update; Tk needs the + full picture to rebuild a font or re-derive a layout, so each + widget caches its merged props under ``_pn_props``. + """ + merged: Dict[str, Any] = getattr(widget, "_pn_props", None) or {} + merged.update(props) + widget._pn_props = merged + return merged + + +def _place(widget: Any) -> None: + """Position ``widget`` inside its logical parent, if both are known. + + Idempotent and order-independent: ``set_frame`` records the frame + and ``add_child`` records the parent; whichever runs second triggers + the actual ``place``. Coordinates compose through nested ``-in`` + parents, so a child's parent-relative frame lands at the right + absolute spot. + """ + frame = getattr(widget, "_pn_frame", None) + if frame is None: + return + parent = getattr(widget, "_pn_parent", None) + target = parent if parent is not None else get_root_container() + if target is None: + return + x, y, w, h = frame + tx, ty = getattr(widget, "_pn_translate", (0.0, 0.0)) + try: + widget.place(in_=target, x=x + tx, y=y + ty, width=max(0.0, w), height=max(0.0, h)) + widget.lift() + except Exception: + pass + + +def _set_translate_from_transform(widget: Any, spec: Any) -> None: + """Extract a translate offset from a ``transform`` prop for placement. + + Tkinter can't scale or rotate widgets, but translation maps cleanly + onto ``place`` coordinates, so animated/transformed views still move + in the preview. Scale and rotate are ignored. + """ + tx = 0.0 + ty = 0.0 + if spec is not None: + entries = spec if isinstance(spec, list) else [spec] + for entry in entries: + if not isinstance(entry, dict): + continue + if "translate_x" in entry: + tx = _finite(entry["translate_x"]) + if "translate_y" in entry: + ty = _finite(entry["translate_y"]) + widget._pn_translate = (tx, ty) + + +def _apply_common(widget: Any, props: Dict[str, Any]) -> None: + """Apply visual props shared across most handlers (bg, border, transform).""" + if "background_color" in props: + color = _tk_color(props["background_color"]) + if color is not None: + try: + widget.configure(background=color) + except Exception: + pass + if any(k in props for k in ("border_width", "border_color")): + try: + width = props.get("border_width") + color = _tk_color(props.get("border_color")) or "#3c3c43" + if width: + widget.configure( + highlightthickness=int(round(_finite(width))), + highlightbackground=color, + highlightcolor=color, + ) + else: + widget.configure(highlightthickness=0) + except Exception: + pass + if "transform" in props: + _set_translate_from_transform(widget, props["transform"]) + _place(widget) + + +# ====================================================================== +# Base handler +# ====================================================================== + + +class DesktopViewHandler(ViewHandler): + """Shared ``set_frame`` / child / measure behavior for Tk handlers. + + Concrete handlers implement ``create`` / ``update`` (and optionally + ``measure_intrinsic``); child management and frame application are + inherited and route through the order-independent + [`_place`][pythonnative.native_views.desktop._place] helper. + """ + + def add_child(self, parent: Any, child: Any) -> None: + child._pn_parent = parent + _place(child) + + def insert_child(self, parent: Any, child: Any, index: int) -> None: + child._pn_parent = parent + _place(child) + + def remove_child(self, parent: Any, child: Any) -> None: + try: + child.place_forget() + except Exception: + pass + child._pn_parent = None + + def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None: + if native_view is None: + return + native_view._pn_frame = (_finite(x), _finite(y), max(0.0, _finite(width)), max(0.0, _finite(height))) + _place(native_view) + + def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]: + return (0.0, 0.0) + + def set_animated_property( + self, + native_view: Any, + prop_name: str, + value: Any, + duration_ms: float = 0.0, + easing: str = "linear", + ) -> None: + """Apply the final value of an animated property (no tween). + + The preview shows animation *end states* rather than smooth + interpolation. Translation maps onto placement; opacity, scale, + and rotation have no cheap Tk analogue and are skipped. + """ + if native_view is None: + return + if prop_name == "translate_x": + _, ty = getattr(native_view, "_pn_translate", (0.0, 0.0)) + native_view._pn_translate = (_finite(value), ty) + _place(native_view) + elif prop_name == "translate_y": + tx, _ = getattr(native_view, "_pn_translate", (0.0, 0.0)) + native_view._pn_translate = (tx, _finite(value)) + _place(native_view) + elif prop_name == "background_color": + color = _tk_color(value) + if color is not None: + try: + native_view.configure(background=color) + except Exception: + pass + + +# ====================================================================== +# Containers (View / Column / Row / SafeAreaView / KeyboardAvoidingView) +# ====================================================================== + + +class FlexContainerHandler(DesktopViewHandler): + """A bare positioning surface (``tk.Frame``). + + All flex semantics are computed by the layout engine and applied via + ``set_frame``; the frame only carries visual chrome (background, + border). + """ + + def create(self, props: Dict[str, Any]) -> Any: + frame = tk.Frame(_master(), highlightthickness=0, bd=0) + _apply_common(frame, _merge_props(frame, props)) + return frame + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + _apply_common(native_view, _merge_props(native_view, changed)) + + +class ScrollViewHandler(FlexContainerHandler): + """Preview ScrollView — a plain frame. + + The layout engine still lets the content grow past the viewport on + the scroll axis; the desktop preview renders that overflow without + interactive scrolling or clipping (a documented preview limitation). + """ + + +class SafeAreaViewHandler(FlexContainerHandler): + """Desktop has no notch/home-indicator insets, so this is a frame.""" + + +class KeyboardAvoidingViewHandler(FlexContainerHandler): + """No soft keyboard on desktop; behaves as a plain frame.""" + + +# ====================================================================== +# Text +# ====================================================================== + + +_ANCHOR_FOR_ALIGN = {"left": "w", "center": "center", "right": "e"} +_JUSTIFY_FOR_ALIGN = {"left": "left", "center": "center", "right": "right"} + + +class TextHandler(DesktopViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + label = tk.Label(_master(), highlightthickness=0, bd=0, padx=0, pady=0) + self._apply(label, props) + return label + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, label: Any, props: Dict[str, Any]) -> None: + merged = _merge_props(label, props) + text = merged.get("text") + label._pn_text = "" if text is None else str(text) + font = _make_font(merged) + label._pn_font = font + opts: Dict[str, Any] = {"text": label._pn_text, "font": font} + color = _tk_color(merged.get("color")) + if color is not None: + opts["foreground"] = color + align = merged.get("text_align") + if align in _ANCHOR_FOR_ALIGN: + opts["anchor"] = _ANCHOR_FOR_ALIGN[align] + opts["justify"] = _JUSTIFY_FOR_ALIGN[align] + else: + opts["anchor"] = "w" + opts["justify"] = "left" + try: + label.configure(**opts) + except Exception: + pass + _apply_common(label, merged) + + def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None: + # Wrap to the laid-out width so multi-line text flows the way the + # engine measured it. + try: + native_view.configure(wraplength=max(1, int(_finite(width)))) + except Exception: + pass + super().set_frame(native_view, x, y, width, height) + + def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]: + font = getattr(native_view, "_pn_font", None) + if font is None: + return (0.0, 0.0) + text = getattr(native_view, "_pn_text", "") + return _measure_text(font, text, max_width) + + +# ====================================================================== +# Button +# ====================================================================== + + +class ButtonHandler(DesktopViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + button = tk.Button(_master(), highlightthickness=0, takefocus=0) + self._apply(button, props) + return button + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, button: Any, props: Dict[str, Any]) -> None: + merged = _merge_props(button, props) + title = merged.get("title") + button._pn_text = "" if title is None else str(title) + font = _make_font(merged) + button._pn_font = font + opts: Dict[str, Any] = {"text": button._pn_text, "font": font} + color = _tk_color(merged.get("color")) + if color is not None: + opts["foreground"] = color + bg = _tk_color(merged.get("background_color")) + if bg is not None: + opts["background"] = bg + opts["activebackground"] = bg + if "enabled" in merged: + opts["state"] = "normal" if merged.get("enabled", True) else "disabled" + try: + button.configure(**opts) + except Exception: + pass + if "on_click" in props: + callback = props["on_click"] + + def _command() -> None: + if callable(callback): + try: + callback() + except Exception: + pass + + try: + button.configure(command=_command if callable(callback) else "") + except Exception: + pass + _apply_common(button, merged) + + def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]: + font = getattr(native_view, "_pn_font", None) + text = getattr(native_view, "_pn_text", "") + if font is None: + return (0.0, 0.0) + w, h = _measure_text(font, text, math.inf) + return (w + 28.0, h + 14.0) + + +# ====================================================================== +# TextInput +# ====================================================================== + + +class TextInputHandler(DesktopViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + multiline = bool(props.get("multiline")) + widget: Any + if multiline: + widget = tk.Text(_master(), highlightthickness=1, bd=0, wrap="word", height=1) + else: + widget = tk.Entry(_master(), highlightthickness=1, bd=0) + widget._pn_multiline = multiline + widget._pn_suppress = False + self._bind(widget, props) + self._apply(widget, props) + return widget + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _current_text(self, widget: Any) -> str: + try: + if getattr(widget, "_pn_multiline", False): + return widget.get("1.0", "end-1c") + return widget.get() + except Exception: + return "" + + def _set_text(self, widget: Any, value: str) -> None: + widget._pn_suppress = True + try: + if getattr(widget, "_pn_multiline", False): + widget.delete("1.0", "end") + widget.insert("1.0", value) + else: + widget.delete(0, "end") + widget.insert(0, value) + except Exception: + pass + finally: + widget._pn_suppress = False + + def _bind(self, widget: Any, props: Dict[str, Any]) -> None: + def _on_key(_event: Any = None) -> None: + if getattr(widget, "_pn_suppress", False): + return + callback = getattr(widget, "_pn_on_change", None) + if callable(callback): + try: + callback(self._current_text(widget)) + except Exception: + pass + + def _on_return(_event: Any = None) -> str: + callback = getattr(widget, "_pn_on_submit", None) + if callable(callback): + try: + callback(self._current_text(widget)) + except Exception: + pass + return "break" + + try: + widget.bind("", _on_key) + if not getattr(widget, "_pn_multiline", False): + widget.bind("", _on_return) + except Exception: + pass + + def _apply(self, widget: Any, props: Dict[str, Any]) -> None: + merged = _merge_props(widget, props) + if "on_change" in props: + widget._pn_on_change = props["on_change"] + if "on_submit" in props: + widget._pn_on_submit = props["on_submit"] + opts: Dict[str, Any] = {"font": _make_font(merged)} + color = _tk_color(merged.get("color")) + if color is not None: + opts["foreground"] = color + bg = _tk_color(merged.get("background_color")) + if bg is not None: + opts["background"] = bg + if not getattr(widget, "_pn_multiline", False) and merged.get("secure"): + opts["show"] = "\u2022" + if "editable" in merged: + opts["state"] = "normal" if merged.get("editable", True) else "disabled" + try: + widget.configure(**opts) + except Exception: + pass + if "value" in props: + incoming = "" if props["value"] is None else str(props["value"]) + if self._current_text(widget) != incoming: + self._set_text(widget, incoming) + _apply_common(widget, merged) + try: + widget.configure(highlightbackground="#c7c7cc", highlightcolor="#007aff") + except Exception: + pass + + def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]: + merged = getattr(native_view, "_pn_props", {}) or {} + font = _make_font(merged) + try: + line_h = float(font.metrics("linespace")) + except Exception: + line_h = float(_DEFAULT_FONT_SIZE + 4) + return (160.0, line_h + 16.0) + + +# ====================================================================== +# Image +# ====================================================================== + + +class ImageHandler(DesktopViewHandler): + """Best-effort image preview. + + Tk's ``PhotoImage`` loads PNG/GIF/PPM from local paths; network URLs + and JPEG aren't supported without Pillow, so those fall back to a + labeled placeholder. The handler keeps a reference to the + ``PhotoImage`` (Tk garbage-collects images that aren't referenced). + """ + + def create(self, props: Dict[str, Any]) -> Any: + label = tk.Label(_master(), highlightthickness=0, bd=0, background="#d1d1d6") + self._apply(label, props) + return label + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, label: Any, props: Dict[str, Any]) -> None: + merged = _merge_props(label, props) + if "source" in props: + source = props.get("source") + photo = None + if source and "://" not in str(source): + try: + photo = tk.PhotoImage(file=str(source)) + except Exception: + photo = None + label._pn_photo = photo # keep a reference alive + try: + if photo is not None: + label.configure(image=photo, text="") + else: + name = str(source).rsplit("/", 1)[-1] if source else "image" + label.configure(image="", text=f"\U0001f5bc\n{name}", compound="center") + except Exception: + pass + _apply_common(label, merged) + + def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]: + photo = getattr(native_view, "_pn_photo", None) + if photo is not None: + try: + return (float(photo.width()), float(photo.height())) + except Exception: + pass + return (64.0, 64.0) + + +# ====================================================================== +# Switch / Checkbox +# ====================================================================== + + +class SwitchHandler(DesktopViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + var = tk.IntVar(master=_master(), value=1 if props.get("value") else 0) + check = tk.Checkbutton(_master(), variable=var, takefocus=0, highlightthickness=0, text="") + check._pn_var = var + self._bind(check, props) + self._apply(check, props) + return check + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _bind(self, check: Any, props: Dict[str, Any]) -> None: + def _command() -> None: + callback = getattr(check, "_pn_on_change", None) + if callable(callback): + try: + callback(bool(check._pn_var.get())) + except Exception: + pass + + try: + check.configure(command=_command) + except Exception: + pass + + def _apply(self, check: Any, props: Dict[str, Any]) -> None: + merged = _merge_props(check, props) + if "on_change" in props: + check._pn_on_change = props["on_change"] + if "value" in props: + try: + check._pn_var.set(1 if props.get("value") else 0) + except Exception: + pass + _apply_common(check, merged) + + def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]: + return (51.0, 31.0) + + +class CheckboxHandler(DesktopViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + var = tk.IntVar(master=_master(), value=1 if props.get("value") else 0) + check = tk.Checkbutton(_master(), variable=var, takefocus=0, highlightthickness=0, anchor="w") + check._pn_var = var + self._bind(check, props) + self._apply(check, props) + return check + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _bind(self, check: Any, props: Dict[str, Any]) -> None: + def _command() -> None: + callback = getattr(check, "_pn_on_change", None) + if callable(callback): + try: + callback(bool(check._pn_var.get())) + except Exception: + pass + + try: + check.configure(command=_command) + except Exception: + pass + + def _apply(self, check: Any, props: Dict[str, Any]) -> None: + merged = _merge_props(check, props) + if "on_change" in props: + check._pn_on_change = props["on_change"] + opts: Dict[str, Any] = {} + if "label" in merged: + opts["text"] = "" if merged.get("label") is None else str(merged["label"]) + if "disabled" in merged: + opts["state"] = "disabled" if merged.get("disabled") else "normal" + color = _tk_color(merged.get("color")) + if color is not None: + opts["selectcolor"] = color + if opts: + try: + check.configure(**opts) + except Exception: + pass + if "value" in props: + try: + check._pn_var.set(1 if props.get("value") else 0) + except Exception: + pass + _apply_common(check, merged) + + +# ====================================================================== +# Slider / ProgressBar / ActivityIndicator +# ====================================================================== + + +class SliderHandler(DesktopViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + scale = tk.Scale( + _master(), + orient="horizontal", + showvalue=False, + highlightthickness=0, + bd=0, + sliderlength=20, + ) + self._bind(scale, props) + self._apply(scale, props) + return scale + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _bind(self, scale: Any, props: Dict[str, Any]) -> None: + def _command(_value: Any) -> None: + callback = getattr(scale, "_pn_on_change", None) + if callable(callback): + try: + callback(float(scale.get())) + except Exception: + pass + + try: + scale.configure(command=_command) + except Exception: + pass + + def _apply(self, scale: Any, props: Dict[str, Any]) -> None: + merged = _merge_props(scale, props) + if "on_change" in props: + scale._pn_on_change = props["on_change"] + opts: Dict[str, Any] = { + "from_": _finite(merged.get("min_value", 0.0)), + "to": _finite(merged.get("max_value", 1.0)), + } + rng = opts["to"] - opts["from_"] + opts["resolution"] = rng / 100.0 if rng > 0 else 0.01 + try: + scale.configure(**opts) + except Exception: + pass + if "value" in merged: + scale._pn_suppress = True + try: + scale.set(_finite(merged.get("value"))) + except Exception: + pass + finally: + scale._pn_suppress = False + _apply_common(scale, merged) + + def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]: + width = max_width if math.isfinite(max_width) else 200.0 + return (width, 28.0) + + +class ProgressBarHandler(DesktopViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + bar = ttk.Progressbar(_master(), orient="horizontal", maximum=1.0) + self._apply(bar, props) + return bar + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, bar: Any, props: Dict[str, Any]) -> None: + merged = _merge_props(bar, props) + if merged.get("indeterminate"): + try: + bar.configure(mode="indeterminate") + bar.start(60) + except Exception: + pass + else: + try: + bar.configure(mode="determinate", value=max(0.0, min(1.0, _finite(merged.get("value", 0.0))))) + except Exception: + pass + + def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]: + width = max_width if math.isfinite(max_width) else 200.0 + return (width, 6.0) + + +class ActivityIndicatorHandler(DesktopViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + bar = ttk.Progressbar(_master(), orient="horizontal", mode="indeterminate", length=40) + self._apply(bar, props) + return bar + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, bar: Any, props: Dict[str, Any]) -> None: + merged = _merge_props(bar, props) + try: + if merged.get("animating", True): + bar.start(50) + else: + bar.stop() + except Exception: + pass + + def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]: + merged = getattr(native_view, "_pn_props", {}) or {} + size = 52.0 if merged.get("size") == "large" else 37.0 + return (size, 20.0) + + +# ====================================================================== +# Spacer / StatusBar / WebView +# ====================================================================== + + +class SpacerHandler(DesktopViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + return tk.Frame(_master(), highlightthickness=0, bd=0) + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + pass + + +class StatusBarHandler(DesktopViewHandler): + """Desktop has no system status bar; render an inert zero-size frame.""" + + def create(self, props: Dict[str, Any]) -> Any: + return tk.Frame(_master(), highlightthickness=0, bd=0) + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + pass + + +class WebViewHandler(DesktopViewHandler): + """No embedded browser on desktop; show a labeled placeholder.""" + + def create(self, props: Dict[str, Any]) -> Any: + label = tk.Label( + _master(), + background="#1c1c1e", + foreground="#ffffff", + highlightthickness=0, + justify="center", + ) + self._apply(label, props) + return label + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, label: Any, props: Dict[str, Any]) -> None: + merged = _merge_props(label, props) + target = merged.get("url") or ("inline HTML" if merged.get("html") else "") + try: + label.configure(text=f"\U0001f310 WebView\n{target}") + except Exception: + pass + _apply_common(label, merged) + + +# ====================================================================== +# Pressable +# ====================================================================== + + +class PressableHandler(DesktopViewHandler): + """A frame that forwards click / long-press to its callbacks.""" + + def create(self, props: Dict[str, Any]) -> Any: + frame = tk.Frame(_master(), highlightthickness=0, bd=0, cursor="hand2") + self._bind(frame) + self._apply(frame, props) + return frame + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _bind(self, frame: Any) -> None: + def _on_press(_event: Any = None) -> None: + callback = getattr(frame, "_pn_on_press", None) + if callable(callback): + try: + callback() + except Exception: + pass + + def _schedule_long(_event: Any = None) -> None: + callback = getattr(frame, "_pn_on_long_press", None) + if callable(callback): + frame._pn_long_after = frame.after(500, _fire_long) + + def _fire_long() -> None: + callback = getattr(frame, "_pn_on_long_press", None) + if callable(callback): + try: + callback() + except Exception: + pass + + def _cancel_long(_event: Any = None) -> None: + after_id = getattr(frame, "_pn_long_after", None) + if after_id is not None: + try: + frame.after_cancel(after_id) + except Exception: + pass + frame._pn_long_after = None + + try: + frame.bind("", _on_press) + frame.bind("", _schedule_long) + frame.bind("", _cancel_long) + except Exception: + pass + + def _apply(self, frame: Any, props: Dict[str, Any]) -> None: + merged = _merge_props(frame, props) + if "on_press" in props: + frame._pn_on_press = props["on_press"] + if "on_long_press" in props: + frame._pn_on_long_press = props["on_long_press"] + _apply_common(frame, merged) + + +# ====================================================================== +# Modal +# ====================================================================== + + +class ModalHandler(DesktopViewHandler): + """Overlay modal — a frame that fills the stage when ``visible``. + + The reconciler lays the modal's content out against the full + viewport (see ``Reconciler._layout_visible_modals``) and applies + frames to the children, so this handler only toggles its own + visibility and stacking. + """ + + def create(self, props: Dict[str, Any]) -> Any: + frame = tk.Frame(_master(), highlightthickness=0, bd=0, background="#ffffff") + self._apply(frame, props) + return frame + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, frame: Any, props: Dict[str, Any]) -> None: + merged = _merge_props(frame, props) + if merged.get("transparent"): + try: + frame.configure(background="#33000000".replace("33", "")) # solid fallback + except Exception: + pass + visible = bool(merged.get("visible")) + stage = get_root_container() + try: + if visible and stage is not None: + frame.place(in_=stage, x=0, y=0, relwidth=1.0, relheight=1.0) + frame.lift() + else: + frame.place_forget() + except Exception: + pass + + def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None: + # Modal placement is driven by visibility in ``_apply``; the + # engine never frames the placeholder itself. + return + + +# ====================================================================== +# TabBar +# ====================================================================== + + +class TabBarHandler(DesktopViewHandler): + """Bottom tab bar — a row of buttons laid out across its width.""" + + def create(self, props: Dict[str, Any]) -> Any: + frame = tk.Frame(_master(), highlightthickness=1, bd=0, background="#f2f2f7") + try: + frame.configure(highlightbackground="#c6c6c8", highlightcolor="#c6c6c8") + except Exception: + pass + frame._pn_buttons = [] + self._apply(frame, props) + return frame + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, frame: Any, props: Dict[str, Any]) -> None: + merged = _merge_props(frame, props) + items: List[Dict[str, Any]] = merged.get("items") or [] + active = merged.get("active_tab") + on_select = merged.get("on_tab_select") + for button in getattr(frame, "_pn_buttons", []): + try: + button.destroy() + except Exception: + pass + buttons: List[Any] = [] + for item in items: + name = item.get("name") + title = item.get("title", name) + is_active = name == active + + def _make_cmd(tab_name: Any) -> Any: + def _cmd() -> None: + if callable(on_select): + try: + on_select(tab_name) + except Exception: + pass + + return _cmd + + button = tk.Button( + frame, + text=str(title), + command=_make_cmd(name), + relief="flat", + takefocus=0, + highlightthickness=0, + foreground="#007aff" if is_active else "#8e8e93", + background="#f2f2f7", + activebackground="#e5e5ea", + borderwidth=0, + ) + buttons.append(button) + frame._pn_buttons = buttons + self._layout_buttons(frame) + + def _layout_buttons(self, frame: Any) -> None: + buttons = getattr(frame, "_pn_buttons", []) + count = len(buttons) + if count == 0: + return + frame_w, frame_h = getattr(frame, "_pn_size", (0.0, 0.0)) + if frame_w <= 0: + return + each = frame_w / count + for i, button in enumerate(buttons): + try: + button.place(x=i * each, y=0, width=each, height=frame_h) + except Exception: + pass + + def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None: + native_view._pn_size = (max(0.0, _finite(width)), max(0.0, _finite(height))) + super().set_frame(native_view, x, y, width, height) + self._layout_buttons(native_view) + + def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]: + width = max_width if math.isfinite(max_width) else 320.0 + return (width, 49.0) + + +# ====================================================================== +# Picker / SegmentedControl / DatePicker +# ====================================================================== + + +class PickerHandler(DesktopViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + combo = ttk.Combobox(_master(), state="readonly") + self._bind(combo) + self._apply(combo, props) + return combo + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _bind(self, combo: Any) -> None: + def _on_select(_event: Any = None) -> None: + callback = getattr(combo, "_pn_on_change", None) + items = getattr(combo, "_pn_items", []) + idx = combo.current() + if callable(callback) and 0 <= idx < len(items): + try: + callback(items[idx].get("value")) + except Exception: + pass + + try: + combo.bind("<>", _on_select) + except Exception: + pass + + def _apply(self, combo: Any, props: Dict[str, Any]) -> None: + merged = _merge_props(combo, props) + if "on_change" in props: + combo._pn_on_change = props["on_change"] + items: List[Dict[str, Any]] = merged.get("items") or [] + combo._pn_items = items + labels = [str(item.get("label", item.get("value", ""))) for item in items] + try: + combo.configure(values=labels) + except Exception: + pass + if "value" in merged: + target = merged.get("value") + for i, item in enumerate(items): + if item.get("value") == target: + try: + combo.current(i) + except Exception: + pass + break + + def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]: + return (180.0, 30.0) + + +class SegmentedControlHandler(DesktopViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + frame = tk.Frame(_master(), highlightthickness=0, bd=0) + frame._pn_buttons = [] + self._apply(frame, props) + return frame + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, frame: Any, props: Dict[str, Any]) -> None: + merged = _merge_props(frame, props) + segments: List[str] = merged.get("segments") or [] + selected = int(merged.get("selected_index", 0) or 0) + on_change = merged.get("on_change") + tint = _tk_color(merged.get("tint_color")) or "#007aff" + for button in getattr(frame, "_pn_buttons", []): + try: + button.destroy() + except Exception: + pass + buttons: List[Any] = [] + for i, label in enumerate(segments): + is_active = i == selected + + def _make_cmd(index: int) -> Any: + def _cmd() -> None: + if callable(on_change): + try: + on_change(index) + except Exception: + pass + + return _cmd + + button = tk.Button( + frame, + text=str(label), + command=_make_cmd(i), + relief="flat", + takefocus=0, + highlightthickness=1, + borderwidth=1, + foreground="#ffffff" if is_active else tint, + background=tint if is_active else "#ffffff", + ) + try: + state = "normal" if merged.get("enabled", True) else "disabled" + button.configure({"highlightbackground": tint, "state": state}) + except Exception: + pass + buttons.append(button) + frame._pn_buttons = buttons + self._layout_buttons(frame) + + def _layout_buttons(self, frame: Any) -> None: + buttons = getattr(frame, "_pn_buttons", []) + count = len(buttons) + if count == 0: + return + frame_w, frame_h = getattr(frame, "_pn_size", (0.0, 0.0)) + if frame_w <= 0: + return + each = frame_w / count + for i, button in enumerate(buttons): + try: + button.place(x=i * each, y=0, width=each, height=frame_h) + except Exception: + pass + + def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None: + native_view._pn_size = (max(0.0, _finite(width)), max(0.0, _finite(height))) + super().set_frame(native_view, x, y, width, height) + self._layout_buttons(native_view) + + +class DatePickerHandler(DesktopViewHandler): + """Preview DatePicker — a text entry for the ISO date/time string.""" + + def create(self, props: Dict[str, Any]) -> Any: + entry = tk.Entry(_master(), highlightthickness=1, bd=0) + self._bind(entry) + self._apply(entry, props) + return entry + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _bind(self, entry: Any) -> None: + def _on_key(_event: Any = None) -> None: + callback = getattr(entry, "_pn_on_change", None) + if callable(callback): + try: + callback(entry.get()) + except Exception: + pass + + try: + entry.bind("", _on_key) + except Exception: + pass + + def _apply(self, entry: Any, props: Dict[str, Any]) -> None: + merged = _merge_props(entry, props) + if "on_change" in props: + entry._pn_on_change = props["on_change"] + if "enabled" in merged: + try: + entry.configure(state="normal" if merged.get("enabled", True) else "disabled") + except Exception: + pass + if "value" in props: + incoming = "" if props["value"] is None else str(props["value"]) + try: + if entry.get() != incoming: + state = str(entry.cget("state")) + entry.configure(state="normal") + entry.delete(0, "end") + entry.insert(0, incoming) + entry.configure(state=state) + except Exception: + pass + try: + entry.configure(highlightbackground="#c7c7cc", highlightcolor="#007aff") + except Exception: + pass + + +# ====================================================================== +# VirtualList (FlatList / SectionList) +# ====================================================================== + + +class VirtualListHandler(DesktopViewHandler): + """Preview list — eagerly mounts a bounded window of rows. + + The native iOS/Android backends recycle cells; the desktop preview + mounts up to [`_MAX_ROWS`][pythonnative.native_views.desktop.VirtualListHandler] + rows into per-row cells once its frame is known. Each cell is handed + to the ``mount_row`` callback supplied by + [`FlatList`][pythonnative.FlatList] / [`SectionList`][pythonnative.SectionList], + which mounts the row's element subtree through a nested reconciler. + """ + + _MAX_ROWS = 200 + + def create(self, props: Dict[str, Any]) -> Any: + frame = tk.Frame(_master(), highlightthickness=0, bd=0) + frame._pn_rows = [] + frame._pn_mounted = False + self._apply(frame, props) + return frame + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + was_count = (getattr(native_view, "_pn_props", {}) or {}).get("count") + self._apply(native_view, changed) + if "count" in changed and changed.get("count") != was_count: + native_view._pn_mounted = False + self._mount_rows(native_view) + + def _apply(self, frame: Any, props: Dict[str, Any]) -> Dict[str, Any]: + merged = _merge_props(frame, props) + _apply_common(frame, merged) + return merged + + def _mount_rows(self, frame: Any) -> None: + if getattr(frame, "_pn_mounted", False): + return + merged = getattr(frame, "_pn_props", {}) or {} + count = int(merged.get("count", 0) or 0) + row_height = _finite(merged.get("row_height", 0.0)) + mount_row = merged.get("mount_row") + frame_w, _frame_h = getattr(frame, "_pn_size", (0.0, 0.0)) + if count <= 0 or row_height <= 0 or not callable(mount_row) or frame_w <= 0: + return + for cell in getattr(frame, "_pn_rows", []): + try: + cell.destroy() + except Exception: + pass + rows: List[Any] = [] + for index in range(min(count, self._MAX_ROWS)): + cell = tk.Frame(_master(), highlightthickness=0, bd=0) + cell._pn_parent = frame + cell._pn_frame = (0.0, index * row_height, frame_w, row_height) + _place(cell) + rows.append(cell) + try: + mount_row(index, cell, frame_w, row_height) + except Exception: + pass + frame._pn_rows = rows + frame._pn_mounted = True + + def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None: + native_view._pn_size = (max(0.0, _finite(width)), max(0.0, _finite(height))) + super().set_frame(native_view, x, y, width, height) + self._mount_rows(native_view) + + +# ====================================================================== +# Registration +# ====================================================================== + + +def register_handlers(registry: Any) -> None: + """Register every built-in desktop handler on ``registry``. + + Mirrors ``register_handlers`` in the iOS / Android backends so the + desktop registry services the same 25 element types. + """ + flex = FlexContainerHandler() + registry.register("View", flex) + registry.register("Column", flex) + registry.register("Row", flex) + registry.register("Text", TextHandler()) + registry.register("Button", ButtonHandler()) + registry.register("TextInput", TextInputHandler()) + registry.register("Image", ImageHandler()) + registry.register("Switch", SwitchHandler()) + registry.register("ProgressBar", ProgressBarHandler()) + registry.register("ActivityIndicator", ActivityIndicatorHandler()) + registry.register("WebView", WebViewHandler()) + registry.register("Spacer", SpacerHandler()) + registry.register("ScrollView", ScrollViewHandler()) + registry.register("SafeAreaView", SafeAreaViewHandler()) + registry.register("Modal", ModalHandler()) + registry.register("Slider", SliderHandler()) + registry.register("TabBar", TabBarHandler()) + registry.register("Pressable", PressableHandler()) + registry.register("StatusBar", StatusBarHandler()) + registry.register("KeyboardAvoidingView", KeyboardAvoidingViewHandler()) + registry.register("VirtualList", VirtualListHandler()) + registry.register("Picker", PickerHandler()) + registry.register("Checkbox", CheckboxHandler()) + registry.register("SegmentedControl", SegmentedControlHandler()) + registry.register("DatePicker", DatePickerHandler()) diff --git a/src/pythonnative/platform.py b/src/pythonnative/platform.py index 5f4ca68..e634ce7 100644 --- a/src/pythonnative/platform.py +++ b/src/pythonnative/platform.py @@ -27,7 +27,7 @@ def App(): import sys from typing import Any, Dict, Optional -from .utils import IS_ANDROID, IS_IOS +from .utils import IS_ANDROID, IS_DESKTOP, IS_IOS def _detect_os() -> str: @@ -35,6 +35,8 @@ def _detect_os() -> str: return "android" if IS_IOS: return "ios" + if IS_DESKTOP: + return "desktop" return "test" @@ -72,12 +74,13 @@ class Platform: """Platform-aware constants and the ``select`` dispatcher. All attributes are read at import time. ``OS`` is one of - ``"ios"``, ``"android"``, or ``"test"`` (the latter when running - off-device, e.g., in unit tests). + ``"ios"``, ``"android"``, ``"desktop"`` (the Tkinter preview + backend), or ``"test"`` (when running off-device, e.g., in unit + tests). """ OS: str = _detect_os() - """``"ios"``, ``"android"``, or ``"test"``.""" + """``"ios"``, ``"android"``, ``"desktop"``, or ``"test"``.""" Version: str = _detect_version() """Best-effort OS version string (``"17.4"``, ``"14"``, ``"python-3.11"``).""" @@ -88,6 +91,9 @@ class Platform: is_android: bool = IS_ANDROID """``True`` when running inside an Android process.""" + is_desktop: bool = IS_DESKTOP + """``True`` when running the desktop (Tkinter) preview backend.""" + is_test: bool = OS == "test" """``True`` when running off-device (no native runtime).""" @@ -96,13 +102,14 @@ def select(spec: Dict[str, Any], default: Any = None) -> Any: """Pick the value matching the current platform. Looks up ``spec[Platform.OS]``, then falls back to - ``spec["native"]`` (matches both iOS and Android), then to - ``spec["default"]``, then to the explicit ``default`` argument. + ``spec["native"]`` (matches iOS and Android — *not* desktop, + which is a development surface), then to ``spec["default"]``, + then to the explicit ``default`` argument. Args: spec: Mapping from platform name to value. Recognized keys: - ``"ios"``, ``"android"``, ``"test"``, ``"native"``, - ``"default"``. + ``"ios"``, ``"android"``, ``"desktop"``, ``"test"``, + ``"native"``, ``"default"``. default: Value returned when ``spec`` has no matching key and no ``"default"`` entry. @@ -144,9 +151,11 @@ def _set_platform_for_test(name: Optional[str]) -> None: Platform.OS = _detect_os() Platform.is_ios = IS_IOS Platform.is_android = IS_ANDROID + Platform.is_desktop = IS_DESKTOP Platform.is_test = Platform.OS == "test" return Platform.OS = name Platform.is_ios = name == "ios" Platform.is_android = name == "android" + Platform.is_desktop = name == "desktop" Platform.is_test = name == "test" diff --git a/src/pythonnative/preview.py b/src/pythonnative/preview.py new file mode 100644 index 0000000..18fe804 --- /dev/null +++ b/src/pythonnative/preview.py @@ -0,0 +1,471 @@ +"""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 + + +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) <= 1: + return + top = self._stack.pop() + self._teardown(top) + restored = self._stack[-1] + self._activate(restored) + try: + restored.on_resume() + restored.set_viewport_size(self._width, self._height) + except Exception: + pass + + def reset_to_root(self) -> 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 + try: + host.on_destroy() + except Exception: + pass + reconciler = getattr(host, "_reconciler", None) + tree = getattr(reconciler, "_tree", None) if reconciler is not None else None + if reconciler is not None and tree is not None: + try: + reconciler._destroy_tree(tree) + except Exception: + pass + container = getattr(host, "_pn_container", None) + if container is not None: + try: + container.destroy() + except Exception: + pass + + # -- viewport / resize -------------------------------------------- + + def resize(self, width: float, height: float) -> None: + """Propagate a window resize to the active screen's reconciler.""" + if width <= 0 or height <= 0: + return + self._width = float(width) + self._height = float(height) + host = self.active_host() + if host is not None: + try: + host.set_viewport_size(self._width, self._height) + except Exception: + pass + + def active_host(self) -> Any: + """Return the top-of-stack host, or ``None`` if nothing is mounted.""" + return self._stack[-1] if self._stack else None + + # -- hot reload --------------------------------------------------- + + def reload(self, changed_modules: Optional[List[str]] = None) -> 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) + + 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) + + 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 + 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) diff --git a/src/pythonnative/runtime.py b/src/pythonnative/runtime.py index 1a48649..50165d4 100644 --- a/src/pythonnative/runtime.py +++ b/src/pythonnative/runtime.py @@ -290,6 +290,27 @@ def create_future() -> "asyncio.Future[Any]": # bridge back when it needs to talk to native UI. +# Desktop (Tkinter) main-thread dispatcher, installed by +# ``pythonnative.preview`` while a ``pn preview`` session is live. Tk is +# not thread-safe, so UI work scheduled from the asyncio worker thread +# (animations, alerts) must hop onto the Tk main thread; the preview's +# poll loop drains whatever this dispatcher enqueues. When no preview is +# running (plain scripts / tests) the dispatcher stays ``None`` and work +# runs inline. +_desktop_main_dispatch: Optional[Callable[[Callable[[], None]], None]] = None + + +def set_desktop_main_dispatch(dispatch: Optional[Callable[[Callable[[], None]], None]]) -> None: + """Install (or clear) the desktop main-thread dispatcher. + + Called by ``pythonnative.preview`` with a + function that marshals ``fn`` onto the Tk main thread, and with + ``None`` when the preview window closes. + """ + global _desktop_main_dispatch + _desktop_main_dispatch = dispatch + + def call_on_main_thread(fn: Callable[[], None]) -> None: """Run ``fn()`` on the platform UI thread. @@ -299,7 +320,9 @@ def call_on_main_thread(fn: Callable[[], None]) -> None: ``_ios_call_on_main`` comment block for why this matters). - **Android**: posts a ``Runnable`` to ``Handler(Looper.getMainLooper())``. - - **Desktop / tests**: runs ``fn()`` inline. + - **Desktop**: enqueues ``fn`` for the ``pn preview`` poll loop to + run on the Tk main thread (or runs inline if no preview is live). + - **Tests**: runs ``fn()`` inline. Exceptions raised by ``fn`` are caught and printed; they must not propagate into UIKit / the Android Looper. If you need to surface @@ -317,6 +340,8 @@ def call_on_main_thread(fn: Callable[[], None]) -> None: _ios_call_on_main(fn) elif Platform.is_android: _android_call_on_main(fn) + elif Platform.is_desktop and _desktop_main_dispatch is not None: + _desktop_main_dispatch(fn) else: fn() diff --git a/src/pythonnative/screen.py b/src/pythonnative/screen.py index 984ca0b..28650f4 100644 --- a/src/pythonnative/screen.py +++ b/src/pythonnative/screen.py @@ -50,7 +50,7 @@ def App(): import threading from typing import Any, Dict, Optional, Sequence -from .utils import IS_ANDROID, IS_IOS, set_android_context +from .utils import IS_ANDROID, IS_DESKTOP, IS_IOS, set_android_context _MAX_RENDER_PASSES = 25 _DEBUG_ENV = "PYTHONNATIVE_DEBUG" @@ -87,6 +87,20 @@ def _resolve_component_path(component_ref: Any) -> str: raise ValueError(f"Cannot resolve component path for {component_ref!r}") +def _missing_module_is_target(exc: ModuleNotFoundError, dotted: str) -> bool: + """Return ``True`` when ``exc`` means ``dotted`` itself is absent. + + Distinguishes "the component module/package cannot be found" (so the + caller should fall through to the next resolution strategy) from "the + module exists but raised :class:`ModuleNotFoundError` while importing + one of *its own* dependencies". The latter must propagate so the + developer sees the real missing import (e.g. ``No module named + 'emoji'``) instead of a misleading "could not resolve component". + """ + missing = exc.name or "" + return missing == dotted or dotted.startswith(missing + ".") + + def _import_component(component_path: str) -> Any: """Import a component by module or dotted-attribute path. @@ -117,11 +131,16 @@ def _import_component(component_path: str) -> Any: The resolved component callable. Raises: - ImportError: If neither resolution path succeeds. + ImportError: If the module or dotted path cannot be found. + Errors raised *inside* a resolvable module (such as a + missing third-party dependency it imports) propagate + unchanged so the real cause stays visible. """ try: module = importlib.import_module(component_path) - except ModuleNotFoundError: + except ModuleNotFoundError as exc: + if not _missing_module_is_target(exc, component_path): + raise module = None if module is not None: component = getattr(module, "App", None) @@ -132,7 +151,9 @@ def _import_component(component_path: str) -> Any: module_path, attr = component_path.rsplit(".", 1) try: parent = importlib.import_module(module_path) - except ModuleNotFoundError: + except ModuleNotFoundError as exc: + if not _missing_module_is_target(exc, module_path): + raise parent = None if parent is not None: component = getattr(parent, attr, None) @@ -868,6 +889,165 @@ def set_viewport_size(self, width: float, height: float) -> None: """Public hook for native code to push viewport sizes (Maestro/tests).""" _push_viewport_size(self, width, height) +elif IS_DESKTOP: + # ------------------------------------------------------------------ + # Desktop preview host (Tkinter), driven by ``pn preview``. + # + # The screen host owns the reconciler + lifecycle just like the + # device hosts; placement of the root view and the navigation stack + # are delegated to the ``DesktopApp`` controller in + # ``pythonnative.preview`` (passed in as ``native_instance``). The + # controller runs the Tk event loop on the main thread and polls + # ``drain_desktop_scheduled_renders`` so renders requested from the + # asyncio worker thread are applied on the main thread. + # ------------------------------------------------------------------ + + _DESKTOP_SCHEDULED_RENDER_HOSTS: Dict[int, Any] = {} + _desktop_render_lock = threading.Lock() + + def _schedule_render_async(host: Any) -> bool: + """Queue an off-main-thread render for the Tk poll loop to drain. + + Renders requested on the Tk main thread (button handlers, etc.) + run synchronously (returns ``False``); requests from the asyncio + worker thread are queued and applied by + [`drain_desktop_scheduled_renders`][pythonnative.screen.drain_desktop_scheduled_renders]. + """ + if not IS_DESKTOP: + return False + if threading.current_thread() is threading.main_thread(): + return False + if getattr(host, "_render_scheduled", False): + return True + host._render_scheduled = True + with _desktop_render_lock: + _DESKTOP_SCHEDULED_RENDER_HOSTS[id(host)] = host + return True + + def drain_desktop_scheduled_renders() -> None: + """Apply renders queued from worker threads (called on the main thread).""" + with _desktop_render_lock: + hosts = list(_DESKTOP_SCHEDULED_RENDER_HOSTS.values()) + _DESKTOP_SCHEDULED_RENDER_HOSTS.clear() + _flush_scheduled_renders(hosts) + + class _ScreenHost: + """Desktop host backed by a Tk window and an in-process nav stack. + + Created by ``pythonnative.preview`` for + each screen on the navigation stack. ``native_instance`` is the + ``DesktopApp`` controller, which provides the stage frame, + viewport size, and push/pop primitives. + """ + + def __init__(self, native_instance: Any = None, component_path: str = "", component_func: Any = None) -> None: + self.native_instance = native_instance + _init_host_common(self, component_path, component_func) + + def on_create(self) -> None: + _on_create(self) + + def on_start(self) -> None: + pass + + def on_resume(self) -> None: + _set_host_focused(self, True) + + def on_layout(self) -> None: + pass + + def on_pause(self) -> None: + _set_host_focused(self, False) + + def on_stop(self) -> None: + pass + + def on_destroy(self) -> None: + pass + + def enable_hot_reload(self, manifest_path: str, source_root: Optional[str] = None) -> None: + _enable_hot_reload(self, manifest_path) + + def hot_reload_tick(self) -> bool: + return _hot_reload_tick(self) + + def reload(self, changed_modules: Optional[Sequence[str]] = None) -> None: + _reload_host(self, changed_modules) + + def on_restart(self) -> None: + pass + + def on_save_instance_state(self) -> None: + pass + + def on_restore_instance_state(self) -> None: + pass + + def set_args(self, args: Any) -> None: + _set_args(self, args) + + def _get_nav_args(self) -> Dict[str, Any]: + return self._args + + def _push(self, component: Any, args: Optional[Dict[str, Any]] = None) -> None: + screen_path = _resolve_component_path(component) + app = self.native_instance + if app is None or not hasattr(app, "push_screen"): + raise RuntimeError("desktop navigation requires a running `pn preview` session") + app.push_screen(screen_path, args) + + def _pop(self) -> None: + app = self.native_instance + if app is not None and hasattr(app, "pop_screen"): + app.pop_screen() + + def _reset_to_root(self) -> None: + app = self.native_instance + if app is not None and hasattr(app, "reset_to_root"): + try: + app.reset_to_root() + except Exception: + pass + + def _set_screen_options(self, options: Dict[str, Any]) -> None: + title = options.get("title") if isinstance(options, dict) else None + app = self.native_instance + if title and app is not None and hasattr(app, "set_title"): + try: + app.set_title(str(title)) + except Exception: + pass + + def _attach_root(self, native_view: Any) -> None: + from .native_views import desktop as _desktop_backend + + stage = _desktop_backend.get_root_container() + if stage is not None and native_view is not None: + try: + native_view.place(in_=stage, x=0, y=0, relwidth=1.0, relheight=1.0) + native_view.lift() + except Exception: + pass + app = self.native_instance + if app is not None and hasattr(app, "viewport_size"): + try: + width, height = app.viewport_size() + if width > 0 and height > 0: + _push_viewport_size(self, float(width), float(height)) + except Exception: + pass + + def _detach_root(self, native_view: Any) -> None: + if native_view is not None: + try: + native_view.place_forget() + except Exception: + pass + + def set_viewport_size(self, width: float, height: float) -> None: + """Push a viewport-size change (called on window resize).""" + _push_viewport_size(self, width, height) + else: from typing import Dict as _Dict diff --git a/src/pythonnative/utils.py b/src/pythonnative/utils.py index ef9416a..cba4041 100644 --- a/src/pythonnative/utils.py +++ b/src/pythonnative/utils.py @@ -19,6 +19,10 @@ `PN_PLATFORM=ios`, `sys.platform == "ios"`, or a Simulator `HOME` path). Importing `rubicon-objc` alone is intentionally not enough to trigger this flag. + IS_DESKTOP: `True` when running the desktop preview backend + (signaled by `PN_PLATFORM=desktop`, set by ``pn preview``). + This drives the Tkinter native-view registry so a PythonNative + app can render in a real OS window for fast local iteration. """ import os @@ -31,6 +35,7 @@ _is_android: Optional[bool] = None _is_ios: Optional[bool] = None +_is_desktop: Optional[bool] = None def _detect_android() -> bool: @@ -75,13 +80,29 @@ def _detect_ios() -> bool: return False +def _detect_desktop() -> bool: + """Detect whether we're running the desktop (Tkinter) preview backend. + + The only signal is the explicit ``PN_PLATFORM=desktop`` env var, + set by ``pn preview`` before importing PythonNative. Desktop is a + *development* target: it renders the app in a native OS window via + the pure-Python Tkinter registry so the inner dev loop doesn't + require a device build. Off-device unit tests deliberately leave + this flag ``False`` so they keep using an injected mock registry + and ``Platform.OS == "test"``. + """ + return os.environ.get("PN_PLATFORM") == "desktop" + + def _ensure_platform_detection() -> None: - """Populate `_is_android` / `_is_ios` once, then reuse.""" - global _is_android, _is_ios + """Populate `_is_android` / `_is_ios` / `_is_desktop` once, then reuse.""" + global _is_android, _is_ios, _is_desktop if _is_android is None: _is_android = _detect_android() if _is_ios is None: _is_ios = (not _is_android) and _detect_ios() + if _is_desktop is None: + _is_desktop = (not _is_android) and (not _is_ios) and _detect_desktop() def _get_is_android() -> bool: @@ -98,6 +119,13 @@ def _get_is_ios() -> bool: return _is_ios +def _get_is_desktop() -> bool: + """Return the cached desktop-detection result.""" + _ensure_platform_detection() + assert _is_desktop is not None + return _is_desktop + + IS_ANDROID: bool = _get_is_android() """``True`` when running inside an Android process. @@ -113,6 +141,14 @@ def _get_is_ios() -> bool: `HOME` path. """ +IS_DESKTOP: bool = _get_is_desktop() +"""``True`` when running the desktop (Tkinter) preview backend. + +Set by ``pn preview`` via ``PN_PLATFORM=desktop``. Mutually exclusive +with `IS_ANDROID` / `IS_IOS`. Off-device unit tests leave this +``False`` and inject a mock registry instead. +""" + # ====================================================================== # Android context management # ====================================================================== diff --git a/tests/test_desktop_backend.py b/tests/test_desktop_backend.py new file mode 100644 index 0000000..bbf47b5 --- /dev/null +++ b/tests/test_desktop_backend.py @@ -0,0 +1,363 @@ +"""Tests for the desktop (Tkinter) preview backend. + +Split into two tiers: + +- **Pure / headless tests** (always run): registration parity with the + device backends, color conversion, font-weight detection, and the + ``Platform`` desktop selector. These need no display. +- **GUI tests** (skipped when Tk can't open a display, e.g. on CI): + mount a real element tree through a ``Reconciler`` + the desktop + registry and assert widgets are created, laid out, measured, and + wired to callbacks. A subprocess test exercises the full + ``pn preview`` host + navigation stack under ``PN_PLATFORM=desktop``. +""" + +import os +import subprocess +import sys +import textwrap +from typing import Any + +import pytest + +from pythonnative.native_views import NativeViewRegistry + +# Importing the desktop backend pulls in ``tkinter`` (the module, not a +# live display). That's available on standard CPython, but guard it so a +# Python built entirely without Tk skips this module instead of erroring +# at collection. +try: + from pythonnative.native_views import desktop as desktop_backend + + _DESKTOP_IMPORT_ERROR = None +except Exception as exc: # pragma: no cover - environment dependent + desktop_backend = None + _DESKTOP_IMPORT_ERROR = exc + +pytestmark = pytest.mark.skipif( + desktop_backend is None, + reason=f"tkinter unavailable: {_DESKTOP_IMPORT_ERROR!r}", +) + +# The 25 built-in element types every platform backend must service. +_EXPECTED_TYPES = { + "View", + "Column", + "Row", + "Text", + "Button", + "TextInput", + "Image", + "Switch", + "ProgressBar", + "ActivityIndicator", + "WebView", + "Spacer", + "ScrollView", + "SafeAreaView", + "Modal", + "Slider", + "TabBar", + "Pressable", + "StatusBar", + "KeyboardAvoidingView", + "VirtualList", + "Picker", + "Checkbox", + "SegmentedControl", + "DatePicker", +} + + +def _display_available() -> bool: + """Return ``True`` if Tk can open a display in this environment. + + Probed in a *subprocess* on purpose: repeatedly creating and + destroying Tk roots in a single process is unstable on macOS Aqua + (it can segfault), so the GUI tests below each run in their own + fresh interpreter and the probe must not leave a root behind here. + """ + try: + result = subprocess.run( + [sys.executable, "-c", "import tkinter; tkinter.Tk().destroy()"], + capture_output=True, + timeout=30, + ) + return result.returncode == 0 + except Exception: + return False + + +HAS_DISPLAY = _display_available() +requires_display = pytest.mark.skipif(not HAS_DISPLAY, reason="Tk display unavailable (e.g. headless CI)") + + +# ====================================================================== +# Registration parity (headless) +# ====================================================================== + + +def test_register_handlers_covers_all_builtin_types() -> None: + registry = NativeViewRegistry() + desktop_backend.register_handlers(registry) + registered = set(registry._handlers.keys()) + assert registered == _EXPECTED_TYPES + + +def test_view_column_row_share_one_handler() -> None: + registry = NativeViewRegistry() + desktop_backend.register_handlers(registry) + assert registry._handlers["View"] is registry._handlers["Column"] + assert registry._handlers["View"] is registry._handlers["Row"] + + +# ====================================================================== +# Color conversion (headless) +# ====================================================================== + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("#abc", "#aabbcc"), + ("#AABBCC", "#AABBCC"), + ("#80ff0000", "#ff0000"), # #aarrggbb -> drop alpha + ("#f00a", "#ff0000"), # #rgba -> drop alpha + (0xFF0000, "#ff0000"), + ((255, 0, 0), "#ff0000"), + ("rgb(255, 0, 0)", "#ff0000"), + ("rgba(0, 255, 0, 0.5)", "#00ff00"), + ("red", "red"), + ("transparent", None), + ("", None), + (None, None), + (True, None), + ], +) +def test_tk_color(value: Any, expected: Any) -> None: + assert desktop_backend._tk_color(value) == expected + + +# ====================================================================== +# Font-weight detection (headless) +# ====================================================================== + + +@pytest.mark.parametrize( + ("props", "expected"), + [ + ({"bold": True}, True), + ({"font_weight": "bold"}, True), + ({"font_weight": "semibold"}, True), + ({"font_weight": 700}, True), + ({"font_weight": 600}, True), + ({"font_weight": 500}, False), + ({"font_weight": "normal"}, False), + ({}, False), + ], +) +def test_is_bold(props: dict, expected: bool) -> None: + assert desktop_backend._is_bold(props) is expected + + +# ====================================================================== +# Platform.select desktop branch (headless) +# ====================================================================== + + +def test_platform_select_desktop() -> None: + from pythonnative.platform import Platform, _set_platform_for_test + + try: + _set_platform_for_test("desktop") + assert Platform.OS == "desktop" + assert Platform.is_desktop is True + assert Platform.is_test is False + assert Platform.select({"desktop": "d", "ios": "i", "default": "x"}) == "d" + # ``native`` matches iOS/Android only — desktop is a dev surface. + assert Platform.select({"native": "n", "default": "x"}) == "x" + finally: + _set_platform_for_test(None) + + +# ====================================================================== +# GUI tests (require a display; each runs in its own subprocess) +# ====================================================================== +# +# Tk is exercised in isolated subprocesses rather than in-process: macOS +# Aqua is unstable when a single interpreter creates and tears down +# multiple Tk roots across tests (observed segfaults). One root per +# process, with a clean exit, sidesteps that entirely and also keeps +# the global stage / registry state from leaking between tests. + + +def _run_gui_script(tmp_path: Any, name: str, body: str, sentinel: str, *, desktop_env: bool = False) -> None: + script = tmp_path / name + script.write_text(textwrap.dedent(body)) + env = dict(os.environ) + if desktop_env: + env["PN_PLATFORM"] = "desktop" + result = subprocess.run( + [sys.executable, str(script)], + env=env, + capture_output=True, + text=True, + timeout=60, + ) + assert result.returncode == 0, f"stdout={result.stdout!r}\nstderr={result.stderr!r}" + assert sentinel in result.stdout, f"missing {sentinel}; stdout={result.stdout!r} stderr={result.stderr!r}" + + +_BACKEND_SCRIPT = """ + import tkinter as tk + import pythonnative as pn + from pythonnative.reconciler import Reconciler + from pythonnative.native_views import NativeViewRegistry + from pythonnative.native_views import desktop as dk + + root = tk.Tk(); root.withdraw() + stage = tk.Frame(root); stage.place(x=0, y=0, width=390, height=844) + dk.set_root_container(stage) + reg = NativeViewRegistry(); dk.register_handlers(reg) + rec = Reconciler(reg) + + calls = [] + rec.mount(pn.Column( + pn.Text("Hi there", style={"font_size": 18}), + pn.Button("OK", on_click=lambda: calls.append(1)), + style={"padding": 10, "spacing": 8}, + )) + rec.set_viewport_size(390.0, 844.0) + root.update_idletasks() + + def walk(w, acc): + for c in w.winfo_children(): + acc.append(c); walk(c, acc) + return acc + + widgets = walk(stage, []) + text = next(w for w in widgets if isinstance(w, tk.Label) and w.cget("text") == "Hi there") + assert text.winfo_width() > 0 and text.winfo_height() > 0, (text.winfo_width(), text.winfo_height()) + assert text.winfo_x() >= 10, text.winfo_x() # padding pushed it in + + button = next(w for w in widgets if isinstance(w, tk.Button) and w.cget("text") == "OK") + button.invoke() + assert calls == [1], calls + + rec.reconcile(pn.Column( + pn.Text("changed", style={"font_size": 18}), + pn.Button("OK"), + style={"padding": 10, "spacing": 8}, + )) + root.update_idletasks() + texts = [w.cget("text") for w in walk(stage, []) if isinstance(w, tk.Label)] + assert "changed" in texts and "Hi there" not in texts, texts + + handler = dk.TextHandler() + label = handler.create({"text": "hello world wrapping test", "font_size": 16}) + wide_w, wide_h = handler.measure_intrinsic(label, 10000.0, 10000.0) + narrow_w, narrow_h = handler.measure_intrinsic(label, 40.0, 10000.0) + assert wide_w > 0 and wide_h > 0, (wide_w, wide_h) + assert narrow_w <= 40.0 and narrow_h >= wide_h, (narrow_w, narrow_h, wide_h) + + root.destroy() + print("BACKEND_OK") +""" + + +@requires_display +def test_backend_mount_layout_and_measure(tmp_path: Any) -> None: + _run_gui_script(tmp_path, "backend_script.py", _BACKEND_SCRIPT, "BACKEND_OK") + + +# ====================================================================== +# Full preview host + navigation stack (subprocess, requires display) +# ====================================================================== + +_NAV_SCRIPT = """ + import sys, types + import tkinter as tk + import pythonnative as pn + from pythonnative import preview, screen as sc + from pythonnative.native_views import desktop as dk + from pythonnative.platform import Platform + from pythonnative.utils import IS_DESKTOP + + assert IS_DESKTOP, "IS_DESKTOP must be True under PN_PLATFORM=desktop" + assert Platform.OS == "desktop", Platform.OS + + Stack = pn.create_stack_navigator() + + @pn.component + def Home(): + count, set_count = pn.use_state(0) + nav = pn.use_navigation() + return pn.Column( + pn.Text("HOME"), + pn.Text("count=%d" % count), + pn.Button("inc", on_click=lambda: set_count(count + 1)), + pn.Button("detail", on_click=lambda: nav.navigate("Detail", {"x": count})), + ) + + @pn.component + def Detail(): + nav = pn.use_navigation() + p = pn.use_route() + return pn.Column( + pn.Text("DETAIL x=%s" % p.get("x")), + pn.Button("back", on_click=nav.go_back), + ) + + @pn.component + def App(): + return pn.NavigationContainer(Stack.Navigator( + Stack.Screen("Home", component=Home), + Stack.Screen("Detail", component=Detail), + )) + + mod = types.ModuleType("nav_app") + mod.App = App + sys.modules["nav_app"] = mod + + def walk(w, acc): + for c in w.winfo_children(): + acc.append(c); walk(c, acc) + return acc + + def labels(stage): + return [w.cget("text") for w in walk(stage, []) if isinstance(w, tk.Label)] + + def button(stage, text): + for b in walk(stage, []): + if isinstance(b, tk.Button) and b.cget("text") == text: + return b + raise AssertionError("no button " + text) + + root = tk.Tk(); root.withdraw() + stage = tk.Frame(root); stage.place(x=0, y=0, width=390, height=844) + dk.set_root_container(stage) + app = preview.DesktopApp(root, stage, 390, 844) + app.mount_root("nav_app") + root.update() + assert any("HOME" in t for t in labels(stage)), labels(stage) + assert any("count=0" in t for t in labels(stage)), labels(stage) + + button(stage, "inc").invoke(); sc.drain_desktop_scheduled_renders(); root.update() + assert any("count=1" in t for t in labels(stage)), labels(stage) + + button(stage, "detail").invoke(); sc.drain_desktop_scheduled_renders(); root.update() + assert any("DETAIL x=1" in t for t in labels(stage)), labels(stage) + assert len(app._stack) == 2, len(app._stack) + + button(stage, "back").invoke(); sc.drain_desktop_scheduled_renders(); root.update() + assert len(app._stack) == 1, len(app._stack) + assert any("count=1" in t for t in labels(stage)), labels(stage) # state preserved + + root.destroy() + print("NAV_OK") +""" + + +@requires_display +def test_preview_navigation_stack_subprocess(tmp_path: Any) -> None: + _run_gui_script(tmp_path, "nav_script.py", _NAV_SCRIPT, "NAV_OK", desktop_env=True) diff --git a/tests/test_screen.py b/tests/test_screen.py index c4baede..209e6f5 100644 --- a/tests/test_screen.py +++ b/tests/test_screen.py @@ -187,3 +187,46 @@ def on_layout(self) -> None: assert host.received == ["on_layout"] finally: registry.clear() + + +def test_import_component_propagates_real_dependency_errors( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A failing import *inside* the app must not be masked. + + Regression: ``_import_component`` caught every ``ModuleNotFoundError`` + and reported a generic "could not resolve component", hiding the real + cause (e.g. a dependency the developer forgot to install). The actual + error must propagate, while a genuinely absent path still yields the + friendly resolve error. This is what makes ``pn preview`` surface + ``No module named 'emoji'`` instead of a misleading message. + """ + from pythonnative.screen import _import_component + + pkg = tmp_path / "resolver_app" + pkg.mkdir() + (pkg / "__init__.py").write_text("", encoding="utf-8") + (pkg / "good.py").write_text("def App():\n return None\n", encoding="utf-8") + (pkg / "bad.py").write_text( + "import a_dependency_that_is_not_installed_xyz\n\ndef App():\n return None\n", + encoding="utf-8", + ) + + monkeypatch.syspath_prepend(os.fspath(tmp_path)) + monkeypatch.setattr(sys, "dont_write_bytecode", True) + for name in ("resolver_app", "resolver_app.good", "resolver_app.bad"): + sys.modules.pop(name, None) + + # A resolvable module returns its ``App``. + assert callable(_import_component("resolver_app.good")) + + # The module exists but imports a missing dependency: the real + # ``ModuleNotFoundError`` (naming that dependency) must propagate. + with pytest.raises(ModuleNotFoundError) as excinfo: + _import_component("resolver_app.bad") + assert excinfo.value.name == "a_dependency_that_is_not_installed_xyz" + + # A genuinely absent path still gives the friendly resolve error. + with pytest.raises(ImportError, match="Could not resolve component"): + _import_component("resolver_app.nope_does_not_exist")