pythonnative 0.22.0__py3-none-any.whl → 0.23.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pythonnative/__init__.py +16 -2
- pythonnative/animated.py +6 -6
- pythonnative/appearance.py +124 -0
- pythonnative/cli/pn.py +1 -1
- pythonnative/components.py +718 -48
- pythonnative/events.py +5 -5
- pythonnative/gestures.py +3 -3
- pythonnative/hooks.py +44 -3
- pythonnative/hot_reload.py +4 -4
- pythonnative/images.py +196 -0
- pythonnative/layout.py +3 -3
- pythonnative/mutations.py +4 -13
- pythonnative/native_modules/camera.py +1 -1
- pythonnative/native_modules/haptics.py +2 -2
- pythonnative/native_modules/location.py +1 -1
- pythonnative/native_modules/permissions.py +2 -2
- pythonnative/native_modules/secure_store.py +1 -1
- pythonnative/native_views/__init__.py +1 -7
- pythonnative/native_views/android.py +592 -64
- pythonnative/native_views/base.py +3 -3
- pythonnative/native_views/desktop.py +85 -26
- pythonnative/native_views/ios.py +762 -74
- pythonnative/navigation.py +4 -4
- pythonnative/net.py +3 -3
- pythonnative/platform.py +1 -1
- pythonnative/platform_metrics.py +5 -5
- pythonnative/preview.py +34 -3
- pythonnative/project/builder.py +1 -1
- pythonnative/project/config.py +4 -12
- pythonnative/project/doctor.py +2 -2
- pythonnative/project/icons.py +1 -1
- pythonnative/project/ios.py +1 -1
- pythonnative/project/permissions.py +2 -2
- pythonnative/project/runtime_assets.py +3 -3
- pythonnative/reconciler.py +9 -9
- pythonnative/runtime.py +8 -8
- pythonnative/screen.py +90 -5
- pythonnative/sdk/_components.py +1 -1
- pythonnative/storage.py +3 -3
- pythonnative/style.py +66 -7
- pythonnative/templates/android_template/app/src/main/AndroidManifest.xml +2 -1
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNAccessibilityDelegate.kt +47 -0
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNBorderDrawable.kt +67 -0
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNVirtualListView.java +172 -0
- pythonnative/templates/ios_template/ios_template.xcodeproj/project.pbxproj +2 -2
- pythonnative/templates/ios_template/ios_templateUITests/ios_templateUITests.swift +1 -1
- pythonnative/virtual_rows.py +137 -0
- {pythonnative-0.22.0.dist-info → pythonnative-0.23.0.dist-info}/METADATA +13 -13
- {pythonnative-0.22.0.dist-info → pythonnative-0.23.0.dist-info}/RECORD +53 -47
- {pythonnative-0.22.0.dist-info → pythonnative-0.23.0.dist-info}/WHEEL +1 -1
- {pythonnative-0.22.0.dist-info → pythonnative-0.23.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.22.0.dist-info → pythonnative-0.23.0.dist-info}/licenses/LICENSE +0 -0
- {pythonnative-0.22.0.dist-info → pythonnative-0.23.0.dist-info}/top_level.txt +0 -0
pythonnative/navigation.py
CHANGED
|
@@ -25,7 +25,7 @@ Stack navigators rendered as the root of an app host (i.e. the parent
|
|
|
25
25
|
own handle) talk to the platform via that host's ``_push`` / ``_pop``
|
|
26
26
|
methods, so the back stack matches what UIKit / AndroidX maintain.
|
|
27
27
|
Nested stacks (e.g. a stack inside a tab) fall back to in-Python
|
|
28
|
-
state
|
|
28
|
+
state; there's no second native navigation controller to push
|
|
29
29
|
onto in that case.
|
|
30
30
|
|
|
31
31
|
Example:
|
|
@@ -66,7 +66,7 @@ from .hooks import (
|
|
|
66
66
|
|
|
67
67
|
# Defaults to True: components rendered outside any declarative
|
|
68
68
|
# navigator (e.g. the root component of a screen pushed via the host's
|
|
69
|
-
# native nav stack) are by definition focused
|
|
69
|
+
# native nav stack) are by definition focused; the host's own
|
|
70
70
|
# ``on_resume`` / ``on_pause`` lifecycle drives the focus state for
|
|
71
71
|
# those. Declarative navigators override this provider on the active
|
|
72
72
|
# subtree (always True today; reserved for future inactive-screen
|
|
@@ -152,7 +152,7 @@ class _DeclarativeNavHandle:
|
|
|
152
152
|
|
|
153
153
|
When ``parent`` is the host's own ``NavigationHandle`` (root
|
|
154
154
|
Stack), ``navigate`` / ``go_back`` / ``reset`` drive the native
|
|
155
|
-
navigation controller and the in-Python stack is bypassed
|
|
155
|
+
navigation controller and the in-Python stack is bypassed; the
|
|
156
156
|
OS owns the back-stack source of truth.
|
|
157
157
|
|
|
158
158
|
When ``parent`` is another declarative handle (nested navigator),
|
|
@@ -212,7 +212,7 @@ class _DeclarativeNavHandle:
|
|
|
212
212
|
untouched because the native controller is the source of
|
|
213
213
|
truth.
|
|
214
214
|
- **Nested stack / tab / drawer**: ``set_stack`` is called with
|
|
215
|
-
the new route
|
|
215
|
+
the new route; the parent reconciler re-renders the active
|
|
216
216
|
screen subtree in place.
|
|
217
217
|
- **Unknown route**: forwarded to ``parent`` if one exists,
|
|
218
218
|
otherwise raises ``ValueError``.
|
pythonnative/net.py
CHANGED
|
@@ -4,7 +4,7 @@ A small, dependency-free coroutine wrapper around
|
|
|
4
4
|
:mod:`urllib.request`. Operates on bytes internally and exposes a
|
|
5
5
|
:class:`Response` with `text()`, `json()`, and `bytes` accessors.
|
|
6
6
|
|
|
7
|
-
The implementation is deliberately minimal
|
|
7
|
+
The implementation is deliberately minimal; it covers the
|
|
8
8
|
"call a JSON API" path that's overwhelmingly the use case for mobile
|
|
9
9
|
apps. For streaming, multipart uploads, or HTTP/2, integrate
|
|
10
10
|
``httpx`` / ``aiohttp`` directly; this module won't try to compete.
|
|
@@ -161,7 +161,7 @@ async def fetch(
|
|
|
161
161
|
TimeoutError: If the request doesn't complete within
|
|
162
162
|
``timeout`` seconds.
|
|
163
163
|
OSError: For network errors (DNS failure, connection refused,
|
|
164
|
-
etc.)
|
|
164
|
+
etc.), re-raised from ``urllib``.
|
|
165
165
|
|
|
166
166
|
Example:
|
|
167
167
|
```python
|
|
@@ -223,7 +223,7 @@ def _dispatch_request(request: urllib.request.Request, timeout: float) -> Respon
|
|
|
223
223
|
content=content,
|
|
224
224
|
)
|
|
225
225
|
except urllib.error.HTTPError as exc:
|
|
226
|
-
# HTTPError is itself a response object
|
|
226
|
+
# HTTPError is itself a response object; propagate the body
|
|
227
227
|
# so callers can inspect it before deciding to raise.
|
|
228
228
|
body = exc.read() if hasattr(exc, "read") else b""
|
|
229
229
|
return Response(
|
pythonnative/platform.py
CHANGED
|
@@ -102,7 +102,7 @@ class Platform:
|
|
|
102
102
|
"""Pick the value matching the current platform.
|
|
103
103
|
|
|
104
104
|
Looks up ``spec[Platform.OS]``, then falls back to
|
|
105
|
-
``spec["native"]`` (matches iOS and Android
|
|
105
|
+
``spec["native"]`` (matches iOS and Android, *not* desktop,
|
|
106
106
|
which is a development surface), then to ``spec["default"]``,
|
|
107
107
|
then to the explicit ``default`` argument.
|
|
108
108
|
|
pythonnative/platform_metrics.py
CHANGED
|
@@ -16,7 +16,7 @@ that state to size themselves correctly:
|
|
|
16
16
|
Rather than threading those values through every
|
|
17
17
|
[`measure_intrinsic`][pythonnative.native_views.base.ViewHandler.measure_intrinsic]
|
|
18
18
|
call signature, the screen host writes them here and handlers read
|
|
19
|
-
them on demand. Values are in **dp on Android** and **pt on iOS
|
|
19
|
+
them on demand. Values are in **dp on Android** and **pt on iOS**,
|
|
20
20
|
i.e., the same "layout units" the layout engine uses on each
|
|
21
21
|
platform, so handlers can add them to other layout-unit values
|
|
22
22
|
without further conversion. On iOS the screen host consumes the top
|
|
@@ -88,7 +88,7 @@ def subscribe(callback: Callable[[], None]) -> Callable[[], None]:
|
|
|
88
88
|
|
|
89
89
|
Returns an unsubscribe function. Hooks pass a state setter so a
|
|
90
90
|
component re-renders whenever the platform reports a new value.
|
|
91
|
-
Threadsafe
|
|
91
|
+
Threadsafe: multiple subscribers may register/unregister
|
|
92
92
|
concurrently.
|
|
93
93
|
"""
|
|
94
94
|
with _subscribers_lock:
|
|
@@ -138,7 +138,7 @@ def set_safe_area_insets(top: float, left: float, bottom: float, right: float) -
|
|
|
138
138
|
def get_safe_area_insets() -> SafeAreaInsets:
|
|
139
139
|
"""Return the current safe-area insets.
|
|
140
140
|
|
|
141
|
-
The default value is ``(0, 0, 0, 0)
|
|
141
|
+
The default value is ``(0, 0, 0, 0)``; handlers should still
|
|
142
142
|
function correctly on a desktop / unit-test environment where no
|
|
143
143
|
screen host has published insets.
|
|
144
144
|
"""
|
|
@@ -217,7 +217,7 @@ def reset_keyboard_height() -> None:
|
|
|
217
217
|
# trust ``UITabBar.sizeThatFits_`` (it has historically returned 0 in
|
|
218
218
|
# some configurations) and the screen host deliberately extends the
|
|
219
219
|
# root view past the bottom safe area so the bar reaches the home
|
|
220
|
-
# indicator
|
|
220
|
+
# indicator; both pieces conspire to require a single source of
|
|
221
221
|
# truth for the height formula.
|
|
222
222
|
#
|
|
223
223
|
# Android intentionally has no equivalent: ``BottomNavigationView``
|
|
@@ -240,7 +240,7 @@ def ios_tab_bar_height() -> float:
|
|
|
240
240
|
Equal to ``IOS_TAB_BAR_BASE_HEIGHT_PT + safe_area_insets.bottom``
|
|
241
241
|
so the bar reaches the home indicator. The iOS screen host
|
|
242
242
|
deliberately extends the root view past the bottom safe area for
|
|
243
|
-
this very reason
|
|
243
|
+
this very reason; the tab bar absorbs the inset and UIKit
|
|
244
244
|
renders the pill with internal padding for the home indicator.
|
|
245
245
|
Used by ``pythonnative.native_views.ios.TabBarHandler``; exposed
|
|
246
246
|
here so the formula is testable without importing the iOS
|
pythonnative/preview.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Desktop preview runtime
|
|
1
|
+
"""Desktop preview runtime, the engine behind ``pn preview``.
|
|
2
2
|
|
|
3
3
|
``pn preview`` renders a PythonNative app in a real OS window using the
|
|
4
4
|
Tkinter backend ([`pythonnative.native_views.desktop`][pythonnative.native_views.desktop]),
|
|
@@ -43,6 +43,36 @@ _POLL_INTERVAL_MS = 16
|
|
|
43
43
|
_WATCH_INTERVAL_S = 0.4
|
|
44
44
|
|
|
45
45
|
|
|
46
|
+
def _publish_desktop_color_scheme() -> None:
|
|
47
|
+
"""Publish the host OS appearance so `use_color_scheme` works in preview.
|
|
48
|
+
|
|
49
|
+
``PN_COLOR_SCHEME=light|dark`` forces a value (handy for testing
|
|
50
|
+
both appearances); otherwise macOS is asked via ``defaults read``
|
|
51
|
+
(the key only exists when dark mode is on). Other platforms default
|
|
52
|
+
to light.
|
|
53
|
+
"""
|
|
54
|
+
from . import appearance
|
|
55
|
+
|
|
56
|
+
forced = os.environ.get("PN_COLOR_SCHEME")
|
|
57
|
+
if forced in ("light", "dark"):
|
|
58
|
+
appearance.set_system_color_scheme(forced)
|
|
59
|
+
return
|
|
60
|
+
if sys.platform == "darwin":
|
|
61
|
+
import subprocess
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
result = subprocess.run(
|
|
65
|
+
["defaults", "read", "-g", "AppleInterfaceStyle"],
|
|
66
|
+
capture_output=True,
|
|
67
|
+
text=True,
|
|
68
|
+
timeout=2,
|
|
69
|
+
)
|
|
70
|
+
is_dark = result.returncode == 0 and result.stdout.strip() == "Dark"
|
|
71
|
+
appearance.set_system_color_scheme("dark" if is_dark else "light")
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
|
|
46
76
|
class DesktopApp:
|
|
47
77
|
"""Navigation-stack controller for the desktop preview window.
|
|
48
78
|
|
|
@@ -50,7 +80,7 @@ class DesktopApp:
|
|
|
50
80
|
[`screen`][pythonnative.screen] host as the ``native_instance`` so
|
|
51
81
|
hosts can drive navigation (``push_screen`` / ``pop_screen`` /
|
|
52
82
|
``reset_to_root``), report the viewport size, and set the window
|
|
53
|
-
title
|
|
83
|
+
title, mirroring the role a ``UIViewController`` / ``Activity``
|
|
54
84
|
plays on device.
|
|
55
85
|
"""
|
|
56
86
|
|
|
@@ -359,6 +389,7 @@ def run_preview(
|
|
|
359
389
|
)
|
|
360
390
|
|
|
361
391
|
root_dir, watched = _resolve_paths(component_path, project_root, watch_dir)
|
|
392
|
+
_publish_desktop_color_scheme()
|
|
362
393
|
|
|
363
394
|
root = tk.Tk()
|
|
364
395
|
root.title(title)
|
|
@@ -424,7 +455,7 @@ def run_preview(
|
|
|
424
455
|
|
|
425
456
|
root.protocol("WM_DELETE_WINDOW", _on_close)
|
|
426
457
|
root.after(_POLL_INTERVAL_MS, _poll)
|
|
427
|
-
print(f"[pn preview] {component_path}
|
|
458
|
+
print(f"[pn preview] {component_path} ({int(width)}x{int(height)})", file=sys.stderr)
|
|
428
459
|
try:
|
|
429
460
|
root.mainloop()
|
|
430
461
|
except KeyboardInterrupt:
|
pythonnative/project/builder.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
The [`Builder`][pythonnative.project.builder.Builder] ties the pieces
|
|
4
4
|
together: it stages the bundled native template, runs the platform
|
|
5
5
|
[`configurators`][pythonnative.project.android], invokes the native
|
|
6
|
-
toolchains (Gradle / Xcode), and
|
|
6
|
+
toolchains (Gradle / Xcode), and, on iOS, embeds the CPython runtime into
|
|
7
7
|
the built app.
|
|
8
8
|
|
|
9
9
|
All shell-outs go through a small
|
pythonnative/project/config.py
CHANGED
|
@@ -113,7 +113,7 @@ class IOSSigning:
|
|
|
113
113
|
"""iOS code-signing / export configuration (``[ios.signing]``).
|
|
114
114
|
|
|
115
115
|
Attributes:
|
|
116
|
-
export_method: How the archive is exported
|
|
116
|
+
export_method: How the archive is exported, one of
|
|
117
117
|
``development``, ``ad-hoc``, ``app-store``, ``enterprise``.
|
|
118
118
|
provisioning_profile: Optional provisioning profile name or UUID
|
|
119
119
|
for manual signing.
|
|
@@ -321,15 +321,7 @@ class AppConfig:
|
|
|
321
321
|
root = Path(project_root) if project_root is not None else Path.cwd()
|
|
322
322
|
config_path = root / CONFIG_FILENAME
|
|
323
323
|
if not config_path.is_file():
|
|
324
|
-
|
|
325
|
-
hint = ""
|
|
326
|
-
if legacy.is_file():
|
|
327
|
-
hint = (
|
|
328
|
-
"\nFound a legacy 'pythonnative.json'. PythonNative now uses "
|
|
329
|
-
"'pythonnative.toml'.\nRun 'pn init --force' to scaffold one, then "
|
|
330
|
-
"port your settings over."
|
|
331
|
-
)
|
|
332
|
-
raise ConfigError(f"No {CONFIG_FILENAME} found in {root}.{hint}")
|
|
324
|
+
raise ConfigError(f"No {CONFIG_FILENAME} found in {root}.")
|
|
333
325
|
try:
|
|
334
326
|
with open(config_path, "rb") as handle:
|
|
335
327
|
data = _toml.load(handle)
|
|
@@ -590,7 +582,7 @@ def render_default_toml(*, name: str, app_id: str, python_version: str = "3.11")
|
|
|
590
582
|
"""
|
|
591
583
|
display = name.replace("_", " ").replace("-", " ").strip().title() or name
|
|
592
584
|
return f"""# PythonNative project configuration.
|
|
593
|
-
# Docs: https://
|
|
585
|
+
# Docs: https://pythonnative.com/guides/configuration/
|
|
594
586
|
|
|
595
587
|
[app]
|
|
596
588
|
id = "{app_id}"
|
|
@@ -604,7 +596,7 @@ entry_point = "app/main.py"
|
|
|
604
596
|
|
|
605
597
|
# Declare the device capabilities your app needs. A string becomes the
|
|
606
598
|
# iOS permission prompt text; `true` uses a sensible default.
|
|
607
|
-
# See: https://
|
|
599
|
+
# See: https://pythonnative.com/guides/permissions/
|
|
608
600
|
[permissions]
|
|
609
601
|
# camera = "Scan receipts with your camera."
|
|
610
602
|
# location_when_in_use = "Show nearby stores."
|
pythonnative/project/doctor.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Environment diagnostics for ``pn doctor``.
|
|
2
2
|
|
|
3
3
|
Inspects the local toolchain and the project's ``pythonnative.toml`` and
|
|
4
|
-
reports what's ready and what's missing for building on each platform
|
|
4
|
+
reports what's ready and what's missing for building on each platform,
|
|
5
5
|
analogous to ``flutter doctor`` / ``npx react-native doctor``. The checks
|
|
6
6
|
are deliberately read-only and fast; they shell out only to ask tools for
|
|
7
7
|
their versions.
|
|
@@ -44,7 +44,7 @@ class CheckResult:
|
|
|
44
44
|
def format(self) -> str:
|
|
45
45
|
"""Return a single aligned line for terminal output."""
|
|
46
46
|
symbol = _SYMBOLS.get(self.level, "[?]")
|
|
47
|
-
suffix = f"
|
|
47
|
+
suffix = f": {self.detail}" if self.detail else ""
|
|
48
48
|
return f" {symbol} {self.name}{suffix}"
|
|
49
49
|
|
|
50
50
|
|
pythonnative/project/icons.py
CHANGED
|
@@ -77,7 +77,7 @@ def _circular(img: "object") -> "object":
|
|
|
77
77
|
def generate_ios_icons(source: Path, appiconset_dir: Path) -> bool:
|
|
78
78
|
"""Generate a single-size iOS ``AppIcon.appiconset``.
|
|
79
79
|
|
|
80
|
-
Writes ``icon-1024.png`` (a flattened, opaque 1024x1024 image
|
|
80
|
+
Writes ``icon-1024.png`` (a flattened, opaque 1024x1024 image; the
|
|
81
81
|
App Store rejects icons with alpha) and a ``Contents.json`` that
|
|
82
82
|
declares it as the universal iOS app icon. Xcode derives every other
|
|
83
83
|
size at build time.
|
pythonnative/project/ios.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Adapts the bundled ``ios_template`` Xcode project to a specific
|
|
4
4
|
[`AppConfig`][pythonnative.project.config.AppConfig]. Unlike Android, the
|
|
5
5
|
iOS bundle identifier, version, team, and deployment target are *not*
|
|
6
|
-
baked into files
|
|
6
|
+
baked into files; they're passed to ``xcodebuild`` as build-setting
|
|
7
7
|
overrides (see [`build_settings`][pythonnative.project.ios.build_settings]),
|
|
8
8
|
which avoids fragile ``project.pbxproj`` edits. This module owns the
|
|
9
9
|
parts that must live on disk:
|
|
@@ -23,7 +23,7 @@ artifacts it requires:
|
|
|
23
23
|
A capability's value may be either a string (used verbatim as the iOS
|
|
24
24
|
usage description) or ``true`` (use the capability's
|
|
25
25
|
[`default_reason`][pythonnative.project.permissions.Capability]). A value
|
|
26
|
-
of ``false`` disables the capability
|
|
26
|
+
of ``false`` disables the capability, useful for switching one off
|
|
27
27
|
without deleting the line.
|
|
28
28
|
|
|
29
29
|
The catalog is the single source of truth shared by the iOS and Android
|
|
@@ -284,7 +284,7 @@ def resolve_permissions(
|
|
|
284
284
|
"""Resolve a declared capability map into native permission artifacts.
|
|
285
285
|
|
|
286
286
|
Args:
|
|
287
|
-
permissions: The ``[permissions]`` table
|
|
287
|
+
permissions: The ``[permissions]`` table, capability key to a
|
|
288
288
|
reason string or boolean. ``false``/``None`` values are
|
|
289
289
|
skipped (capability disabled).
|
|
290
290
|
extra_android_permissions: Additional raw Android permission
|
|
@@ -9,8 +9,8 @@ paths the [`ios`][pythonnative.project.ios] configurator needs:
|
|
|
9
9
|
``Python.xcframework``, the simulator ``Python.framework``, the standard
|
|
10
10
|
library, and the simulator headers/static lib.
|
|
11
11
|
|
|
12
|
-
Android doesn't need any of this
|
|
13
|
-
Gradle
|
|
12
|
+
Android doesn't need any of this: Chaquopy ships its own CPython via
|
|
13
|
+
Gradle, so there's no Android equivalent here.
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
16
|
from __future__ import annotations
|
|
@@ -238,7 +238,7 @@ def prepare_ios_runtime(
|
|
|
238
238
|
try:
|
|
239
239
|
return _locate_runtime(extract_root, python_version)
|
|
240
240
|
except RuntimeError:
|
|
241
|
-
# Stale/partial extraction
|
|
241
|
+
# Stale/partial extraction: re-extract below.
|
|
242
242
|
pass
|
|
243
243
|
|
|
244
244
|
url = resolve_asset_url(python_version, preferred_name=preferred_name)
|
pythonnative/reconciler.py
CHANGED
|
@@ -9,7 +9,7 @@ The diff phase is *pure*: it never touches the native layer. Each pass
|
|
|
9
9
|
appends ops (`pythonnative.mutations`) to a transaction list, and the
|
|
10
10
|
commit applies them through a single
|
|
11
11
|
``backend.apply_mutations(ops)`` call. Event callbacks never cross into
|
|
12
|
-
the native layer at all
|
|
12
|
+
the native layer at all; they live in the Python-side
|
|
13
13
|
[`EventRegistry`][pythonnative.events.EventRegistry], keyed by tag, so
|
|
14
14
|
re-renders that only produce fresh closures cost nothing natively.
|
|
15
15
|
|
|
@@ -66,7 +66,7 @@ def _shallow_equal_props(old: dict, new: dict) -> bool:
|
|
|
66
66
|
|
|
67
67
|
Used by [`memo`][pythonnative.memo] to skip re-rendering when none
|
|
68
68
|
of a component's props changed identity. Callables only count as
|
|
69
|
-
equal if they're the *same object
|
|
69
|
+
equal if they're the *same object*; fresh closures always invalidate
|
|
70
70
|
the memo (matching React's behavior; pair with
|
|
71
71
|
[`use_callback`][pythonnative.use_callback] when stability matters).
|
|
72
72
|
"""
|
|
@@ -173,7 +173,7 @@ class VNode:
|
|
|
173
173
|
# node's props change, so unchanged leaves skip native
|
|
174
174
|
# ``measure_intrinsic`` calls entirely.
|
|
175
175
|
self._measure_cache: Optional[Tuple[float, float, float, float]] = None
|
|
176
|
-
# Last frame sent to the native side
|
|
176
|
+
# Last frame sent to the native side; frames that don't change
|
|
177
177
|
# are skipped (frame diffing).
|
|
178
178
|
self._last_frame: Optional[Tuple[float, float, float, float]] = None
|
|
179
179
|
# Cached LayoutNode reused across passes while the subtree is
|
|
@@ -444,8 +444,8 @@ class Reconciler:
|
|
|
444
444
|
Unlike a full reconcile from the root, a local update starts in
|
|
445
445
|
the *middle* of the tree, so the context stack of every
|
|
446
446
|
``__Provider__`` ancestor must be re-established before the body
|
|
447
|
-
runs (otherwise [`use_context`][pythonnative.use_context]
|
|
448
|
-
therefore [`use_navigation`][pythonnative.use_navigation]
|
|
447
|
+
runs (otherwise [`use_context`][pythonnative.use_context], and
|
|
448
|
+
therefore [`use_navigation`][pythonnative.use_navigation], would
|
|
449
449
|
read the context default instead of the provided value). Nested
|
|
450
450
|
providers *inside* this subtree are pushed/popped normally by the
|
|
451
451
|
recursive reconcile beneath us.
|
|
@@ -939,7 +939,7 @@ class Reconciler:
|
|
|
939
939
|
node.hook_state.cleanup_all_effects()
|
|
940
940
|
# Break the back-references so the unmounted component's hook
|
|
941
941
|
# state (and the closures it captured) can be freed by plain
|
|
942
|
-
# refcounting
|
|
942
|
+
# refcounting, important on iOS, where the cyclic GC is
|
|
943
943
|
# disabled.
|
|
944
944
|
node.hook_state._vnode = None
|
|
945
945
|
node.hook_state._reconciler = None
|
|
@@ -983,7 +983,7 @@ class Reconciler:
|
|
|
983
983
|
|
|
984
984
|
Event callables never appear here (they live in the event
|
|
985
985
|
registry), so listener identity churn produces no native
|
|
986
|
-
traffic
|
|
986
|
+
traffic; only the ``_pn_events`` name set is compared.
|
|
987
987
|
"""
|
|
988
988
|
changed = {}
|
|
989
989
|
for key, new_val in new.items():
|
|
@@ -1056,7 +1056,7 @@ class Reconciler:
|
|
|
1056
1056
|
"""Whether ``changed`` props can alter the node's layout.
|
|
1057
1057
|
|
|
1058
1058
|
Content-sized leaves re-measure on *any* prop change (text,
|
|
1059
|
-
font, image source
|
|
1059
|
+
font, image source: almost everything affects their intrinsic
|
|
1060
1060
|
size). Containers only care about the layout style keys.
|
|
1061
1061
|
"""
|
|
1062
1062
|
if type_name in cls._INTRINSIC_TYPES:
|
|
@@ -1110,7 +1110,7 @@ class Reconciler:
|
|
|
1110
1110
|
)
|
|
1111
1111
|
viewport.dirty = True
|
|
1112
1112
|
calculate_layout(viewport, viewport_w, viewport_h)
|
|
1113
|
-
# Skip set_frame for the root itself
|
|
1113
|
+
# Skip set_frame for the root itself; descendants are
|
|
1114
1114
|
# positioned relative to the root's local origin, which is
|
|
1115
1115
|
# what they want regardless of where the host placed the
|
|
1116
1116
|
# root in the screen.
|
pythonnative/runtime.py
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
PythonNative runs a single, framework-wide ``asyncio`` event loop on
|
|
4
4
|
a dedicated daemon thread. Every awaitable surface in the framework
|
|
5
|
-
|
|
5
|
+
schedules its work on this loop via
|
|
6
|
+
[`run_async`][pythonnative.runtime.run_async]: the async hooks
|
|
6
7
|
([`use_async_effect`][pythonnative.hooks.use_async_effect],
|
|
7
8
|
[`use_query`][pythonnative.hooks.use_query],
|
|
8
9
|
[`use_mutation`][pythonnative.hooks.use_mutation]), the
|
|
@@ -12,8 +13,7 @@ native modules
|
|
|
12
13
|
([`Camera`][pythonnative.native_modules.camera.Camera] /
|
|
13
14
|
[`Location`][pythonnative.native_modules.location.Location] /
|
|
14
15
|
[`Notifications`][pythonnative.native_modules.notifications.Notifications]),
|
|
15
|
-
and awaited animations
|
|
16
|
-
[`run_async`][pythonnative.runtime.run_async].
|
|
16
|
+
and awaited animations.
|
|
17
17
|
|
|
18
18
|
The reconciler is **not** asyncio-aware; it still runs synchronously on
|
|
19
19
|
the platform main thread. Coroutines that want to mutate component
|
|
@@ -66,7 +66,7 @@ _lock = threading.Lock()
|
|
|
66
66
|
# ======================================================================
|
|
67
67
|
#
|
|
68
68
|
# By default ``threading.Thread`` lands at a low QoS class on Apple
|
|
69
|
-
# platforms, which iOS subjects to wake-up coalescing
|
|
69
|
+
# platforms, which iOS subjects to wake-up coalescing; the asyncio
|
|
70
70
|
# loop only gets ~2 timeslices per second (~500ms granularity). Bumping
|
|
71
71
|
# the thread to ``QOS_CLASS_USER_INTERACTIVE`` is *necessary* for it to
|
|
72
72
|
# be treated as foreground work, but on the simulator it's not
|
|
@@ -85,7 +85,7 @@ def _apply_apple_thread_qos() -> None:
|
|
|
85
85
|
No-op on other platforms or if the symbol can't be loaded. Must be
|
|
86
86
|
called from inside the target thread (the underlying syscall is
|
|
87
87
|
``pthread_set_qos_class_self_np``). Empirically ``USER_INTERACTIVE``
|
|
88
|
-
is required on iOS
|
|
88
|
+
is required on iOS; anything lower triggers wake-up coalescing on
|
|
89
89
|
the background asyncio thread, which adds ~500ms latency to every
|
|
90
90
|
cross-thread dispatch.
|
|
91
91
|
"""
|
|
@@ -151,7 +151,7 @@ def _shutdown_for_tests() -> None:
|
|
|
151
151
|
Cancels every pending task, stops the loop, joins the thread, and
|
|
152
152
|
clears the module-level state so the next call to
|
|
153
153
|
[`get_loop`][pythonnative.runtime.get_loop] starts a fresh loop.
|
|
154
|
-
Production code should not call this
|
|
154
|
+
Production code should not call this; the loop is a daemon and
|
|
155
155
|
will be torn down with the process.
|
|
156
156
|
"""
|
|
157
157
|
global _loop, _thread
|
|
@@ -316,7 +316,7 @@ def call_on_main_thread(fn: Callable[[], None]) -> None:
|
|
|
316
316
|
|
|
317
317
|
- **iOS**: dispatches ``fn`` onto the main dispatch queue via
|
|
318
318
|
``libdispatch.dispatch_async_f`` (called through
|
|
319
|
-
:class:`ctypes.PyDLL` to keep the GIL held
|
|
319
|
+
:class:`ctypes.PyDLL` to keep the GIL held; see the
|
|
320
320
|
``_ios_call_on_main`` comment block for why this matters).
|
|
321
321
|
- **Android**: posts a ``Runnable`` to
|
|
322
322
|
``Handler(Looper.getMainLooper())``.
|
|
@@ -469,7 +469,7 @@ def _ios_call_on_main(fn: Callable[[], None]) -> None:
|
|
|
469
469
|
_main_next_id += 1
|
|
470
470
|
key = _main_next_id
|
|
471
471
|
_main_pending[key] = fn
|
|
472
|
-
# dispatch_async_f(queue, context, work)
|
|
472
|
+
# dispatch_async_f(queue, context, work): non-blocking; just
|
|
473
473
|
# enqueues the work onto the main queue and returns.
|
|
474
474
|
assert _dispatch_async_f_c is not None
|
|
475
475
|
_dispatch_async_f_c(_dispatch_main_q_ptr, key, _main_trampoline_c)
|
pythonnative/screen.py
CHANGED
|
@@ -186,7 +186,7 @@ def _init_host_common(host: Any, component_path: str, component_func: Any) -> No
|
|
|
186
186
|
host._hot_reload_manifest_path = None
|
|
187
187
|
host._hot_reload_last_version = None
|
|
188
188
|
host._layout_listener = None # retained on Android to prevent GC
|
|
189
|
-
# Focus state
|
|
189
|
+
# Focus state: drives ``use_focus_effect``. Starts focused because
|
|
190
190
|
# a host is only created when the screen is being presented; the
|
|
191
191
|
# platform lifecycle hooks (``on_resume`` / ``on_pause``) flip this
|
|
192
192
|
# when the user navigates to / from another screen.
|
|
@@ -276,14 +276,14 @@ def _on_create(host: Any) -> None:
|
|
|
276
276
|
# Android the FragmentManager destroys and recreates a screen's
|
|
277
277
|
# view every time the user pops back to it, and the platform
|
|
278
278
|
# template calls ``screen.on_create()`` again from
|
|
279
|
-
# ``onViewCreated
|
|
279
|
+
# ``onViewCreated``, but the Python screen object (and therefore
|
|
280
280
|
# the reconciler, hook state, focus subscribers, etc.) persists
|
|
281
281
|
# across that. Re-running the full mount path here would reset
|
|
282
282
|
# use_state, clobber use_focus_effect subscriptions, and break
|
|
283
283
|
# navigation handles held by existing components, which is why
|
|
284
284
|
# the focus counter never advanced past ``1`` before this guard.
|
|
285
285
|
# If we're already mounted, just re-attach the existing root view
|
|
286
|
-
# to the (newly created) native container
|
|
286
|
+
# to the (newly created) native container; ``on_resume`` will
|
|
287
287
|
# fire the focus subscribers separately.
|
|
288
288
|
if host._reconciler is not None and host._root_native_view is not None:
|
|
289
289
|
host._attach_root(host._root_native_view)
|
|
@@ -678,6 +678,65 @@ if IS_ANDROID:
|
|
|
678
678
|
bottom_px / density,
|
|
679
679
|
right_px / density,
|
|
680
680
|
)
|
|
681
|
+
# IME (soft keyboard) insets: API 30+ exposes them through
|
|
682
|
+
# the typed getInsets API. The keyboard overlaps the system
|
|
683
|
+
# navigation bar, so the visible keyboard height is the IME
|
|
684
|
+
# inset minus the permanent bottom bar inset. Older API
|
|
685
|
+
# levels don't report IME insets here; those devices rely
|
|
686
|
+
# on ``adjustResize`` shrinking the window (the viewport
|
|
687
|
+
# push handles the layout) and the height stays 0.
|
|
688
|
+
try:
|
|
689
|
+
ime = insets_obj.getInsets(Type.ime())
|
|
690
|
+
ime_px = max(0, int(ime.bottom) - bottom_px)
|
|
691
|
+
platform_metrics.set_keyboard_height(ime_px / density)
|
|
692
|
+
except Exception:
|
|
693
|
+
pass
|
|
694
|
+
except Exception:
|
|
695
|
+
pass
|
|
696
|
+
|
|
697
|
+
def _android_publish_color_scheme(activity: Any) -> None:
|
|
698
|
+
"""Read the system night mode from the activity and publish it.
|
|
699
|
+
|
|
700
|
+
Android delivers appearance changes as a configuration change,
|
|
701
|
+
which recreates the activity by default, so publishing on
|
|
702
|
+
``on_create`` / ``on_resume`` covers both the initial value and
|
|
703
|
+
subsequent flips.
|
|
704
|
+
"""
|
|
705
|
+
try:
|
|
706
|
+
from . import appearance
|
|
707
|
+
|
|
708
|
+
Configuration = jclass("android.content.res.Configuration")
|
|
709
|
+
ui_mode = int(activity.getResources().getConfiguration().uiMode)
|
|
710
|
+
night = ui_mode & int(Configuration.UI_MODE_NIGHT_MASK)
|
|
711
|
+
is_dark = night == int(Configuration.UI_MODE_NIGHT_YES)
|
|
712
|
+
appearance.set_system_color_scheme("dark" if is_dark else "light")
|
|
713
|
+
except Exception:
|
|
714
|
+
pass
|
|
715
|
+
|
|
716
|
+
def _android_register_insets_listener(host: Any, view: Any) -> None:
|
|
717
|
+
"""Re-publish insets whenever the window's insets change.
|
|
718
|
+
|
|
719
|
+
The ``OnLayoutChangeListener`` in ``_android_register_layout_listener``
|
|
720
|
+
only fires when the container is re-measured, which covers
|
|
721
|
+
``adjustResize`` keyboards but not inset-only changes (e.g. an
|
|
722
|
+
edge-to-edge window where the IME floats over the content).
|
|
723
|
+
``setOnApplyWindowInsetsListener`` fires on every insets pass,
|
|
724
|
+
so keyboard show/hide reaches ``platform_metrics`` immediately.
|
|
725
|
+
"""
|
|
726
|
+
try:
|
|
727
|
+
View = jclass("android.view.View")
|
|
728
|
+
|
|
729
|
+
class _PNInsetsListener(dynamic_proxy(View.OnApplyWindowInsetsListener)): # type: ignore[misc]
|
|
730
|
+
def onApplyWindowInsets(self, v: Any, insets: Any) -> Any:
|
|
731
|
+
try:
|
|
732
|
+
_android_publish_window_insets(v)
|
|
733
|
+
except Exception:
|
|
734
|
+
pass
|
|
735
|
+
return insets
|
|
736
|
+
|
|
737
|
+
listener = _PNInsetsListener()
|
|
738
|
+
view.setOnApplyWindowInsetsListener(listener)
|
|
739
|
+
host._insets_listener = listener # retain to prevent GC
|
|
681
740
|
except Exception:
|
|
682
741
|
pass
|
|
683
742
|
|
|
@@ -728,7 +787,7 @@ if IS_ANDROID:
|
|
|
728
787
|
# Publish insets first so the very first layout pass sees
|
|
729
788
|
# them. Otherwise handlers reading insets at first paint
|
|
730
789
|
# would get ``(0, 0, 0, 0)`` and re-measure once the
|
|
731
|
-
# ``OnLayoutChangeListener`` fires moments later
|
|
790
|
+
# ``OnLayoutChangeListener`` fires moments later, a
|
|
732
791
|
# measurable flicker (~50–200 ms on a stock Pixel
|
|
733
792
|
# emulator).
|
|
734
793
|
_android_publish_window_insets(view)
|
|
@@ -761,12 +820,14 @@ if IS_ANDROID:
|
|
|
761
820
|
_init_host_common(self, component_path, component_func)
|
|
762
821
|
|
|
763
822
|
def on_create(self) -> None:
|
|
823
|
+
_android_publish_color_scheme(self.native_instance)
|
|
764
824
|
_on_create(self)
|
|
765
825
|
|
|
766
826
|
def on_start(self) -> None:
|
|
767
827
|
pass
|
|
768
828
|
|
|
769
829
|
def on_resume(self) -> None:
|
|
830
|
+
_android_publish_color_scheme(self.native_instance)
|
|
770
831
|
_set_host_focused(self, True)
|
|
771
832
|
|
|
772
833
|
def on_layout(self) -> None:
|
|
@@ -873,6 +934,7 @@ if IS_ANDROID:
|
|
|
873
934
|
|
|
874
935
|
if container is not None:
|
|
875
936
|
_android_register_layout_listener(self, container)
|
|
937
|
+
_android_register_insets_listener(self, container)
|
|
876
938
|
_android_push_initial_viewport(self, container)
|
|
877
939
|
|
|
878
940
|
def _detach_root(self, native_view: Any) -> None:
|
|
@@ -1089,7 +1151,7 @@ else:
|
|
|
1089
1151
|
the concrete type varies between releases:
|
|
1090
1152
|
|
|
1091
1153
|
- On rubicon-objc 0.5.x ``ptr`` is ``bytes`` (the raw 8-byte,
|
|
1092
|
-
little-endian address)
|
|
1154
|
+
little-endian address); ``int(ptr)`` raises ``ValueError``
|
|
1093
1155
|
because Python tries to parse the bytes as a decimal string.
|
|
1094
1156
|
- Older releases return a ``c_void_p`` for which ``int(ptr)``
|
|
1095
1157
|
works.
|
|
@@ -1128,6 +1190,24 @@ else:
|
|
|
1128
1190
|
except Exception:
|
|
1129
1191
|
pass
|
|
1130
1192
|
|
|
1193
|
+
def _ios_publish_color_scheme() -> None:
|
|
1194
|
+
"""Read the current UIKit interface style and publish it.
|
|
1195
|
+
|
|
1196
|
+
``UITraitCollection.currentTraitCollection`` reflects the
|
|
1197
|
+
active appearance; ``userInterfaceStyle`` is 1 for light and
|
|
1198
|
+
2 for dark (0 = unspecified, treated as light). Called from
|
|
1199
|
+
the lifecycle callbacks that fire on appearance flips
|
|
1200
|
+
(``viewDidLayoutSubviews`` / ``viewDidAppear``).
|
|
1201
|
+
"""
|
|
1202
|
+
try:
|
|
1203
|
+
from . import appearance
|
|
1204
|
+
|
|
1205
|
+
UITraitCollection = ObjCClass("UITraitCollection")
|
|
1206
|
+
style_value = int(UITraitCollection.currentTraitCollection.userInterfaceStyle)
|
|
1207
|
+
appearance.set_system_color_scheme("dark" if style_value == 2 else "light")
|
|
1208
|
+
except Exception:
|
|
1209
|
+
pass
|
|
1210
|
+
|
|
1131
1211
|
def _ios_register_screen(vc_instance: Any, host_obj: Any) -> None:
|
|
1132
1212
|
ptr = _objc_addr(vc_instance)
|
|
1133
1213
|
if ptr is None:
|
|
@@ -1273,6 +1353,7 @@ else:
|
|
|
1273
1353
|
_ios_register_screen(self.native_instance, self)
|
|
1274
1354
|
|
|
1275
1355
|
def on_create(self) -> None:
|
|
1356
|
+
_ios_publish_color_scheme()
|
|
1276
1357
|
_on_create(self)
|
|
1277
1358
|
|
|
1278
1359
|
def on_start(self) -> None:
|
|
@@ -1483,6 +1564,9 @@ else:
|
|
|
1483
1564
|
# insets are now valid (initial layout, rotation,
|
|
1484
1565
|
# multitasking, …). Re-sync the root frame and push the
|
|
1485
1566
|
# viewport so the layout engine matches the visible area.
|
|
1567
|
+
# Appearance flips also trigger a layout pass, so the
|
|
1568
|
+
# color scheme is re-published here too.
|
|
1569
|
+
_ios_publish_color_scheme()
|
|
1486
1570
|
if self._root_native_view is None:
|
|
1487
1571
|
_log_pn("on_layout: no root_native_view yet, skipping")
|
|
1488
1572
|
return
|
|
@@ -1494,6 +1578,7 @@ else:
|
|
|
1494
1578
|
# ``viewDidAppear`` always follows ``viewDidLayoutSubviews``,
|
|
1495
1579
|
# but trigger one extra sync here for safety in case a
|
|
1496
1580
|
# template overrides the layout call without forwarding.
|
|
1581
|
+
_ios_publish_color_scheme()
|
|
1497
1582
|
_set_host_focused(self, True)
|
|
1498
1583
|
if self._root_native_view is None:
|
|
1499
1584
|
_log_pn("on_resume: no root_native_view yet, skipping")
|
pythonnative/sdk/_components.py
CHANGED
|
@@ -361,7 +361,7 @@ def element_factory(name: str) -> Callable[..., Element]:
|
|
|
361
361
|
arguments matching the registered props dataclass.
|
|
362
362
|
|
|
363
363
|
If no ``props`` dataclass was registered for ``name``, kwargs flow
|
|
364
|
-
through unmodified
|
|
364
|
+
through unmodified, useful when iterating before locking down a
|
|
365
365
|
prop schema.
|
|
366
366
|
|
|
367
367
|
Args:
|