pythonnative 0.36.0__py3-none-any.whl → 0.37.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 (174) hide show
  1. pythonnative/__init__.py +6 -4
  2. pythonnative/_ios_log.py +1 -1
  3. pythonnative/alerts.py +34 -88
  4. pythonnative/bootstrap.py +90 -0
  5. pythonnative/bridge/__init__.py +306 -0
  6. pythonnative/bridge/android.py +78 -0
  7. pythonnative/bridge/codec.py +164 -0
  8. pythonnative/bridge/fake.py +236 -0
  9. pythonnative/bridge/ios.py +138 -0
  10. pythonnative/components/layout.py +65 -3
  11. pythonnative/gestures.py +13 -10
  12. pythonnative/hooks.py +41 -26
  13. pythonnative/hosts/__init__.py +13 -36
  14. pythonnative/hosts/native.py +348 -0
  15. pythonnative/images.py +6 -5
  16. pythonnative/native_modules/__init__.py +30 -29
  17. pythonnative/native_modules/app_state.py +10 -6
  18. pythonnative/native_modules/battery.py +20 -84
  19. pythonnative/native_modules/biometrics.py +11 -124
  20. pythonnative/native_modules/camera.py +15 -291
  21. pythonnative/native_modules/clipboard.py +14 -109
  22. pythonnative/native_modules/desktop.py +322 -0
  23. pythonnative/native_modules/file_system.py +15 -21
  24. pythonnative/native_modules/haptics.py +19 -129
  25. pythonnative/native_modules/linking.py +24 -137
  26. pythonnative/native_modules/location.py +28 -180
  27. pythonnative/native_modules/net_info.py +25 -135
  28. pythonnative/native_modules/notifications.py +36 -257
  29. pythonnative/native_modules/permissions.py +20 -184
  30. pythonnative/native_modules/registry.py +513 -0
  31. pythonnative/native_modules/secure_store.py +18 -155
  32. pythonnative/native_modules/share.py +8 -113
  33. pythonnative/native_views/__init__.py +67 -86
  34. pythonnative/native_views/bridge_backend.py +264 -0
  35. pythonnative/platform.py +19 -15
  36. pythonnative/platform_metrics.py +3 -3
  37. pythonnative/project/android.py +0 -3
  38. pythonnative/project/builder.py +38 -16
  39. pythonnative/project/config.py +12 -2
  40. pythonnative/project/plugins.py +366 -0
  41. pythonnative/py.typed +0 -0
  42. pythonnative/runtime.py +35 -203
  43. pythonnative/sdk/__init__.py +48 -33
  44. pythonnative/sdk/_components.py +57 -76
  45. pythonnative/storage.py +18 -243
  46. pythonnative/templates/android_template/app/build.gradle +6 -9
  47. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt +34 -166
  48. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/ScreenFragment.kt +13 -151
  49. pythonnative/templates/android_template/pythonnative/build.gradle +48 -0
  50. pythonnative/templates/android_template/pythonnative/src/main/AndroidManifest.xml +11 -0
  51. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/PNBridge.kt +214 -0
  52. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/PythonHost.kt +20 -0
  53. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/animation/AnimationSpecs.kt +141 -0
  54. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/animation/PNAnimator.kt +249 -0
  55. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/bridge/JsonUtil.kt +175 -0
  56. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/bridge/MainThread.kt +32 -0
  57. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/bridge/PNLog.kt +33 -0
  58. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/bridge/PNRegistry.kt +82 -0
  59. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/bridge/PNTransaction.kt +80 -0
  60. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/bridge/TransactionApplier.kt +115 -0
  61. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/bridge/ViewRegistry.kt +74 -0
  62. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/BuiltinComponents.kt +35 -0
  63. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/ButtonManager.kt +33 -0
  64. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/ComponentManager.kt +213 -0
  65. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/ContainerManagers.kt +69 -0
  66. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/ControlManagers.kt +186 -0
  67. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/DatePickerManager.kt +123 -0
  68. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/ImageLoader.kt +141 -0
  69. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/ImageManager.kt +156 -0
  70. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/ModalManager.kt +134 -0
  71. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/PNColor.kt +201 -0
  72. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/PickerManager.kt +87 -0
  73. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/PortalManager.kt +55 -0
  74. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/PressableManager.kt +122 -0
  75. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/ScrollViewManager.kt +214 -0
  76. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/SegmentedControlManager.kt +113 -0
  77. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/StatusBarManager.kt +52 -0
  78. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/TabBarManager.kt +131 -0
  79. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/TextInputManager.kt +245 -0
  80. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/TextManager.kt +233 -0
  81. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/ViewStyler.kt +355 -0
  82. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/VirtualListManager.kt +179 -0
  83. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/WebViewManager.kt +149 -0
  84. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/gestures/GestureArbiter.kt +233 -0
  85. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/gestures/GestureCoordinator.kt +127 -0
  86. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/gestures/GestureRecognizers.kt +527 -0
  87. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/BuiltinModules.kt +73 -0
  88. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/DeviceModules.kt +316 -0
  89. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/HostModule.kt +97 -0
  90. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/MediaModules.kt +200 -0
  91. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/NativeModule.kt +135 -0
  92. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/NotificationsModule.kt +100 -0
  93. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/PermissionsModule.kt +88 -0
  94. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/StorageModules.kt +89 -0
  95. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/SystemModules.kt +178 -0
  96. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/plugins/GeneratedPlugins.kt +16 -0
  97. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/screens/Navigator.kt +116 -0
  98. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/screens/PNScreenFragment.kt +223 -0
  99. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/screens/ScreenRegistry.kt +80 -0
  100. pythonnative/templates/android_template/{app/src/main/java/com/pythonnative/android_template → pythonnative/src/main/java/com/pythonnative/runtime/views}/PNAccessibilityDelegate.kt +5 -4
  101. pythonnative/templates/android_template/{app/src/main/java/com/pythonnative/android_template → pythonnative/src/main/java/com/pythonnative/runtime/views}/PNBorderDrawable.kt +12 -3
  102. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/views/PNEditText.kt +18 -0
  103. pythonnative/templates/android_template/{app/src/main/java/com/pythonnative/android_template → pythonnative/src/main/java/com/pythonnative/runtime/views}/PNFrameLayout.kt +9 -10
  104. pythonnative/templates/android_template/{app/src/main/java/com/pythonnative/android_template → pythonnative/src/main/java/com/pythonnative/runtime/views}/PNVirtualListView.java +9 -12
  105. pythonnative/templates/android_template/pythonnative/src/test/java/com/pythonnative/runtime/AnimationSpecsTest.kt +75 -0
  106. pythonnative/templates/android_template/pythonnative/src/test/java/com/pythonnative/runtime/GestureArbiterTest.kt +197 -0
  107. pythonnative/templates/android_template/pythonnative/src/test/java/com/pythonnative/runtime/PNColorTest.kt +47 -0
  108. pythonnative/templates/android_template/pythonnative/src/test/java/com/pythonnative/runtime/PNTransactionTest.kt +78 -0
  109. pythonnative/templates/android_template/pythonnative/src/test/java/com/pythonnative/runtime/PromiseTest.kt +75 -0
  110. pythonnative/templates/android_template/settings.gradle +1 -0
  111. pythonnative/templates/ios_template/PythonNativeKit/Package.swift +21 -0
  112. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Animation/PNAnimator.swift +301 -0
  113. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Bridge/PNBridge.swift +191 -0
  114. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Bridge/PNRegistry.swift +140 -0
  115. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Bridge/PNTransaction.swift +165 -0
  116. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Bridge/PNViewRegistry.swift +53 -0
  117. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNButtonManagers.swift +206 -0
  118. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNColor.swift +173 -0
  119. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNComponentManager.swift +183 -0
  120. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNContainerView.swift +56 -0
  121. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNControlManagers.swift +281 -0
  122. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNImageManager.swift +221 -0
  123. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNOverlayManagers.swift +243 -0
  124. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNPressableManager.swift +114 -0
  125. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNScrollViewManager.swift +197 -0
  126. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNTabBarManager.swift +106 -0
  127. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNTextInputManager.swift +289 -0
  128. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNTextManager.swift +230 -0
  129. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNTransform.swift +82 -0
  130. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNViewManager.swift +86 -0
  131. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNViewState.swift +87 -0
  132. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNViewStyler.swift +340 -0
  133. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNVirtualListManager.swift +257 -0
  134. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNWebViewManager.swift +124 -0
  135. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Gestures/PNGestureCoordinator.swift +260 -0
  136. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Modules/AlertModule.swift +92 -0
  137. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Modules/HostModule.swift +158 -0
  138. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Modules/LifecycleModules.swift +174 -0
  139. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Modules/MediaModules.swift +177 -0
  140. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Modules/PNNativeModule.swift +125 -0
  141. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Modules/PermissionModules.swift +251 -0
  142. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Modules/SystemModules.swift +251 -0
  143. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Plugins/PNPluginRegistration.swift +8 -0
  144. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Screens/PNScreenRegistry.swift +46 -0
  145. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Screens/PNViewController.swift +251 -0
  146. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Support/PNCompat.swift +46 -0
  147. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Support/PNJSON.swift +173 -0
  148. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Support/PNLog.swift +47 -0
  149. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Support/PNWindow.swift +51 -0
  150. pythonnative/templates/ios_template/PythonNativeKit/Tests/PythonNativeKitTests/PNColorTests.swift +50 -0
  151. pythonnative/templates/ios_template/PythonNativeKit/Tests/PythonNativeKitTests/PNGestureTests.swift +101 -0
  152. pythonnative/templates/ios_template/PythonNativeKit/Tests/PythonNativeKitTests/PNManagerTests.swift +124 -0
  153. pythonnative/templates/ios_template/PythonNativeKit/Tests/PythonNativeKitTests/PNModuleTests.swift +98 -0
  154. pythonnative/templates/ios_template/PythonNativeKit/Tests/PythonNativeKitTests/PNTransactionTests.swift +91 -0
  155. pythonnative/templates/ios_template/ios_template/AppDelegate.swift +8 -50
  156. pythonnative/templates/ios_template/ios_template/Info.plist +4 -4
  157. pythonnative/templates/ios_template/ios_template/PythonRuntime.swift +38 -46
  158. pythonnative/templates/ios_template/ios_template/SceneDelegate.swift +11 -16
  159. pythonnative/templates/ios_template/ios_template/ViewController.swift +12 -142
  160. pythonnative/templates/ios_template/ios_template.xcodeproj/project.pbxproj +47 -1
  161. pythonnative/utils.py +4 -82
  162. pythonnative/virtual_rows.py +3 -3
  163. {pythonnative-0.36.0.dist-info → pythonnative-0.37.0.dist-info}/METADATA +3 -5
  164. pythonnative-0.37.0.dist-info/RECORD +263 -0
  165. pythonnative/hosts/android.py +0 -269
  166. pythonnative/hosts/ios.py +0 -404
  167. pythonnative/native_views/android.py +0 -3702
  168. pythonnative/native_views/ios.py +0 -5253
  169. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/Navigator.kt +0 -43
  170. pythonnative-0.36.0.dist-info/RECORD +0 -155
  171. {pythonnative-0.36.0.dist-info → pythonnative-0.37.0.dist-info}/WHEEL +0 -0
  172. {pythonnative-0.36.0.dist-info → pythonnative-0.37.0.dist-info}/entry_points.txt +0 -0
  173. {pythonnative-0.36.0.dist-info → pythonnative-0.37.0.dist-info}/licenses/LICENSE +0 -0
  174. {pythonnative-0.36.0.dist-info → pythonnative-0.37.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,124 @@
1
+ import UIKit
2
+ import WebKit
3
+
4
+ /// `WebView`: `WKWebView` with navigation and script-message delegates.
5
+ ///
6
+ /// Page JavaScript reaches `on_message` through
7
+ /// `window.webkit.messageHandlers.pythonnative.postMessage(value)`.
8
+ public final class PNWebViewManager: PNComponentManager {
9
+ public override func makeView(props: [String: Any]) -> UIView {
10
+ let configuration = WKWebViewConfiguration()
11
+ let delegate = PNWebViewDelegate()
12
+ configuration.userContentController.add(delegate, name: "pythonnative")
13
+ let webView = WKWebView(frame: .zero, configuration: configuration)
14
+ webView.navigationDelegate = delegate
15
+ delegate.webView = webView
16
+ pendingDelegates[ObjectIdentifier(webView)] = delegate
17
+ return webView
18
+ }
19
+
20
+ private var pendingDelegates: [ObjectIdentifier: PNWebViewDelegate] = [:]
21
+
22
+ public override func createView(tag: Int64, props: [String: Any]) -> UIView {
23
+ let view = super.createView(tag: tag, props: props)
24
+ if let delegate = pendingDelegates.removeValue(forKey: ObjectIdentifier(view)) {
25
+ PNViewState.existing(for: view)?.retained.append(delegate)
26
+ delegate.injectJavaScript = PNProps.string(PNProps.value(props, "inject_javascript"))
27
+ }
28
+ return view
29
+ }
30
+
31
+ public override func teardown(view: UIView) {
32
+ guard let webView = view as? WKWebView else { return }
33
+ webView.configuration.userContentController.removeScriptMessageHandler(forName: "pythonnative")
34
+ webView.navigationDelegate = nil
35
+ webView.stopLoading()
36
+ }
37
+
38
+ public override func apply(view: UIView, props: [String: Any], initial: Bool) {
39
+ guard let webView = view as? WKWebView else { return }
40
+ if PNProps.has(props, "inject_javascript"),
41
+ let delegate = PNViewState.existing(for: webView)?.retained.compactMap({ $0 as? PNWebViewDelegate }).first
42
+ {
43
+ delegate.injectJavaScript = PNProps.string(PNProps.value(props, "inject_javascript"))
44
+ }
45
+ if let html = PNProps.string(PNProps.value(props, "html")), !html.isEmpty {
46
+ let base = PNProps.string(PNProps.value(mergedProps(webView), "base_url")).flatMap { URL(string: $0) }
47
+ webView.loadHTMLString(html, baseURL: base)
48
+ } else if let url = PNProps.string(PNProps.value(props, "url")), !url.isEmpty, let target = URL(string: url) {
49
+ webView.load(URLRequest(url: target))
50
+ }
51
+ if PNProps.has(props, "scroll_enabled") {
52
+ webView.scrollView.isScrollEnabled = PNProps.bool(PNProps.value(props, "scroll_enabled")) ?? true
53
+ }
54
+ if PNProps.has(props, "allows_back_forward_gestures") {
55
+ webView.allowsBackForwardNavigationGestures = PNProps.bool(PNProps.value(props, "allows_back_forward_gestures")) ?? false
56
+ }
57
+ PNViewStyler.applyCommon(webView, props)
58
+ }
59
+
60
+ public override func command(view: UIView, name: String, args: [String: Any]) -> Any? {
61
+ guard let webView = view as? WKWebView else { return nil }
62
+ switch name {
63
+ case "eval_js", "inject_javascript":
64
+ webView.evaluateJavaScript(PNProps.string(args["source"]) ?? PNProps.string(args["script"]) ?? "") { _, _ in }
65
+ case "reload": webView.reload()
66
+ case "go_back": webView.goBack()
67
+ case "go_forward": webView.goForward()
68
+ case "stop_loading": webView.stopLoading()
69
+ case "load_url":
70
+ if let url = PNProps.string(args["url"]).flatMap({ URL(string: $0) }) { webView.load(URLRequest(url: url)) }
71
+ case "get_url":
72
+ return webView.url?.absoluteString ?? ""
73
+ case "can_go_back":
74
+ return webView.canGoBack
75
+ case "can_go_forward":
76
+ return webView.canGoForward
77
+ default:
78
+ break
79
+ }
80
+ return nil
81
+ }
82
+ }
83
+
84
+ /// `WKNavigationDelegate` + `WKScriptMessageHandler` bridge.
85
+ final class PNWebViewDelegate: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
86
+ weak var webView: WKWebView?
87
+ var injectJavaScript: String?
88
+
89
+ private func currentURL() -> String {
90
+ webView?.url?.absoluteString ?? ""
91
+ }
92
+
93
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
94
+ if let js = injectJavaScript, !js.isEmpty {
95
+ webView.evaluateJavaScript(js) { _, _ in }
96
+ }
97
+ PNEvents.emit(webView, "on_load", [currentURL()])
98
+ PNEvents.emitIfWired(webView, "on_navigation_state_change", [["url": currentURL(), "loading": false, "can_go_back": webView.canGoBack, "can_go_forward": webView.canGoForward, "title": webView.title ?? ""]])
99
+ }
100
+
101
+ func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
102
+ PNEvents.emitIfWired(webView, "on_load_start", [currentURL()])
103
+ PNEvents.emitIfWired(webView, "on_navigation_state_change", [["url": currentURL(), "loading": true, "can_go_back": webView.canGoBack, "can_go_forward": webView.canGoForward, "title": webView.title ?? ""]])
104
+ }
105
+
106
+ func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
107
+ PNEvents.emit(webView, "on_error", [error.localizedDescription])
108
+ }
109
+
110
+ func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
111
+ PNEvents.emit(webView, "on_error", [error.localizedDescription])
112
+ }
113
+
114
+ func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
115
+ guard let webView = webView else { return }
116
+ let body: String
117
+ if let text = message.body as? String {
118
+ body = text
119
+ } else {
120
+ body = PNJSON.encode(message.body)
121
+ }
122
+ PNEvents.emit(webView, "on_message", [body])
123
+ }
124
+ }
@@ -0,0 +1,260 @@
1
+ import UIKit
2
+
3
+ /// Installs `UIGestureRecognizer`s from the `gestures` prop and emits
4
+ /// `gesture:<index>` events with the payload dict Python's
5
+ /// `GestureEvent` expects.
6
+ ///
7
+ /// Spec fields:
8
+ /// - `kind`: `tap`, `long_press`, `pan`, `swipe`, `fling`, `pinch`, `rotation`
9
+ /// - per-kind config (`n_taps`, `max_distance`, `min_duration_ms`,
10
+ /// `min_distance`, `direction`, `n_pointers`, `min_velocity`)
11
+ /// - `simultaneous`: indices allowed to recognize together
12
+ /// - `wait_for`: indices that must fail before this one may begin
13
+ public final class PNGestureCoordinator: NSObject, UIGestureRecognizerDelegate {
14
+ public static let shared = PNGestureCoordinator()
15
+
16
+ /// Bookkeeping for one installed recognizer.
17
+ final class Entry {
18
+ let index: Int
19
+ let kind: String
20
+ var simultaneous: Set<Int>
21
+ var minVelocity: CGFloat
22
+ var direction: String
23
+ var lastLocation: CGPoint = .zero
24
+
25
+ init(index: Int, kind: String, simultaneous: Set<Int>, minVelocity: CGFloat, direction: String) {
26
+ self.index = index
27
+ self.kind = kind
28
+ self.simultaneous = simultaneous
29
+ self.minVelocity = minVelocity
30
+ self.direction = direction
31
+ }
32
+ }
33
+
34
+ private var entries: [ObjectIdentifier: Entry] = [:]
35
+
36
+ private override init() {
37
+ super.init()
38
+ }
39
+
40
+ // MARK: - Wiring
41
+
42
+ /// Replace the recognizers on `view` with the ones described by `specs`.
43
+ public func wire(view: UIView, specs: Any?) {
44
+ unwire(view: view)
45
+ guard let state = PNViewState.existing(for: view) else { return }
46
+ let list = ((specs as? [Any]) ?? []).map { ($0 as? [String: Any]) ?? [:] }
47
+ guard !list.isEmpty else { return }
48
+ // A spec may own several recognizers (an any-direction swipe is
49
+ // one `UISwipeGestureRecognizer` per direction), so relationships
50
+ // are wired spec-to-spec across every recognizer on each side.
51
+ var built: [Int: [UIGestureRecognizer]] = [:]
52
+ for (index, spec) in list.enumerated() {
53
+ let recognizers = makeRecognizers(spec)
54
+ if recognizers.isEmpty {
55
+ PNLog.once(PNLog.gestures, key: "kind:\(PNProps.string(spec["kind"]) ?? "")", "unsupported gesture kind '\(PNProps.string(spec["kind"]) ?? "")'")
56
+ continue
57
+ }
58
+ for (recognizer, direction) in recognizers {
59
+ let entry = Entry(
60
+ index: index,
61
+ kind: PNProps.string(spec["kind"]) ?? "",
62
+ simultaneous: Set(((spec["simultaneous"] as? [Any]) ?? []).compactMap { PNProps.int($0) }),
63
+ minVelocity: CGFloat(PNProps.double(spec["min_velocity"]) ?? 300),
64
+ direction: direction ?? PNProps.string(spec["direction"]) ?? "any"
65
+ )
66
+ entries[ObjectIdentifier(recognizer)] = entry
67
+ recognizer.delegate = self
68
+ recognizer.cancelsTouchesInView = false
69
+ recognizer.addTarget(self, action: #selector(handle(_:)))
70
+ view.addGestureRecognizer(recognizer)
71
+ state.gestureRecognizers.append(recognizer)
72
+ built[index, default: []].append(recognizer)
73
+ }
74
+ }
75
+ for (index, spec) in list.enumerated() {
76
+ guard let recognizers = built[index] else { continue }
77
+ for wait in ((spec["wait_for"] as? [Any]) ?? []).compactMap({ PNProps.int($0) }) {
78
+ guard wait != index, let others = built[wait] else { continue }
79
+ for recognizer in recognizers {
80
+ for other in others {
81
+ recognizer.require(toFail: other)
82
+ }
83
+ }
84
+ }
85
+ }
86
+ view.isUserInteractionEnabled = true
87
+ }
88
+
89
+ /// Remove every recognizer previously installed on `view`.
90
+ public func unwire(view: UIView) {
91
+ guard let state = PNViewState.existing(for: view) else { return }
92
+ for recognizer in state.gestureRecognizers {
93
+ recognizer.removeTarget(self, action: nil)
94
+ view.removeGestureRecognizer(recognizer)
95
+ entries.removeValue(forKey: ObjectIdentifier(recognizer))
96
+ }
97
+ state.gestureRecognizers = []
98
+ }
99
+
100
+ // MARK: - Recognizer construction
101
+
102
+ /// Build the recognizer(s) for one spec as `(recognizer, direction)`
103
+ /// pairs. `direction` is set only for swipe / fling recognizers: a
104
+ /// `UISwipeGestureRecognizer` reports its configured mask rather than
105
+ /// the direction it resolved, so `direction = "any"` installs one
106
+ /// recognizer per direction and tags each with its own name.
107
+ func makeRecognizers(_ spec: [String: Any]) -> [(UIGestureRecognizer, String?)] {
108
+ switch PNProps.string(spec["kind"]) ?? "" {
109
+ case "tap":
110
+ let tap = UITapGestureRecognizer()
111
+ tap.numberOfTapsRequired = max(1, PNProps.int(spec["n_taps"]) ?? 1)
112
+ return [(tap, nil)]
113
+ case "long_press":
114
+ let press = UILongPressGestureRecognizer()
115
+ press.minimumPressDuration = max(0.01, (PNProps.double(spec["min_duration_ms"]) ?? 500) / 1000)
116
+ press.allowableMovement = CGFloat(PNProps.double(spec["max_distance"]) ?? 12)
117
+ return [(press, nil)]
118
+ case "pan":
119
+ return [(PNPanGestureRecognizer(minDistance: CGFloat(PNProps.double(spec["min_distance"]) ?? 10)), nil)]
120
+ case "swipe", "fling":
121
+ let requested = PNProps.string(spec["direction"]) ?? "any"
122
+ let directions = PNGestureCoordinator.swipeDirectionNames.contains(requested)
123
+ ? [requested]
124
+ : PNGestureCoordinator.swipeDirectionNames
125
+ let touches = max(1, PNProps.int(spec["n_pointers"]) ?? 1)
126
+ return directions.map { name in
127
+ let swipe = UISwipeGestureRecognizer()
128
+ swipe.direction = PNGestureCoordinator.swipeDirection(name)
129
+ swipe.numberOfTouchesRequired = touches
130
+ return (swipe, name)
131
+ }
132
+ case "pinch":
133
+ return [(UIPinchGestureRecognizer(), nil)]
134
+ case "rotation":
135
+ return [(UIRotationGestureRecognizer(), nil)]
136
+ default:
137
+ return []
138
+ }
139
+ }
140
+
141
+ /// The single-direction names a swipe spec may request.
142
+ public static let swipeDirectionNames = ["left", "right", "up", "down"]
143
+
144
+ /// Map a Python direction name to `UISwipeGestureRecognizer.Direction`.
145
+ public static func swipeDirection(_ name: String) -> UISwipeGestureRecognizer.Direction {
146
+ switch name {
147
+ case "left": return .left
148
+ case "right": return .right
149
+ case "up": return .up
150
+ case "down": return .down
151
+ default: return [.left, .right, .up, .down]
152
+ }
153
+ }
154
+
155
+ // MARK: - Relationship lookups (exposed for tests)
156
+
157
+ /// The spec index recorded for `recognizer`, if it was installed by the coordinator.
158
+ public func index(of recognizer: UIGestureRecognizer) -> Int? {
159
+ entries[ObjectIdentifier(recognizer)]?.index
160
+ }
161
+
162
+ /// Whether the two recognizers are allowed to recognize together.
163
+ public func allowsSimultaneous(_ a: UIGestureRecognizer, _ b: UIGestureRecognizer) -> Bool {
164
+ guard let ea = entries[ObjectIdentifier(a)], let eb = entries[ObjectIdentifier(b)] else {
165
+ // One side isn't ours (Pressable, scroll view): don't block it.
166
+ return true
167
+ }
168
+ if a.view !== b.view { return true }
169
+ return ea.simultaneous.contains(eb.index) || eb.simultaneous.contains(ea.index)
170
+ }
171
+
172
+ // MARK: - UIGestureRecognizerDelegate
173
+
174
+ public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool {
175
+ allowsSimultaneous(gestureRecognizer, other)
176
+ }
177
+
178
+ // MARK: - Emission
179
+
180
+ @objc private func handle(_ recognizer: UIGestureRecognizer) {
181
+ guard let view = recognizer.view, let entry = entries[ObjectIdentifier(recognizer)] else { return }
182
+ let location = recognizer.location(in: view)
183
+ var payload: [String: Any] = [
184
+ "kind": entry.kind,
185
+ "x": Double(location.x),
186
+ "y": Double(location.y),
187
+ "translation_x": 0.0,
188
+ "translation_y": 0.0,
189
+ "velocity_x": 0.0,
190
+ "velocity_y": 0.0,
191
+ "scale": 1.0,
192
+ "rotation": 0.0,
193
+ "pointer_count": recognizer.numberOfTouches,
194
+ "direction": NSNull(),
195
+ ]
196
+ var state = PNGestureCoordinator.stateName(recognizer.state)
197
+ switch recognizer {
198
+ case let pan as UIPanGestureRecognizer:
199
+ let translation = pan.translation(in: view)
200
+ let velocity = pan.velocity(in: view)
201
+ payload["translation_x"] = Double(translation.x)
202
+ payload["translation_y"] = Double(translation.y)
203
+ payload["velocity_x"] = Double(velocity.x)
204
+ payload["velocity_y"] = Double(velocity.y)
205
+ case let pinch as UIPinchGestureRecognizer:
206
+ payload["scale"] = Double(pinch.scale)
207
+ payload["velocity_x"] = Double(pinch.velocity)
208
+ case let rotation as UIRotationGestureRecognizer:
209
+ payload["rotation"] = Double(rotation.rotation)
210
+ payload["velocity_x"] = Double(rotation.velocity)
211
+ case let swipe as UISwipeGestureRecognizer:
212
+ // UIKit reports swipes as a single `.ended`; Python expects `ended`.
213
+ guard swipe.state == .ended || swipe.state == .recognized else { return }
214
+ state = "ended"
215
+ payload["direction"] = entry.direction
216
+ payload["pointer_count"] = max(1, swipe.numberOfTouchesRequired)
217
+ case is UITapGestureRecognizer:
218
+ guard recognizer.state == .ended || recognizer.state == .recognized else { return }
219
+ state = "ended"
220
+ default:
221
+ break
222
+ }
223
+ guard state != "possible" else { return }
224
+ payload["state"] = state
225
+ PNEvents.emit(view, "gesture:\(entry.index)", [payload])
226
+ }
227
+
228
+ static func stateName(_ state: UIGestureRecognizer.State) -> String {
229
+ switch state {
230
+ case .began: return "began"
231
+ case .changed: return "changed"
232
+ case .ended: return "ended"
233
+ case .cancelled: return "cancelled"
234
+ case .failed: return "failed"
235
+ default: return "possible"
236
+ }
237
+ }
238
+
239
+ /// The direction name recorded for a swipe / fling recognizer (exposed for tests).
240
+ public func direction(of recognizer: UIGestureRecognizer) -> String? {
241
+ guard let entry = entries[ObjectIdentifier(recognizer)], entry.kind == "swipe" || entry.kind == "fling" else {
242
+ return nil
243
+ }
244
+ return entry.direction
245
+ }
246
+ }
247
+
248
+ /// A pan recognizer that records the Python `min_distance` threshold.
249
+ ///
250
+ /// UIKit applies its own ~10 pt hysteresis before a pan begins, which
251
+ /// matches the Python default; the value is kept for diagnostics and
252
+ /// for future tuning rather than overriding UIKit's touch pipeline.
253
+ final class PNPanGestureRecognizer: UIPanGestureRecognizer {
254
+ let minDistance: CGFloat
255
+
256
+ init(minDistance: CGFloat) {
257
+ self.minDistance = max(0, minDistance)
258
+ super.init(target: nil, action: nil)
259
+ }
260
+ }
@@ -0,0 +1,92 @@
1
+ import UIKit
2
+
3
+ /// Presents `UIAlertController`s from the top view controller.
4
+ public enum PNAlertPresenter {
5
+ /// Show an alert or action sheet. `buttons` are
6
+ /// `{"label", "style": "default"|"cancel"|"destructive"}` dicts;
7
+ /// `completion` receives the tapped index, or `-1` on dismissal.
8
+ public static func present(
9
+ title: String?, message: String?, buttons: [[String: Any]], style: String,
10
+ completion: @escaping (Int) -> Void
11
+ ) {
12
+ guard let top = PNWindow.topViewController() else {
13
+ completion(-1)
14
+ return
15
+ }
16
+ let preferred: UIAlertController.Style = style == "action_sheet" ? .actionSheet : .alert
17
+ let alert = UIAlertController(title: title, message: message, preferredStyle: preferred)
18
+ var settled = false
19
+ let finish: (Int) -> Void = { index in
20
+ if settled { return }
21
+ settled = true
22
+ completion(index)
23
+ }
24
+ let list = buttons.isEmpty ? [["label": "OK"]] : buttons
25
+ for (index, button) in list.enumerated() {
26
+ let label = PNProps.string(button["label"]) ?? PNProps.string(button["title"]) ?? "OK"
27
+ let actionStyle: UIAlertAction.Style
28
+ switch PNProps.string(button["style"]) {
29
+ case "cancel": actionStyle = .cancel
30
+ case "destructive": actionStyle = .destructive
31
+ default: actionStyle = .default
32
+ }
33
+ alert.addAction(UIAlertAction(title: label, style: actionStyle) { _ in finish(index) })
34
+ }
35
+ if preferred == .actionSheet, let popover = alert.popoverPresentationController {
36
+ popover.sourceView = top.view
37
+ popover.sourceRect = CGRect(x: top.view.bounds.midX, y: top.view.bounds.maxY - 1, width: 1, height: 1)
38
+ popover.permittedArrowDirections = []
39
+ }
40
+ top.present(alert, animated: true)
41
+ // A sheet dismissed by tapping outside (iPad popover) never runs
42
+ // an action; report -1 once it disappears.
43
+ PNAlertDismissWatcher.watch(alert) { finish(-1) }
44
+ }
45
+ }
46
+
47
+ /// Observes an alert's disappearance to report an outside-tap dismissal.
48
+ final class PNAlertDismissWatcher: NSObject, UIPopoverPresentationControllerDelegate {
49
+ private let onDismiss: () -> Void
50
+ private static var watchers: [ObjectIdentifier: PNAlertDismissWatcher] = [:]
51
+
52
+ private init(onDismiss: @escaping () -> Void) {
53
+ self.onDismiss = onDismiss
54
+ }
55
+
56
+ static func watch(_ alert: UIAlertController, onDismiss: @escaping () -> Void) {
57
+ let watcher = PNAlertDismissWatcher(onDismiss: onDismiss)
58
+ watchers[ObjectIdentifier(alert)] = watcher
59
+ alert.popoverPresentationController?.delegate = watcher
60
+ }
61
+
62
+ func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
63
+ onDismiss()
64
+ PNAlertDismissWatcher.watchers.removeValue(forKey: ObjectIdentifier(presentationController.presentedViewController))
65
+ }
66
+ }
67
+
68
+ /// `Alert`: `present` (async, resolves the tapped index) and `show` (fire-and-forget).
69
+ public final class AlertModule: PNNativeModule {
70
+ public static let name = "Alert"
71
+
72
+ public init() {}
73
+
74
+ public func call(_ method: String, args: [String: Any], promise: PNPromise) {
75
+ switch method {
76
+ case "present", "show":
77
+ let buttons = ((args["buttons"] as? [Any]) ?? []).compactMap { $0 as? [String: Any] }
78
+ let fireAndForget = method == "show"
79
+ if fireAndForget { promise.resolve(nil) }
80
+ PNAlertPresenter.present(
81
+ title: PNProps.string(args["title"]),
82
+ message: PNProps.string(args["message"]),
83
+ buttons: buttons,
84
+ style: PNProps.string(args["style"]) ?? "alert"
85
+ ) { index in
86
+ if !fireAndForget { promise.resolve(index) }
87
+ }
88
+ default:
89
+ promise.reject("Alert has no method '\(method)'", code: "unknown_method")
90
+ }
91
+ }
92
+ }
@@ -0,0 +1,158 @@
1
+ import UIKit
2
+
3
+ /// `Host`: screen root attachment, stack navigation, viewport queries,
4
+ /// and the main-queue pump.
5
+ public final class HostModule: PNNativeModule {
6
+ public static let name = "Host"
7
+
8
+ public init() {}
9
+
10
+ public func call(_ method: String, args: [String: Any], promise: PNPromise) {
11
+ switch method {
12
+ case "post":
13
+ DispatchQueue.main.async {
14
+ PNBridge.shared.callPython(kind: "pump", tag: 0, name: "", payload: "")
15
+ }
16
+ promise.resolve(nil)
17
+ case "is_main_thread":
18
+ promise.resolve(Thread.isMainThread)
19
+ case "attach_root":
20
+ guard let controller = screen(args, promise) else { return }
21
+ guard let tag = PNProps.int(args["tag"]), let view = PNViewRegistry.shared.view(for: Int64(tag)) else {
22
+ promise.reject("attach_root: unknown view tag", code: "unknown_tag")
23
+ return
24
+ }
25
+ controller.attachRoot(view)
26
+ promise.resolve(controller.viewport())
27
+ case "detach_root":
28
+ guard let controller = screen(args, promise) else { return }
29
+ if let tag = PNProps.int(args["tag"]), let view = PNViewRegistry.shared.view(for: Int64(tag)) {
30
+ controller.detachRoot(view)
31
+ }
32
+ promise.resolve(nil)
33
+ case "viewport":
34
+ guard let controller = screen(args, promise) else { return }
35
+ promise.resolve(controller.viewport())
36
+ case "set_options":
37
+ guard let controller = screen(args, promise) else { return }
38
+ HostModule.applyOptions(PNProps.dict(args["options"]) ?? [:], to: controller)
39
+ promise.resolve(nil)
40
+ case "push":
41
+ guard let controller = screen(args, promise) else { return }
42
+ let next = HostModule.makeScreen(args, from: controller)
43
+ promise.resolve(HostModule.runNavOp(controller) { nav in nav.pushViewController(next, animated: true) })
44
+ case "pop":
45
+ guard let controller = screen(args, promise) else { return }
46
+ let count = max(1, PNProps.int(args["count"]) ?? 1)
47
+ promise.resolve(HostModule.runNavOp(controller) { nav in
48
+ if count <= 1 {
49
+ nav.popViewController(animated: true)
50
+ return
51
+ }
52
+ let controllers = nav.viewControllers
53
+ let target = max(0, controllers.count - 1 - count)
54
+ nav.popToViewController(controllers[target], animated: true)
55
+ })
56
+ case "pop_to_root":
57
+ guard let controller = screen(args, promise) else { return }
58
+ promise.resolve(HostModule.runNavOp(controller) { nav in nav.popToRootViewController(animated: true) })
59
+ case "replace":
60
+ guard let controller = screen(args, promise) else { return }
61
+ let next = HostModule.makeScreen(args, from: controller)
62
+ promise.resolve(HostModule.runNavOp(controller) { nav in
63
+ var controllers = nav.viewControllers
64
+ if let index = controllers.firstIndex(where: { $0 === controller }) {
65
+ controllers[index] = next
66
+ } else if controllers.isEmpty {
67
+ controllers = [next]
68
+ } else {
69
+ controllers[controllers.count - 1] = next
70
+ }
71
+ nav.setViewControllers(controllers, animated: true)
72
+ })
73
+ case "reset":
74
+ guard let controller = screen(args, promise) else { return }
75
+ let specs = ((args["screens"] as? [Any]) ?? []).compactMap { $0 as? [String: Any] }
76
+ let screens = specs.map { HostModule.makeScreen($0, from: controller) }
77
+ promise.resolve(HostModule.runNavOp(controller) { nav in
78
+ guard let root = nav.viewControllers.first else {
79
+ nav.setViewControllers(screens, animated: true)
80
+ return
81
+ }
82
+ nav.setViewControllers([root] + screens, animated: true)
83
+ })
84
+ default:
85
+ promise.reject("Host has no method '\(method)'", code: "unknown_method")
86
+ }
87
+ }
88
+
89
+ // MARK: - Helpers
90
+
91
+ private func screen(_ args: [String: Any], _ promise: PNPromise) -> PNViewController? {
92
+ guard let id = PNProps.int(args["screen"]) else {
93
+ promise.reject("missing 'screen' id", code: "bad_args")
94
+ return nil
95
+ }
96
+ guard let controller = PNScreenRegistry.shared.controller(for: Int64(id)) else {
97
+ promise.reject("no screen with id \(id)", code: "unknown_screen")
98
+ return nil
99
+ }
100
+ return controller
101
+ }
102
+
103
+ /// Build a sibling screen controller of the same class as `source`.
104
+ static func makeScreen(_ args: [String: Any], from source: PNViewController) -> PNViewController {
105
+ let next = type(of: source).init(nibName: nil, bundle: nil)
106
+ next.requestedScreenPath = PNProps.string(args["path"])
107
+ if let screenArgs = args["args"], !(screenArgs is NSNull) {
108
+ next.requestedScreenArgsJSON = (screenArgs as? String) ?? PNJSON.encode(screenArgs)
109
+ }
110
+ next.devRoot = source.devRoot
111
+ if let title = PNProps.string(args["title"]) {
112
+ next.title = title
113
+ }
114
+ if let options = PNProps.dict(args["options"]) {
115
+ applyOptions(options, to: next)
116
+ }
117
+ return next
118
+ }
119
+
120
+ static func applyOptions(_ options: [String: Any], to controller: UIViewController) {
121
+ if let title = PNProps.string(options["title"]) {
122
+ controller.title = title
123
+ }
124
+ if let hidden = PNProps.bool(options["header_shown"]) {
125
+ controller.navigationController?.setNavigationBarHidden(!hidden, animated: false)
126
+ }
127
+ if let hidesBack = PNProps.bool(options["hide_back_button"]) {
128
+ controller.navigationItem.hidesBackButton = hidesBack
129
+ }
130
+ if let color = PNColor.parse(options["header_tint_color"]) {
131
+ controller.navigationController?.navigationBar.tintColor = color
132
+ }
133
+ if let color = PNColor.parse(options["header_background_color"]) {
134
+ let appearance = UINavigationBarAppearance()
135
+ appearance.configureWithOpaqueBackground()
136
+ appearance.backgroundColor = color
137
+ controller.navigationItem.standardAppearance = appearance
138
+ controller.navigationItem.scrollEdgeAppearance = appearance
139
+ }
140
+ }
141
+
142
+ /// Run `op` on the controller's navigation stack unless a transition
143
+ /// is in flight (UIKit doesn't queue navigation calls). Returns
144
+ /// whether the op ran.
145
+ @discardableResult
146
+ static func runNavOp(_ controller: UIViewController, _ op: (UINavigationController) -> Void) -> Bool {
147
+ guard let nav = controller.navigationController else {
148
+ PNLog.screens.error("navigation requested but the screen is not inside a UINavigationController")
149
+ return false
150
+ }
151
+ if nav.transitionCoordinator != nil {
152
+ PNLog.rateLimited(PNLog.screens, key: "nav-inflight", "navigation op dropped: transition in flight")
153
+ return false
154
+ }
155
+ op(nav)
156
+ return true
157
+ }
158
+ }