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.
Files changed (53) hide show
  1. pythonnative/__init__.py +16 -2
  2. pythonnative/animated.py +6 -6
  3. pythonnative/appearance.py +124 -0
  4. pythonnative/cli/pn.py +1 -1
  5. pythonnative/components.py +718 -48
  6. pythonnative/events.py +5 -5
  7. pythonnative/gestures.py +3 -3
  8. pythonnative/hooks.py +44 -3
  9. pythonnative/hot_reload.py +4 -4
  10. pythonnative/images.py +196 -0
  11. pythonnative/layout.py +3 -3
  12. pythonnative/mutations.py +4 -13
  13. pythonnative/native_modules/camera.py +1 -1
  14. pythonnative/native_modules/haptics.py +2 -2
  15. pythonnative/native_modules/location.py +1 -1
  16. pythonnative/native_modules/permissions.py +2 -2
  17. pythonnative/native_modules/secure_store.py +1 -1
  18. pythonnative/native_views/__init__.py +1 -7
  19. pythonnative/native_views/android.py +592 -64
  20. pythonnative/native_views/base.py +3 -3
  21. pythonnative/native_views/desktop.py +85 -26
  22. pythonnative/native_views/ios.py +762 -74
  23. pythonnative/navigation.py +4 -4
  24. pythonnative/net.py +3 -3
  25. pythonnative/platform.py +1 -1
  26. pythonnative/platform_metrics.py +5 -5
  27. pythonnative/preview.py +34 -3
  28. pythonnative/project/builder.py +1 -1
  29. pythonnative/project/config.py +4 -12
  30. pythonnative/project/doctor.py +2 -2
  31. pythonnative/project/icons.py +1 -1
  32. pythonnative/project/ios.py +1 -1
  33. pythonnative/project/permissions.py +2 -2
  34. pythonnative/project/runtime_assets.py +3 -3
  35. pythonnative/reconciler.py +9 -9
  36. pythonnative/runtime.py +8 -8
  37. pythonnative/screen.py +90 -5
  38. pythonnative/sdk/_components.py +1 -1
  39. pythonnative/storage.py +3 -3
  40. pythonnative/style.py +66 -7
  41. pythonnative/templates/android_template/app/src/main/AndroidManifest.xml +2 -1
  42. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNAccessibilityDelegate.kt +47 -0
  43. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNBorderDrawable.kt +67 -0
  44. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNVirtualListView.java +172 -0
  45. pythonnative/templates/ios_template/ios_template.xcodeproj/project.pbxproj +2 -2
  46. pythonnative/templates/ios_template/ios_templateUITests/ios_templateUITests.swift +1 -1
  47. pythonnative/virtual_rows.py +137 -0
  48. {pythonnative-0.22.0.dist-info → pythonnative-0.23.0.dist-info}/METADATA +13 -13
  49. {pythonnative-0.22.0.dist-info → pythonnative-0.23.0.dist-info}/RECORD +53 -47
  50. {pythonnative-0.22.0.dist-info → pythonnative-0.23.0.dist-info}/WHEEL +1 -1
  51. {pythonnative-0.22.0.dist-info → pythonnative-0.23.0.dist-info}/entry_points.txt +0 -0
  52. {pythonnative-0.22.0.dist-info → pythonnative-0.23.0.dist-info}/licenses/LICENSE +0 -0
  53. {pythonnative-0.22.0.dist-info → pythonnative-0.23.0.dist-info}/top_level.txt +0 -0
@@ -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 there is no second native navigation controller to push
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 the host's own
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 the
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 the parent reconciler re-renders the active
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 it covers the
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.) re-raised from ``urllib``.
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 propagate the body
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 *not* desktop,
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
 
@@ -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 multiple subscribers may register/unregister
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)`` handlers should still
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 both pieces conspire to require a single source of
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 the tab bar absorbs the inset and UIKit
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 the engine behind ``pn preview``.
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 mirroring the role a ``UIViewController`` / ``Activity``
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} {int(width)}x{int(height)}", file=sys.stderr)
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:
@@ -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), andon iOSembeds the CPython runtime into
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
@@ -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 one of
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
- legacy = root / "pythonnative.json"
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://docs.pythonnative.com/guide/configuration/
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://docs.pythonnative.com/guide/permissions/
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."
@@ -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" {self.detail}" if self.detail else ""
47
+ suffix = f": {self.detail}" if self.detail else ""
48
48
  return f" {symbol} {self.name}{suffix}"
49
49
 
50
50
 
@@ -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 the
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.
@@ -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 they're passed to ``xcodebuild`` as build-setting
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 useful for switching one off
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 capability key to a
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 Chaquopy ships its own CPython via
13
- Gradle so there's no Android equivalent here.
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 re-extract below.
241
+ # Stale/partial extraction: re-extract below.
242
242
  pass
243
243
 
244
244
  url = resolve_asset_url(python_version, preferred_name=preferred_name)
@@ -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 they live in the Python-side
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* fresh closures always invalidate
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 frames that don't change
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] and
448
- therefore [`use_navigation`][pythonnative.use_navigation] would
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 important on iOS, where the cyclic GC is
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 only the ``_pn_events`` name set is compared.
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 almost everything affects their intrinsic
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 descendants are
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
- the async hooks
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 — schedules its work on this loop via
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 the asyncio
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 anything lower triggers wake-up coalescing on
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 the loop is a daemon and
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 see the
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) non-blocking; just
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 drives ``use_focus_effect``. Starts focused because
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`` but the Python screen object (and therefore
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 ``on_resume`` will
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 a
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) ``int(ptr)`` raises ``ValueError``
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")
@@ -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 useful when iterating before locking down a
364
+ through unmodified, useful when iterating before locking down a
365
365
  prop schema.
366
366
 
367
367
  Args: