pythonnative 0.25.0__py3-none-any.whl → 0.27.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 (50) hide show
  1. pythonnative/__init__.py +1 -1
  2. pythonnative/animated.py +697 -62
  3. pythonnative/cli/pn.py +172 -12
  4. pythonnative/components.py +61 -7
  5. pythonnative/diagnostics.py +27 -0
  6. pythonnative/gestures.py +446 -34
  7. pythonnative/native_modules/app_state.py +2 -1
  8. pythonnative/native_modules/battery.py +3 -2
  9. pythonnative/native_modules/biometrics.py +2 -1
  10. pythonnative/native_modules/camera.py +16 -7
  11. pythonnative/native_modules/clipboard.py +3 -2
  12. pythonnative/native_modules/file_system.py +3 -2
  13. pythonnative/native_modules/haptics.py +7 -6
  14. pythonnative/native_modules/linking.py +62 -6
  15. pythonnative/native_modules/location.py +11 -8
  16. pythonnative/native_modules/net_info.py +75 -5
  17. pythonnative/native_modules/notifications.py +134 -15
  18. pythonnative/native_modules/permissions.py +3 -2
  19. pythonnative/native_modules/share.py +2 -1
  20. pythonnative/native_views/android.py +461 -137
  21. pythonnative/native_views/desktop.py +816 -96
  22. pythonnative/native_views/ios.py +750 -193
  23. pythonnative/project/android.py +15 -3
  24. pythonnative/project/builder.py +172 -70
  25. pythonnative/project/config.py +28 -6
  26. pythonnative/project/devices.py +291 -0
  27. pythonnative/project/doctor.py +5 -4
  28. pythonnative/project/ios.py +51 -77
  29. pythonnative/project/permissions.py +19 -1
  30. pythonnative/project/runtime_assets.py +78 -166
  31. pythonnative/reconciler.py +28 -0
  32. pythonnative/style.py +19 -0
  33. pythonnative/templates/android_template/app/build.gradle +9 -0
  34. pythonnative/templates/android_template/app/src/main/AndroidManifest.xml +5 -0
  35. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt +148 -12
  36. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNFrameLayout.kt +50 -0
  37. pythonnative/templates/ios_template/ios_template/AppDelegate.swift +41 -17
  38. pythonnative/templates/ios_template/ios_template/BridgingHeader.h +14 -0
  39. pythonnative/templates/ios_template/ios_template/Info.plist +5 -2
  40. pythonnative/templates/ios_template/ios_template/PythonRuntime.swift +397 -0
  41. pythonnative/templates/ios_template/ios_template/SceneDelegate.swift +31 -20
  42. pythonnative/templates/ios_template/ios_template/ViewController.swift +77 -198
  43. pythonnative/templates/ios_template/ios_template.xcodeproj/project.pbxproj +72 -47
  44. {pythonnative-0.25.0.dist-info → pythonnative-0.27.0.dist-info}/METADATA +3 -3
  45. {pythonnative-0.25.0.dist-info → pythonnative-0.27.0.dist-info}/RECORD +49 -46
  46. pythonnative/templates/ios_template/ios_template/Base.lproj/Main.storyboard +0 -24
  47. {pythonnative-0.25.0.dist-info → pythonnative-0.27.0.dist-info}/WHEEL +0 -0
  48. {pythonnative-0.25.0.dist-info → pythonnative-0.27.0.dist-info}/entry_points.txt +0 -0
  49. {pythonnative-0.25.0.dist-info → pythonnative-0.27.0.dist-info}/licenses/LICENSE +0 -0
  50. {pythonnative-0.25.0.dist-info → pythonnative-0.27.0.dist-info}/top_level.txt +0 -0
pythonnative/cli/pn.py CHANGED
@@ -6,10 +6,14 @@ The console script `pn` (declared in `pyproject.toml`) dispatches to:
6
6
  - `pn doctor [platform]`: diagnose the local toolchain and config.
7
7
  - `pn preview [component]`: render the app in a desktop (Tkinter) window
8
8
  with Fast Refresh, the fast inner dev loop, no device required.
9
- - `pn run android|ios`: stage + build + install + launch on a device or
10
- simulator, with optional on-device hot reload.
9
+ - `pn devices [platform]`: list connected devices, emulators, and
10
+ simulators.
11
+ - `pn run android|ios [--device D]`: stage + build + install + launch on
12
+ a device, emulator, or simulator, with optional on-device hot reload.
13
+ - `pn logs android|ios`: stream logs from the running app without
14
+ rebuilding.
11
15
  - `pn build android|ios`: produce standalone artifacts (signed APK/AAB,
12
- or an iOS archive/IPA).
16
+ or an iOS archive/IPA, optionally uploaded to App Store Connect).
13
17
  - `pn app-id android|ios`: print the resolved application/bundle id
14
18
  (handy for scripts and CI).
15
19
  - `pn clean`: remove the local `build/` directory.
@@ -34,6 +38,7 @@ from pathlib import Path
34
38
  from typing import Any, Dict, List, Optional
35
39
 
36
40
  from ..project import builder as builder_mod
41
+ from ..project import devices as devices_mod
37
42
  from ..project import doctor as doctor_mod
38
43
  from ..project.android import collect_logcat_filters
39
44
  from ..project.config import CONFIG_FILENAME, AppConfig, ConfigError, render_default_toml
@@ -241,6 +246,41 @@ def _preview_entry(project_dir: Path) -> str:
241
246
  return "app.main"
242
247
 
243
248
 
249
+ # ======================================================================
250
+ # devices
251
+ # ======================================================================
252
+
253
+
254
+ def devices_command(args: argparse.Namespace) -> None:
255
+ """List connected devices, emulators, and simulators.
256
+
257
+ Args:
258
+ args: Parsed namespace with optional ``platform``.
259
+ """
260
+ platform: Optional[str] = getattr(args, "platform", None)
261
+ devices = devices_mod.list_devices(platform)
262
+ if not devices:
263
+ print("No devices found.")
264
+ print("Android: start an emulator or connect a device with USB debugging enabled.")
265
+ print("iOS: open Xcode once to install Simulators, or plug in a device.")
266
+ sys.exit(1)
267
+ print(f" {'IDENTIFIER':<40} {'KIND':<10} {'STATE':<10} NAME")
268
+ for device in devices:
269
+ print(device.format())
270
+ print("\nTarget one with: pn run <platform> --device <identifier or name>")
271
+
272
+
273
+ def _resolve_device(platform: str, query: Optional[str]) -> Optional[devices_mod.Device]:
274
+ """Resolve ``--device`` to a concrete target, exiting on a bad query."""
275
+ if not query:
276
+ return None
277
+ device = devices_mod.find_device(devices_mod.list_devices(platform), query)
278
+ if device is None:
279
+ print(f"Error: no {platform} device matches {query!r}. Run 'pn devices {platform}' to list targets.")
280
+ sys.exit(1)
281
+ return device
282
+
283
+
244
284
  # ======================================================================
245
285
  # run
246
286
  # ======================================================================
@@ -250,13 +290,14 @@ def run_project(args: argparse.Namespace) -> None:
250
290
  """Stage, build, install, and launch the app on a device/simulator.
251
291
 
252
292
  Args:
253
- args: Parsed namespace (``platform``, ``prepare_only``,
254
- ``hot_reload``, ``no_logs``).
293
+ args: Parsed namespace (``platform``, ``device``,
294
+ ``prepare_only``, ``hot_reload``, ``no_logs``).
255
295
  """
256
296
  platform: str = args.platform
257
297
  prepare_only: bool = getattr(args, "prepare_only", False)
258
298
  hot_reload: bool = getattr(args, "hot_reload", False)
259
299
  show_logs: bool = not getattr(args, "no_logs", False)
300
+ device = _resolve_device(platform, getattr(args, "device", None))
260
301
 
261
302
  config = _load_config_or_exit()
262
303
  builder = builder_mod.Builder(config, log=print)
@@ -273,9 +314,14 @@ def run_project(args: argparse.Namespace) -> None:
273
314
 
274
315
  try:
275
316
  if platform == "android":
276
- _run_android(builder, prepared, hot_reload=hot_reload, show_logs=show_logs)
317
+ _run_android(builder, prepared, device=device, hot_reload=hot_reload, show_logs=show_logs)
318
+ elif device is not None and device.kind == "device":
319
+ _run_ios_device(builder, prepared, device=device)
320
+ if hot_reload:
321
+ print("Note: hot reload targets the iOS Simulator; skipping it for a physical device.")
322
+ return
277
323
  else:
278
- _run_ios(builder, prepared, hot_reload=hot_reload, show_logs=show_logs)
324
+ _run_ios(builder, prepared, device=device, hot_reload=hot_reload, show_logs=show_logs)
279
325
  except builder_mod.BuildError as exc:
280
326
  print(f"Error: {exc}")
281
327
  sys.exit(1)
@@ -295,9 +341,14 @@ def _run_android(
295
341
  builder: builder_mod.Builder,
296
342
  prepared: builder_mod.PreparedProject,
297
343
  *,
344
+ device: Optional[devices_mod.Device],
298
345
  hot_reload: bool,
299
346
  show_logs: bool,
300
347
  ) -> None:
348
+ if device is not None:
349
+ # Both Gradle's install task and every adb call below honor
350
+ # ANDROID_SERIAL, so exporting it targets the whole run.
351
+ os.environ["ANDROID_SERIAL"] = device.identifier
301
352
  builder.install_android_debug(prepared)
302
353
  _clear_android_hot_reload_overlay(prepared.app_id)
303
354
  subprocess.run(
@@ -319,11 +370,12 @@ def _run_ios(
319
370
  builder: builder_mod.Builder,
320
371
  prepared: builder_mod.PreparedProject,
321
372
  *,
373
+ device: Optional[devices_mod.Device],
322
374
  hot_reload: bool,
323
375
  show_logs: bool,
324
376
  ) -> None:
325
377
  app_path = builder.build_ios_simulator(prepared)
326
- udid = _select_ios_simulator()
378
+ udid = device.identifier if device is not None else _select_ios_simulator()
327
379
  if udid is None:
328
380
  print("No available iOS Simulators found; open the project in Xcode to run.")
329
381
  return
@@ -352,6 +404,45 @@ def _run_ios(
352
404
  print("Launched iOS app on Simulator.")
353
405
 
354
406
 
407
+ def _run_ios_device(
408
+ builder: builder_mod.Builder,
409
+ prepared: builder_mod.PreparedProject,
410
+ *,
411
+ device: devices_mod.Device,
412
+ ) -> None:
413
+ """Build, install, and launch on a physical iOS device via devicectl."""
414
+ app_path = builder.build_ios_device(prepared)
415
+ print(f"Installing on {device.name}...")
416
+ install = subprocess.run(
417
+ ["xcrun", "devicectl", "device", "install", "app", "--device", device.identifier, str(app_path)],
418
+ check=False,
419
+ )
420
+ if install.returncode != 0:
421
+ print(
422
+ "Error: install failed. Make sure the device is unlocked, paired with this Mac, "
423
+ "and has Developer Mode enabled (Settings > Privacy & Security > Developer Mode)."
424
+ )
425
+ sys.exit(1)
426
+ launch = subprocess.run(
427
+ [
428
+ "xcrun",
429
+ "devicectl",
430
+ "device",
431
+ "process",
432
+ "launch",
433
+ "--terminate-existing",
434
+ "--device",
435
+ device.identifier,
436
+ prepared.app_id,
437
+ ],
438
+ check=False,
439
+ )
440
+ if launch.returncode != 0:
441
+ print("Error: launch failed. Launch the app from the home screen to see details.")
442
+ sys.exit(1)
443
+ print(f"Launched on {device.name}. Use Console.app (or Xcode > Devices) for device logs.")
444
+
445
+
355
446
  # ======================================================================
356
447
  # build
357
448
  # ======================================================================
@@ -361,16 +452,24 @@ def build_project(args: argparse.Namespace) -> None:
361
452
  """Build standalone, distributable artifacts for ``platform``.
362
453
 
363
454
  Args:
364
- args: Parsed namespace (``platform``, ``debug``).
455
+ args: Parsed namespace (``platform``, ``debug``, ``upload``).
365
456
  """
366
457
  platform: str = args.platform
367
458
  debug: bool = getattr(args, "debug", False)
459
+ upload: bool = getattr(args, "upload", False)
368
460
 
369
461
  config = _load_config_or_exit()
370
462
  builder = builder_mod.Builder(config, log=print)
371
463
 
464
+ if upload and (platform != "ios" or debug):
465
+ print("Error: --upload applies to 'pn build ios' release builds only.")
466
+ sys.exit(1)
467
+ if upload and config.ios.signing.export_method != "app-store":
468
+ print('Error: --upload requires [ios.signing] export_method = "app-store" in pythonnative.toml.')
469
+ sys.exit(1)
470
+
372
471
  try:
373
- prepared = builder.prepare(platform)
472
+ prepared = builder.prepare(platform, release=not debug)
374
473
  if platform == "android":
375
474
  artifacts = builder.build_android(prepared, debug=debug)
376
475
  else:
@@ -378,19 +477,61 @@ def build_project(args: argparse.Namespace) -> None:
378
477
  app_path = builder.build_ios_simulator(prepared)
379
478
  artifacts = builder_mod.BuildArtifacts(paths=[app_path])
380
479
  else:
381
- artifacts = builder.build_ios_archive(prepared)
480
+ artifacts = builder.build_ios_archive(prepared, upload=upload)
382
481
  except builder_mod.BuildError as exc:
383
482
  print(f"Error: {exc}")
384
483
  sys.exit(1)
385
484
 
485
+ if upload:
486
+ print("\nUploaded to App Store Connect. Check the build's status at appstoreconnect.apple.com.")
386
487
  if not artifacts.paths:
387
- print("Build completed, but no artifacts were found. Check the build output above.")
488
+ if not upload:
489
+ print("Build completed, but no artifacts were found. Check the build output above.")
388
490
  return
389
491
  print("\nBuilt artifacts:")
390
492
  for path in artifacts.paths:
391
493
  print(f" {path}")
392
494
 
393
495
 
496
+ # ======================================================================
497
+ # logs
498
+ # ======================================================================
499
+
500
+
501
+ def logs_command(args: argparse.Namespace) -> None:
502
+ """Stream logs from the running app without rebuilding.
503
+
504
+ Args:
505
+ args: Parsed namespace (``platform``).
506
+ """
507
+ platform: str = args.platform
508
+ if platform == "android":
509
+ proc = _start_android_log_stream()
510
+ if proc is None:
511
+ sys.exit(1)
512
+ try:
513
+ proc.wait()
514
+ except KeyboardInterrupt:
515
+ print()
516
+ _terminate_subprocess(proc)
517
+ print("Stopped log streaming.")
518
+ return
519
+
520
+ # iOS: relaunch the app on the booted simulator with a console PTY so
521
+ # Python's stdout/stderr stream to this terminal.
522
+ config = _load_config_or_exit()
523
+ proc = _start_ios_log_stream(config.bundle_id)
524
+ if proc is None:
525
+ print("For a physical device, use Console.app or Xcode > Devices and Simulators.")
526
+ sys.exit(1)
527
+ try:
528
+ proc.wait()
529
+ except KeyboardInterrupt:
530
+ print()
531
+ _terminate_subprocess(proc)
532
+ print("Stopped log streaming.")
533
+
534
+
394
535
  # ======================================================================
395
536
  # clean
396
537
  # ======================================================================
@@ -747,16 +888,35 @@ def _build_parser() -> argparse.ArgumentParser:
747
888
  parser_preview.add_argument("--no-hot-reload", action="store_true", help="Disable file watching / Fast Refresh")
748
889
  parser_preview.set_defaults(func=preview_project)
749
890
 
891
+ parser_devices = subparsers.add_parser("devices", help="List devices, emulators, and simulators")
892
+ parser_devices.add_argument("platform", nargs="?", choices=["android", "ios"], help="Restrict to a platform")
893
+ parser_devices.set_defaults(func=devices_command)
894
+
750
895
  parser_run = subparsers.add_parser("run", help="Build, install, and launch on a device/simulator")
751
896
  parser_run.add_argument("platform", choices=["android", "ios"])
897
+ parser_run.add_argument(
898
+ "--device",
899
+ "-d",
900
+ help="Target device: an identifier or name from 'pn devices' "
901
+ "(physical iOS devices need [ios].development_team)",
902
+ )
752
903
  parser_run.add_argument("--prepare-only", action="store_true", help="Stage + configure without building")
753
904
  parser_run.add_argument("--hot-reload", action="store_true", help="Watch app/ and push updates to the running app")
754
905
  parser_run.add_argument("--no-logs", action="store_true", help="Don't stream device logs after launch")
755
906
  parser_run.set_defaults(func=run_project)
756
907
 
908
+ parser_logs = subparsers.add_parser("logs", help="Stream logs from the running app")
909
+ parser_logs.add_argument("platform", choices=["android", "ios"])
910
+ parser_logs.set_defaults(func=logs_command)
911
+
757
912
  parser_build = subparsers.add_parser("build", help="Build distributable artifacts")
758
913
  parser_build.add_argument("platform", choices=["android", "ios"])
759
914
  parser_build.add_argument("--debug", action="store_true", help="Build the debug variant instead of release")
915
+ parser_build.add_argument(
916
+ "--upload",
917
+ action="store_true",
918
+ help='Upload the iOS release build to App Store Connect (needs export_method = "app-store")',
919
+ )
760
920
  parser_build.set_defaults(func=build_project)
761
921
 
762
922
  parser_app_id = subparsers.add_parser("app-id", help="Print the resolved application/bundle id")
@@ -755,6 +755,8 @@ def View(
755
755
  *children: Element,
756
756
  style: StyleProp = None,
757
757
  gestures: Optional[List[Any]] = None,
758
+ hit_slop: Optional[Union[float, Dict[str, float]]] = None,
759
+ on_layout: Optional[Callable[[Dict[str, float]], None]] = None,
758
760
  accessibility_label: Optional[str] = None,
759
761
  accessibility_hint: Optional[str] = None,
760
762
  accessibility_role: Optional[str] = None,
@@ -797,6 +799,13 @@ def View(
797
799
  gestures: Optional list of gesture descriptors from
798
800
  `pythonnative.gestures` (e.g. ``[gestures.Pan(on_change=…)]``)
799
801
  recognized natively on this view.
802
+ hit_slop: Extend the touch target beyond the view's bounds
803
+ without changing layout: a uniform number of points, or a
804
+ dict with any of ``top`` / ``left`` / ``bottom`` /
805
+ ``right``.
806
+ on_layout: Callback invoked with
807
+ ``{"x", "y", "width", "height"}`` after this view is laid
808
+ out, and again whenever its frame changes.
800
809
  accessibility_label: Spoken description for screen readers.
801
810
  accessibility_hint: Spoken extra detail (iOS only).
802
811
  accessibility_role: Semantic role for assistive tech.
@@ -824,6 +833,8 @@ def View(
824
833
  ref=ref,
825
834
  key=key,
826
835
  gestures=gestures,
836
+ hit_slop=hit_slop,
837
+ on_layout=on_layout,
827
838
  accessibility_label=accessibility_label,
828
839
  accessibility_hint=accessibility_hint,
829
840
  accessibility_role=accessibility_role,
@@ -838,6 +849,7 @@ def View(
838
849
  def Column(
839
850
  *children: Element,
840
851
  style: StyleProp = None,
852
+ on_layout: Optional[Callable[[Dict[str, float]], None]] = None,
841
853
  ref: Optional[Ref] = None,
842
854
  key: Optional[str] = None,
843
855
  ) -> Element:
@@ -850,6 +862,9 @@ def Column(
850
862
  Args:
851
863
  *children: Child elements stacked top to bottom.
852
864
  style: Style dict (or list of dicts).
865
+ on_layout: Callback invoked with
866
+ ``{"x", "y", "width", "height"}`` after layout and on
867
+ frame changes.
853
868
  ref: Optional [`Ref`][pythonnative.Ref] for native-view access.
854
869
  key: Stable identity for keyed reconciliation.
855
870
 
@@ -860,6 +875,7 @@ def Column(
860
875
  "Column",
861
876
  *children,
862
877
  style=style,
878
+ on_layout=on_layout,
863
879
  ref=ref,
864
880
  key=key,
865
881
  _forced={"flex_direction": "column"},
@@ -869,6 +885,7 @@ def Column(
869
885
  def Row(
870
886
  *children: Element,
871
887
  style: StyleProp = None,
888
+ on_layout: Optional[Callable[[Dict[str, float]], None]] = None,
872
889
  ref: Optional[Ref] = None,
873
890
  key: Optional[str] = None,
874
891
  ) -> Element:
@@ -881,6 +898,9 @@ def Row(
881
898
  Args:
882
899
  *children: Child elements arranged left to right.
883
900
  style: Style dict (or list of dicts).
901
+ on_layout: Callback invoked with
902
+ ``{"x", "y", "width", "height"}`` after layout and on
903
+ frame changes.
884
904
  ref: Optional [`Ref`][pythonnative.Ref] for native-view access.
885
905
  key: Stable identity for keyed reconciliation.
886
906
 
@@ -891,6 +911,7 @@ def Row(
891
911
  "Row",
892
912
  *children,
893
913
  style=style,
914
+ on_layout=on_layout,
894
915
  ref=ref,
895
916
  key=key,
896
917
  _forced={"flex_direction": "row"},
@@ -1144,6 +1165,8 @@ def Pressable(
1144
1165
  on_press_out: Optional[Callable[[], None]] = None,
1145
1166
  pressed_opacity: float = 0.6,
1146
1167
  gestures: Optional[List[Any]] = None,
1168
+ hit_slop: Optional[Union[float, Dict[str, float]]] = None,
1169
+ on_layout: Optional[Callable[[Dict[str, float]], None]] = None,
1147
1170
  style: Union[StyleProp, Callable[[Dict[str, bool]], StyleProp]] = None,
1148
1171
  accessibility_label: Optional[str] = None,
1149
1172
  accessibility_hint: Optional[str] = None,
@@ -1174,6 +1197,14 @@ def Pressable(
1174
1197
  gestures: Optional list of gesture descriptors from
1175
1198
  `pythonnative.gestures` recognized natively on this view
1176
1199
  (pan / swipe / pinch / rotation / multi-tap).
1200
+ hit_slop: Extend the touch target beyond the view's bounds
1201
+ without changing layout: a uniform number of points, or a
1202
+ dict with any of ``top`` / ``left`` / ``bottom`` /
1203
+ ``right``. Essential for small touch targets (icons,
1204
+ chips) that should honor the 44-point guideline.
1205
+ on_layout: Callback invoked with
1206
+ ``{"x", "y", "width", "height"}`` after layout and on
1207
+ frame changes.
1177
1208
  style: Style dict applied to the wrapper, or a callable
1178
1209
  receiving the interaction state (``{"pressed": bool}``)
1179
1210
  and returning a style, re-evaluated on every press
@@ -1220,6 +1251,8 @@ def Pressable(
1220
1251
  on_press_out=on_press_out,
1221
1252
  pressed_opacity=pressed_opacity,
1222
1253
  gestures=gestures,
1254
+ hit_slop=hit_slop,
1255
+ on_layout=on_layout,
1223
1256
  accessibility_label=accessibility_label,
1224
1257
  accessibility_hint=accessibility_hint,
1225
1258
  accessibility_role=accessibility_role,
@@ -1240,6 +1273,8 @@ def Pressable(
1240
1273
  on_press_out=on_press_out,
1241
1274
  pressed_opacity=pressed_opacity,
1242
1275
  gestures=gestures,
1276
+ hit_slop=hit_slop,
1277
+ on_layout=on_layout,
1243
1278
  accessibility_label=accessibility_label,
1244
1279
  accessibility_hint=accessibility_hint,
1245
1280
  accessibility_role=accessibility_role,
@@ -2620,13 +2655,30 @@ def _KeyboardAvoidingContainer(**p: Any) -> Element:
2620
2655
  inside a full-height container).
2621
2656
  - ``"position"``: translates the whole container upward without
2622
2657
  resizing it (useful for pinned footers/toolbars).
2658
+ - ``"height"``: shrinks the container's own height by the shift.
2659
+ The resting height is captured via ``on_layout`` while the
2660
+ keyboard is hidden, so flex-sized containers work too.
2623
2661
  """
2624
- height = use_keyboard_height()
2662
+ keyboard = use_keyboard_height()
2663
+ base_height, set_base_height = use_state(0.0)
2625
2664
  behavior = p.get("behavior") or "padding"
2626
2665
  offset = float(p.get("keyboard_vertical_offset") or 0.0)
2627
- shift = max(0.0, height - offset) if height > 0 else 0.0
2666
+ shift = max(0.0, keyboard - offset) if keyboard > 0 else 0.0
2628
2667
  style: Dict[str, Any] = dict(p.get("style") or {})
2629
- if shift > 0:
2668
+ props: Dict[str, Any] = style
2669
+ if behavior == "height":
2670
+
2671
+ def _record_layout(frame: Dict[str, float]) -> None:
2672
+ if keyboard <= 0:
2673
+ measured = float(frame.get("height", 0.0))
2674
+ if measured > 0 and abs(measured - base_height) > 0.5:
2675
+ set_base_height(measured)
2676
+
2677
+ props = dict(style)
2678
+ props["on_layout"] = _record_layout
2679
+ if shift > 0 and base_height > 0:
2680
+ props["height"] = max(0.0, base_height - shift)
2681
+ elif shift > 0:
2630
2682
  if behavior == "position":
2631
2683
  transform = list(style.get("transform") or [])
2632
2684
  transform.append({"translate_y": -shift})
@@ -2634,12 +2686,12 @@ def _KeyboardAvoidingContainer(**p: Any) -> Element:
2634
2686
  else:
2635
2687
  style["padding_bottom"] = _numeric_edge_padding(style, "bottom") + shift
2636
2688
  children = p.get("children") or []
2637
- return Element("KeyboardAvoidingView", style, list(children))
2689
+ return Element("KeyboardAvoidingView", props, list(children))
2638
2690
 
2639
2691
 
2640
2692
  def KeyboardAvoidingView(
2641
2693
  *children: Element,
2642
- behavior: Literal["padding", "position"] = "padding",
2694
+ behavior: Literal["padding", "position", "height"] = "padding",
2643
2695
  keyboard_vertical_offset: float = 0.0,
2644
2696
  style: StyleProp = None,
2645
2697
  key: Optional[str] = None,
@@ -2656,8 +2708,10 @@ def KeyboardAvoidingView(
2656
2708
  Args:
2657
2709
  *children: Children rendered inside the avoiding container.
2658
2710
  behavior: ``"padding"`` (adds bottom padding, resizing the
2659
- content) or ``"position"`` (translates the container
2660
- upward without resizing).
2711
+ content), ``"position"`` (translates the container upward
2712
+ without resizing), or ``"height"`` (shrinks the
2713
+ container's height by the keyboard overlap, matching
2714
+ React Native's ``"height"`` behavior).
2661
2715
  keyboard_vertical_offset: Distance in layout units already
2662
2716
  covered by other UI (e.g. a nav bar); subtracted from the
2663
2717
  keyboard height before applying the shift.
@@ -37,6 +37,7 @@ __all__ = [
37
37
  "is_dev",
38
38
  "warn",
39
39
  "warn_once",
40
+ "swallowed",
40
41
  "get_warnings",
41
42
  "clear_warnings",
42
43
  "set_error_reporter",
@@ -143,6 +144,32 @@ def warn_once(message: str, key: Optional[str] = None) -> None:
143
144
  warn(message)
144
145
 
145
146
 
147
+ def swallowed(context: str) -> None:
148
+ """Surface a suppressed native-backend exception (dev mode only).
149
+
150
+ The native view/module backends intentionally degrade gracefully in
151
+ production: a failed style application or a missing OS API must not
152
+ crash the app. Call this from the ``except`` block instead of
153
+ ``pass`` so that, in dev mode, each suppression is reported once per
154
+ call site instead of disappearing.
155
+
156
+ Args:
157
+ context: Where the suppression happened, e.g.
158
+ ``"ios.TextHandler._apply"``.
159
+ """
160
+ if not is_dev():
161
+ return
162
+ exc = sys.exc_info()[1]
163
+ if exc is None:
164
+ return
165
+ frame = sys._getframe(1)
166
+ location = f"{os.path.basename(frame.f_code.co_filename)}:{frame.f_lineno}"
167
+ warn_once(
168
+ f"{context} suppressed {type(exc).__name__}: {exc} ({location})",
169
+ key=f"swallowed:{context}:{location}",
170
+ )
171
+
172
+
146
173
  def get_warnings() -> List[str]:
147
174
  """Return a snapshot of the recorded warnings (oldest first)."""
148
175
  with _warn_lock: