pythonnative 0.35.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 (176) 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/cli/pn.py +24 -6
  11. pythonnative/components/layout.py +65 -3
  12. pythonnative/gestures.py +13 -10
  13. pythonnative/hooks.py +41 -26
  14. pythonnative/hosts/__init__.py +13 -36
  15. pythonnative/hosts/native.py +348 -0
  16. pythonnative/images.py +6 -5
  17. pythonnative/mutations.py +21 -0
  18. pythonnative/native_modules/__init__.py +30 -29
  19. pythonnative/native_modules/app_state.py +10 -6
  20. pythonnative/native_modules/battery.py +20 -84
  21. pythonnative/native_modules/biometrics.py +11 -124
  22. pythonnative/native_modules/camera.py +15 -291
  23. pythonnative/native_modules/clipboard.py +14 -109
  24. pythonnative/native_modules/desktop.py +322 -0
  25. pythonnative/native_modules/file_system.py +15 -21
  26. pythonnative/native_modules/haptics.py +19 -129
  27. pythonnative/native_modules/linking.py +24 -137
  28. pythonnative/native_modules/location.py +28 -180
  29. pythonnative/native_modules/net_info.py +25 -135
  30. pythonnative/native_modules/notifications.py +36 -257
  31. pythonnative/native_modules/permissions.py +20 -184
  32. pythonnative/native_modules/registry.py +513 -0
  33. pythonnative/native_modules/secure_store.py +18 -155
  34. pythonnative/native_modules/share.py +8 -113
  35. pythonnative/native_views/__init__.py +67 -86
  36. pythonnative/native_views/bridge_backend.py +264 -0
  37. pythonnative/platform.py +19 -15
  38. pythonnative/platform_metrics.py +3 -3
  39. pythonnative/project/android.py +0 -3
  40. pythonnative/project/builder.py +38 -16
  41. pythonnative/project/config.py +12 -2
  42. pythonnative/project/plugins.py +366 -0
  43. pythonnative/py.typed +0 -0
  44. pythonnative/runtime.py +35 -203
  45. pythonnative/sdk/__init__.py +48 -33
  46. pythonnative/sdk/_components.py +57 -76
  47. pythonnative/storage.py +18 -243
  48. pythonnative/templates/android_template/app/build.gradle +6 -9
  49. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt +34 -166
  50. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/ScreenFragment.kt +13 -151
  51. pythonnative/templates/android_template/pythonnative/build.gradle +48 -0
  52. pythonnative/templates/android_template/pythonnative/src/main/AndroidManifest.xml +11 -0
  53. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/PNBridge.kt +214 -0
  54. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/PythonHost.kt +20 -0
  55. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/animation/AnimationSpecs.kt +141 -0
  56. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/animation/PNAnimator.kt +249 -0
  57. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/bridge/JsonUtil.kt +175 -0
  58. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/bridge/MainThread.kt +32 -0
  59. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/bridge/PNLog.kt +33 -0
  60. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/bridge/PNRegistry.kt +82 -0
  61. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/bridge/PNTransaction.kt +80 -0
  62. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/bridge/TransactionApplier.kt +115 -0
  63. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/bridge/ViewRegistry.kt +74 -0
  64. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/BuiltinComponents.kt +35 -0
  65. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/ButtonManager.kt +33 -0
  66. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/ComponentManager.kt +213 -0
  67. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/ContainerManagers.kt +69 -0
  68. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/ControlManagers.kt +186 -0
  69. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/DatePickerManager.kt +123 -0
  70. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/ImageLoader.kt +141 -0
  71. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/ImageManager.kt +156 -0
  72. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/ModalManager.kt +134 -0
  73. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/PNColor.kt +201 -0
  74. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/PickerManager.kt +87 -0
  75. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/PortalManager.kt +55 -0
  76. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/PressableManager.kt +122 -0
  77. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/ScrollViewManager.kt +214 -0
  78. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/SegmentedControlManager.kt +113 -0
  79. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/StatusBarManager.kt +52 -0
  80. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/TabBarManager.kt +131 -0
  81. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/TextInputManager.kt +245 -0
  82. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/TextManager.kt +233 -0
  83. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/ViewStyler.kt +355 -0
  84. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/VirtualListManager.kt +179 -0
  85. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/WebViewManager.kt +149 -0
  86. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/gestures/GestureArbiter.kt +233 -0
  87. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/gestures/GestureCoordinator.kt +127 -0
  88. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/gestures/GestureRecognizers.kt +527 -0
  89. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/BuiltinModules.kt +73 -0
  90. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/DeviceModules.kt +316 -0
  91. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/HostModule.kt +97 -0
  92. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/MediaModules.kt +200 -0
  93. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/NativeModule.kt +135 -0
  94. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/NotificationsModule.kt +100 -0
  95. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/PermissionsModule.kt +88 -0
  96. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/StorageModules.kt +89 -0
  97. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/SystemModules.kt +178 -0
  98. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/plugins/GeneratedPlugins.kt +16 -0
  99. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/screens/Navigator.kt +116 -0
  100. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/screens/PNScreenFragment.kt +223 -0
  101. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/screens/ScreenRegistry.kt +80 -0
  102. pythonnative/templates/android_template/{app/src/main/java/com/pythonnative/android_template → pythonnative/src/main/java/com/pythonnative/runtime/views}/PNAccessibilityDelegate.kt +5 -4
  103. pythonnative/templates/android_template/{app/src/main/java/com/pythonnative/android_template → pythonnative/src/main/java/com/pythonnative/runtime/views}/PNBorderDrawable.kt +12 -3
  104. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/views/PNEditText.kt +18 -0
  105. pythonnative/templates/android_template/{app/src/main/java/com/pythonnative/android_template → pythonnative/src/main/java/com/pythonnative/runtime/views}/PNFrameLayout.kt +9 -10
  106. pythonnative/templates/android_template/{app/src/main/java/com/pythonnative/android_template → pythonnative/src/main/java/com/pythonnative/runtime/views}/PNVirtualListView.java +9 -12
  107. pythonnative/templates/android_template/pythonnative/src/test/java/com/pythonnative/runtime/AnimationSpecsTest.kt +75 -0
  108. pythonnative/templates/android_template/pythonnative/src/test/java/com/pythonnative/runtime/GestureArbiterTest.kt +197 -0
  109. pythonnative/templates/android_template/pythonnative/src/test/java/com/pythonnative/runtime/PNColorTest.kt +47 -0
  110. pythonnative/templates/android_template/pythonnative/src/test/java/com/pythonnative/runtime/PNTransactionTest.kt +78 -0
  111. pythonnative/templates/android_template/pythonnative/src/test/java/com/pythonnative/runtime/PromiseTest.kt +75 -0
  112. pythonnative/templates/android_template/settings.gradle +1 -0
  113. pythonnative/templates/ios_template/PythonNativeKit/Package.swift +21 -0
  114. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Animation/PNAnimator.swift +301 -0
  115. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Bridge/PNBridge.swift +191 -0
  116. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Bridge/PNRegistry.swift +140 -0
  117. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Bridge/PNTransaction.swift +165 -0
  118. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Bridge/PNViewRegistry.swift +53 -0
  119. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNButtonManagers.swift +206 -0
  120. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNColor.swift +173 -0
  121. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNComponentManager.swift +183 -0
  122. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNContainerView.swift +56 -0
  123. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNControlManagers.swift +281 -0
  124. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNImageManager.swift +221 -0
  125. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNOverlayManagers.swift +243 -0
  126. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNPressableManager.swift +114 -0
  127. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNScrollViewManager.swift +197 -0
  128. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNTabBarManager.swift +106 -0
  129. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNTextInputManager.swift +289 -0
  130. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNTextManager.swift +230 -0
  131. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNTransform.swift +82 -0
  132. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNViewManager.swift +86 -0
  133. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNViewState.swift +87 -0
  134. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNViewStyler.swift +340 -0
  135. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNVirtualListManager.swift +257 -0
  136. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNWebViewManager.swift +124 -0
  137. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Gestures/PNGestureCoordinator.swift +260 -0
  138. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Modules/AlertModule.swift +92 -0
  139. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Modules/HostModule.swift +158 -0
  140. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Modules/LifecycleModules.swift +174 -0
  141. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Modules/MediaModules.swift +177 -0
  142. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Modules/PNNativeModule.swift +125 -0
  143. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Modules/PermissionModules.swift +251 -0
  144. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Modules/SystemModules.swift +251 -0
  145. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Plugins/PNPluginRegistration.swift +8 -0
  146. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Screens/PNScreenRegistry.swift +46 -0
  147. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Screens/PNViewController.swift +251 -0
  148. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Support/PNCompat.swift +46 -0
  149. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Support/PNJSON.swift +173 -0
  150. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Support/PNLog.swift +47 -0
  151. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Support/PNWindow.swift +51 -0
  152. pythonnative/templates/ios_template/PythonNativeKit/Tests/PythonNativeKitTests/PNColorTests.swift +50 -0
  153. pythonnative/templates/ios_template/PythonNativeKit/Tests/PythonNativeKitTests/PNGestureTests.swift +101 -0
  154. pythonnative/templates/ios_template/PythonNativeKit/Tests/PythonNativeKitTests/PNManagerTests.swift +124 -0
  155. pythonnative/templates/ios_template/PythonNativeKit/Tests/PythonNativeKitTests/PNModuleTests.swift +98 -0
  156. pythonnative/templates/ios_template/PythonNativeKit/Tests/PythonNativeKitTests/PNTransactionTests.swift +91 -0
  157. pythonnative/templates/ios_template/ios_template/AppDelegate.swift +8 -50
  158. pythonnative/templates/ios_template/ios_template/Info.plist +4 -4
  159. pythonnative/templates/ios_template/ios_template/PythonRuntime.swift +38 -46
  160. pythonnative/templates/ios_template/ios_template/SceneDelegate.swift +11 -16
  161. pythonnative/templates/ios_template/ios_template/ViewController.swift +12 -142
  162. pythonnative/templates/ios_template/ios_template.xcodeproj/project.pbxproj +47 -1
  163. pythonnative/utils.py +4 -82
  164. pythonnative/virtual_rows.py +3 -3
  165. {pythonnative-0.35.0.dist-info → pythonnative-0.37.0.dist-info}/METADATA +3 -5
  166. pythonnative-0.37.0.dist-info/RECORD +263 -0
  167. pythonnative/hosts/android.py +0 -269
  168. pythonnative/hosts/ios.py +0 -404
  169. pythonnative/native_views/android.py +0 -3702
  170. pythonnative/native_views/ios.py +0 -5253
  171. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/Navigator.kt +0 -43
  172. pythonnative-0.35.0.dist-info/RECORD +0 -155
  173. {pythonnative-0.35.0.dist-info → pythonnative-0.37.0.dist-info}/WHEEL +0 -0
  174. {pythonnative-0.35.0.dist-info → pythonnative-0.37.0.dist-info}/entry_points.txt +0 -0
  175. {pythonnative-0.35.0.dist-info → pythonnative-0.37.0.dist-info}/licenses/LICENSE +0 -0
  176. {pythonnative-0.35.0.dist-info → pythonnative-0.37.0.dist-info}/top_level.txt +0 -0
@@ -1,3702 +0,0 @@
1
- """Android native-view handlers (Chaquopy / Java bridge).
2
-
3
- Each handler class maps a PythonNative element type to an Android
4
- widget, implementing view creation, property updates, child management,
5
- and frame application. Handlers are registered with the
6
- [`NativeViewRegistry`][pythonnative.native_views.NativeViewRegistry] by
7
- [`register_handlers`][pythonnative.native_views.android.register_handlers].
8
-
9
- **Batched protocol**: the registry applies the reconciler's mutation
10
- ops; handlers receive callable-free props. User callbacks never reach
11
- this module; every interaction (clicks, text changes, scrolls,
12
- gestures) is forwarded through
13
- [`dispatch_event`][pythonnative.events.dispatch_event] keyed by the
14
- view's reconciler-assigned tag.
15
-
16
- **Layout** is owned by the pure-Python flex engine in
17
- `pythonnative.layout`: container handlers create plain `FrameLayout`s,
18
- the engine computes per-child frames, and
19
- [`set_frame`][pythonnative.native_views.android.AndroidViewHandler.set_frame]
20
- applies those frames via per-child `MarginLayoutParams`.
21
-
22
- **Gestures** feed raw ``MotionEvent`` streams into the shared
23
- pure-Python [`GestureArbiter`][pythonnative.gestures.GestureArbiter],
24
- so gesture semantics match the desktop preview exactly. Serialized
25
- specs (including ``simultaneous`` / ``wait_for`` composition metadata
26
- and ``fling`` configs) pass through untouched; this module only
27
- schedules main-looper polls at the arbiter's ``next_deadline()`` so
28
- long-press activation and multi-tap windows fire even when no
29
- further touch events arrive.
30
-
31
- **Animations**: ``timing`` animations on transform/opacity/color props
32
- are driven natively by ``ObjectAnimator`` (Choreographer-paced, no
33
- Python per-frame work); springs and decay fall back to the Python
34
- ticker, whose per-frame values arrive through
35
- ``set_animated_property`` (floats for transforms and opacity,
36
- interpolated "#AARRGGBB" strings for background and text colors that
37
- re-bake through the border-drawable machinery instead of clobbering
38
- it).
39
-
40
- **Interaction props**: ``pointer_events`` maps onto the template's
41
- ``PNFrameLayout`` touch interception when available (with a
42
- Python-side fallback for older projects), ``hit_slop`` expands touch
43
- targets via a parent ``TouchDelegate`` sized after layout,
44
- ``z_index`` maps to ``View.setZ``, and per-corner radii extend the
45
- ``GradientDrawable`` border path.
46
-
47
- This module is only imported on Android at runtime. Desktop tests
48
- inject a mock registry via
49
- [`set_registry`][pythonnative.native_views.set_registry] and never
50
- trigger this import path.
51
- """
52
-
53
- import math
54
- import time
55
- from typing import Any, Callable, Dict, Optional, Tuple
56
-
57
- from java import dynamic_proxy, jclass
58
-
59
- from .. import diagnostics
60
- from ..events import dispatch_event, event_names
61
- from ..gestures import make_arbiter
62
- from ..utils import get_android_context
63
- from .base import (
64
- TEXT_SHADOW_STYLE_KEYS,
65
- ViewHandler,
66
- _safe_max,
67
- parse_color_int,
68
- shadow_offset_xy,
69
- transform_spans,
70
- transform_text,
71
- )
72
-
73
- _SIDE_BORDER_WIDTH_KEYS = (
74
- "border_left_width",
75
- "border_top_width",
76
- "border_right_width",
77
- "border_bottom_width",
78
- )
79
- _SIDE_BORDER_COLOR_KEYS = (
80
- "border_left_color",
81
- "border_top_color",
82
- "border_right_color",
83
- "border_bottom_color",
84
- )
85
- # Order matches GradientDrawable.setCornerRadii: top-left, top-right,
86
- # bottom-right, bottom-left (each corner takes an x and a y radius).
87
- _CORNER_RADIUS_KEYS = (
88
- "border_top_left_radius",
89
- "border_top_right_radius",
90
- "border_bottom_right_radius",
91
- "border_bottom_left_radius",
92
- )
93
- _DRAWABLE_STYLE_KEYS = (
94
- "background_color",
95
- "border_radius",
96
- "border_width",
97
- "border_color",
98
- *_CORNER_RADIUS_KEYS,
99
- *_SIDE_BORDER_WIDTH_KEYS,
100
- *_SIDE_BORDER_COLOR_KEYS,
101
- )
102
-
103
-
104
- # ======================================================================
105
- # Shared helpers
106
- # ======================================================================
107
-
108
-
109
- def _ctx() -> Any:
110
- return get_android_context()
111
-
112
-
113
- def _pn_runtime_class(class_name: str) -> Any:
114
- """Resolve a PythonNative Android helper class for the running app.
115
-
116
- The Android template's helper classes (e.g.
117
- ``PNAccessibilityDelegate``, ``PNBorderDrawable``) live in the
118
- app's own package, which the ``pn`` CLI relocates to the configured
119
- ``application_id`` at build time. Deriving the package from the
120
- runtime ``Context`` (rather than hardcoding the template package)
121
- keeps these lookups correct for any app id.
122
-
123
- Args:
124
- class_name: The class name within the app package, e.g.
125
- ``"PNBorderDrawable"``.
126
-
127
- Returns:
128
- The resolved Java class.
129
- """
130
- package = _ctx().getPackageName()
131
- return jclass(f"{package}.{class_name}")
132
-
133
-
134
- # Cached lookup for the template's PNFrameLayout container class.
135
- # ``None`` means "not resolved yet"; ``False`` means "this generated
136
- # project predates the class", so creation falls back to FrameLayout
137
- # without retrying the (exception-throwing) lookup per view.
138
- _pn_container_cls: Any = None
139
-
140
-
141
- def _new_container() -> Any:
142
- """Create a container view, preferring the template's ``PNFrameLayout``.
143
-
144
- ``PNFrameLayout`` adds native ``pointer_events`` interception
145
- (``onInterceptTouchEvent`` / ``dispatchTouchEvent``), which
146
- Chaquopy can't express from Python. Older generated projects
147
- without the class get a plain ``FrameLayout``; the
148
- ``pointer_events`` prop then degrades to the Python-side
149
- approximation in
150
- [`_apply_pointer_events`][pythonnative.native_views.android._apply_pointer_events].
151
- """
152
- global _pn_container_cls
153
- if _pn_container_cls is None:
154
- try:
155
- _pn_container_cls = _pn_runtime_class("PNFrameLayout")
156
- except Exception:
157
- _pn_container_cls = False
158
- if _pn_container_cls is not False:
159
- try:
160
- return _pn_container_cls(_ctx())
161
- except Exception:
162
- diagnostics.swallowed("android._new_container")
163
- return jclass("android.widget.FrameLayout")(_ctx())
164
-
165
-
166
- def _signed_color(value: int) -> int:
167
- """Clamp a Python color int into Java's signed 32-bit range."""
168
- value &= 0xFFFFFFFF
169
- if value >= 0x80000000:
170
- value -= 0x100000000
171
- return value
172
-
173
-
174
- def _density() -> float:
175
- return float(_ctx().getResources().getDisplayMetrics().density)
176
-
177
-
178
- def _dp(value: float) -> int:
179
- return int(round(value * _density()))
180
-
181
-
182
- def _java_id(jobj: Any) -> int:
183
- """Return ``System.identityHashCode(jobj)`` as a stable lookup key.
184
-
185
- Chaquopy's ``JavaObject.__setattr__`` rejects unknown Python
186
- attributes, so per-view bookkeeping can't live on the wrapper.
187
- The JVM identity hash is stable for the lifetime of the Java
188
- object and identical across every Python wrapper that proxies it.
189
- """
190
- System = jclass("java.lang.System")
191
- return int(System.identityHashCode(jobj))
192
-
193
-
194
- # Tag table: java identity -> reconciler tag, and tag -> per-view state.
195
- _view_tags: Dict[int, int] = {}
196
- _view_state: Dict[int, Dict[str, Any]] = {}
197
-
198
-
199
- def _remember(view: Any, tag: int) -> None:
200
- _view_tags[_java_id(view)] = tag
201
- _view_state[tag] = {"props": {}}
202
-
203
-
204
- def _tag_of(view: Any) -> Optional[int]:
205
- return _view_tags.get(_java_id(view))
206
-
207
-
208
- def _state_of(view: Any) -> Dict[str, Any]:
209
- tag = _tag_of(view)
210
- if tag is None:
211
- return {}
212
- return _view_state.setdefault(tag, {"props": {}})
213
-
214
-
215
- def _forget(view: Any) -> None:
216
- tag = _view_tags.pop(_java_id(view), None)
217
- if tag is not None:
218
- _view_state.pop(tag, None)
219
-
220
-
221
- def _fire(view: Any, name: str, *args: Any) -> bool:
222
- """Dispatch event ``name`` for ``view`` through the tag registry."""
223
- tag = _tag_of(view)
224
- if tag is None:
225
- return False
226
- return dispatch_event(tag, name, *args)
227
-
228
-
229
- def _has_event(view: Any, name: str) -> bool:
230
- """Whether the element wired a callback named ``name`` this render."""
231
- merged = _state_of(view).get("props") or {}
232
- return name in event_names(merged)
233
-
234
-
235
- def _apply_side_border(view: Any, props: Dict[str, Any]) -> bool:
236
- """Apply per-side borders via the template's ``PNBorderDrawable``.
237
-
238
- Activated when any ``border_<side>_width`` key is present. In that
239
- mode the drawable owns all four edges: each side's width falls
240
- back to the uniform ``border_width`` (else 0) and its color to
241
- ``border_color`` (else black), and the background fill and corner
242
- radius are baked into the same drawable.
243
-
244
- Returns:
245
- ``True`` if the drawable was applied; ``False`` when no
246
- per-side key is present or the helper class is unavailable
247
- (older generated projects), in which case the caller falls
248
- back to the uniform ``GradientDrawable`` path.
249
- """
250
- if not any(props.get(k) is not None for k in _SIDE_BORDER_WIDTH_KEYS):
251
- return False
252
- try:
253
- PNBorderDrawable = _pn_runtime_class("PNBorderDrawable")
254
- except Exception:
255
- return False
256
- try:
257
- base_width = float(props.get("border_width") or 0.0)
258
- base_color = props.get("border_color") or "#000000"
259
- widths = [
260
- float(_dp(float(props[k]))) if props.get(k) is not None else float(_dp(base_width))
261
- for k in _SIDE_BORDER_WIDTH_KEYS
262
- ]
263
- colors = [
264
- _signed_color(parse_color_int(props[k] if props.get(k) is not None else base_color))
265
- for k in _SIDE_BORDER_COLOR_KEYS
266
- ]
267
- has_bg = props.get("background_color") is not None
268
- bg = _signed_color(parse_color_int(props["background_color"])) if has_bg else 0
269
- radius = float(_dp(float(props.get("border_radius") or 0.0)))
270
- drawable = PNBorderDrawable(has_bg, bg, radius, widths, colors)
271
- view.setBackground(drawable)
272
- try:
273
- view.invalidate()
274
- except Exception:
275
- diagnostics.swallowed("android._apply_side_border")
276
- return True
277
- except Exception:
278
- return False
279
-
280
-
281
- def _apply_border(view: Any, props: Dict[str, Any]) -> None:
282
- """Apply border_radius / border_width / border_color via a GradientDrawable.
283
-
284
- Android's standard ``View`` doesn't natively support arbitrary
285
- rounded backgrounds; the canonical workaround is to set the
286
- background to a ``GradientDrawable`` (the "shape" XML primitive)
287
- that renders the corner radius and stroke. We preserve any
288
- existing ``background_color`` by re-baking it into the drawable.
289
- Per-side borders route through
290
- [`_apply_side_border`][pythonnative.native_views.android._apply_side_border]
291
- instead.
292
- """
293
- if _apply_side_border(view, props):
294
- return
295
- has_border = any(k in props for k in ("border_radius", "border_width", "border_color", *_CORNER_RADIUS_KEYS))
296
- has_bg = "background_color" in props and props["background_color"] is not None
297
- if not has_border and not has_bg:
298
- return
299
- try:
300
- GradientDrawable = jclass("android.graphics.drawable.GradientDrawable")
301
- drawable = GradientDrawable()
302
- if "background_color" in props and props["background_color"] is not None:
303
- try:
304
- drawable.setColor(parse_color_int(props["background_color"]))
305
- except Exception:
306
- diagnostics.swallowed("android._apply_border")
307
- if any(props.get(k) is not None for k in _CORNER_RADIUS_KEYS):
308
- # Per-corner radii: unspecified corners inherit the uniform
309
- # ``border_radius`` (else 0). ``setCornerRadii`` takes eight
310
- # floats, an (x, y) pair per corner in _CORNER_RADIUS_KEYS
311
- # order, each in pixels.
312
- try:
313
- base_radius = float(props.get("border_radius") or 0.0)
314
- radii = []
315
- for key in _CORNER_RADIUS_KEYS:
316
- r = float(_dp(float(props[key] if props.get(key) is not None else base_radius)))
317
- radii.extend([r, r])
318
- drawable.setCornerRadii(radii)
319
- except Exception:
320
- diagnostics.swallowed("android._apply_border")
321
- elif "border_radius" in props and props["border_radius"] is not None:
322
- try:
323
- drawable.setCornerRadius(float(_dp(float(props["border_radius"]))))
324
- except Exception:
325
- diagnostics.swallowed("android._apply_border")
326
- if ("border_width" in props and props["border_width"] is not None) or (
327
- "border_color" in props and props["border_color"] is not None
328
- ):
329
- width = props.get("border_width", 1)
330
- color = props.get("border_color", "#000000")
331
- try:
332
- drawable.setStroke(
333
- int(_dp(float(width or 0))),
334
- parse_color_int(color or "#000000"),
335
- )
336
- except Exception:
337
- diagnostics.swallowed("android._apply_border")
338
- view.setBackground(drawable)
339
- try:
340
- drawable.invalidateSelf()
341
- except Exception:
342
- diagnostics.swallowed("android._apply_border")
343
- try:
344
- view.invalidate()
345
- except Exception:
346
- diagnostics.swallowed("android._apply_border")
347
- except Exception:
348
- diagnostics.swallowed("android._apply_border")
349
-
350
-
351
- def _set_animated_background(view: Any, value: Any) -> None:
352
- """Apply one animated ``background_color`` frame without losing borders.
353
-
354
- Interpolations hand this module raw color values ("#AARRGGBB" or
355
- "#RRGGBB" strings, or ints). Views whose background is a
356
- border-rendering drawable (the uniform ``GradientDrawable`` or the
357
- per-side ``PNBorderDrawable``) must keep their corner radii and
358
- strokes while the fill animates, so the color routes through the
359
- same [`_apply_border`][pythonnative.native_views.android._apply_border]
360
- machinery ``update()`` uses. When the current background already is
361
- a ``GradientDrawable``, its fill is mutated in place, so no
362
- drawable is allocated per animation frame. Plain views take the
363
- direct ``setBackgroundColor`` path.
364
- """
365
- state = _state_of(view)
366
- visual = state.get("visual")
367
- has_border = isinstance(visual, dict) and any(
368
- visual.get(k) is not None for k in _DRAWABLE_STYLE_KEYS if k != "background_color"
369
- )
370
- if has_border:
371
- visual = dict(visual)
372
- visual["background_color"] = value
373
- state["visual"] = visual
374
- try:
375
- GradientDrawable = jclass("android.graphics.drawable.GradientDrawable")
376
- bg = view.getBackground()
377
- if bg is not None and isinstance(bg, GradientDrawable):
378
- bg.setColor(parse_color_int(value))
379
- return
380
- except Exception:
381
- diagnostics.swallowed("android._set_animated_background")
382
- # Per-side ``PNBorderDrawable`` backgrounds (immutable fill) and
383
- # first frames before any drawable exists re-bake the full set.
384
- _apply_border(view, visual)
385
- return
386
- view.setBackgroundColor(parse_color_int(value))
387
-
388
-
389
- def _apply_shadow(view: Any, props: Dict[str, Any]) -> None:
390
- """Apply shadow props via elevation plus tinted outline shadows.
391
-
392
- Android renders view shadows from ``elevation``; iOS-style
393
- ``shadow_radius`` maps onto it so cross-platform styles work.
394
- On API 28+ the ambient/spot shadow colors are tinted with
395
- ``shadow_color`` (with ``shadow_opacity`` baked into the alpha
396
- channel), which is as close to iOS's layer shadows as the
397
- platform allows. ``shadow_offset`` has no Android equivalent and
398
- is ignored.
399
- """
400
- elevation = props.get("elevation")
401
- if elevation is None and "shadow_radius" in props:
402
- elevation = props.get("shadow_radius")
403
- if elevation is None and (props.get("shadow_color") is not None or props.get("shadow_opacity") is not None):
404
- # Shadow requested without an explicit size: use a Material
405
- # card-like default so the shadow is actually visible.
406
- elevation = 4.0
407
- if elevation is None:
408
- return
409
- try:
410
- view.setElevation(float(_dp(float(elevation))))
411
- except Exception:
412
- diagnostics.swallowed("android._apply_shadow")
413
- color = props.get("shadow_color")
414
- if color is None:
415
- return
416
- try:
417
- Build = jclass("android.os.Build")
418
- if int(Build.VERSION.SDK_INT) < 28:
419
- return
420
- argb = parse_color_int(color) & 0xFFFFFFFF
421
- opacity = props.get("shadow_opacity")
422
- if opacity is not None:
423
- alpha = int(max(0.0, min(1.0, float(opacity))) * 255)
424
- argb = (argb & 0x00FFFFFF) | (alpha << 24)
425
- signed = _signed_color(argb)
426
- view.setOutlineAmbientShadowColor(signed)
427
- view.setOutlineSpotShadowColor(signed)
428
- except Exception:
429
- diagnostics.swallowed("android._apply_shadow")
430
-
431
-
432
- def _apply_transform(view: Any, props: Dict[str, Any]) -> None:
433
- """Apply transform list to scale/rotation/translation properties."""
434
- if "transform" not in props:
435
- return
436
- spec = props["transform"]
437
- if spec is None:
438
- try:
439
- view.setRotation(0.0)
440
- view.setScaleX(1.0)
441
- view.setScaleY(1.0)
442
- view.setTranslationX(0.0)
443
- view.setTranslationY(0.0)
444
- except Exception:
445
- diagnostics.swallowed("android._apply_transform")
446
- return
447
- entries = spec if isinstance(spec, list) else [spec]
448
- for entry in entries:
449
- if not isinstance(entry, dict):
450
- continue
451
- try:
452
- if "rotate" in entry:
453
- v = entry["rotate"]
454
- if isinstance(v, str) and v.endswith("deg"):
455
- angle = float(v[:-3])
456
- elif isinstance(v, str) and v.endswith("rad"):
457
- angle = math.degrees(float(v[:-3]))
458
- else:
459
- angle = float(v)
460
- view.setRotation(angle)
461
- if "scale" in entry:
462
- s = float(entry["scale"])
463
- view.setScaleX(s)
464
- view.setScaleY(s)
465
- if "scale_x" in entry:
466
- view.setScaleX(float(entry["scale_x"]))
467
- if "scale_y" in entry:
468
- view.setScaleY(float(entry["scale_y"]))
469
- if "translate_x" in entry:
470
- view.setTranslationX(float(_dp(float(entry["translate_x"]))))
471
- if "translate_y" in entry:
472
- view.setTranslationY(float(_dp(float(entry["translate_y"]))))
473
- except Exception:
474
- diagnostics.swallowed("android._apply_transform")
475
-
476
-
477
- def _apply_accessibility(view: Any, props: Dict[str, Any]) -> None:
478
- """Apply accessibility props (label / accessible / state / live region / test_id)."""
479
- if "accessible" in props:
480
- try:
481
- View = jclass("android.view.View")
482
- view.setImportantForAccessibility(
483
- View.IMPORTANT_FOR_ACCESSIBILITY_YES if props["accessible"] else View.IMPORTANT_FOR_ACCESSIBILITY_NO
484
- )
485
- except Exception:
486
- diagnostics.swallowed("android._apply_accessibility")
487
- if "accessibility_label" in props:
488
- try:
489
- label = props["accessibility_label"]
490
- view.setContentDescription(str(label) if label is not None else None)
491
- except Exception:
492
- diagnostics.swallowed("android._apply_accessibility")
493
- if "accessibility_live_region" in props:
494
- try:
495
- mode = {"none": 0, "polite": 1, "assertive": 2}.get(
496
- str(props["accessibility_live_region"] or "none").lower(), 0
497
- )
498
- view.setAccessibilityLiveRegion(mode)
499
- except Exception:
500
- diagnostics.swallowed("android._apply_accessibility")
501
-
502
- state = props.get("accessibility_state")
503
- if isinstance(state, dict) and "selected" in state:
504
- try:
505
- view.setSelected(bool(state["selected"]))
506
- except Exception:
507
- diagnostics.swallowed("android._apply_accessibility")
508
- test_id = props.get("test_id")
509
- if test_id is None and not isinstance(state, dict):
510
- return
511
- # test_id (exposed as `resource-id` to UI Automator tools) and the
512
- # remaining state flags need an AccessibilityDelegate, which Python
513
- # can't subclass directly (Chaquopy proxies interfaces only); the
514
- # template ships PNAccessibilityDelegate for this. Older generated
515
- # projects without the class simply skip these props.
516
- try:
517
- vs = _state_of(view)
518
- delegate = vs.get("a11y_delegate")
519
- if delegate is None:
520
- PNAccessibilityDelegate = _pn_runtime_class("PNAccessibilityDelegate")
521
- delegate = PNAccessibilityDelegate()
522
- view.setAccessibilityDelegate(delegate)
523
- vs["a11y_delegate"] = delegate
524
- if test_id is not None:
525
- delegate.setTestId(str(test_id))
526
- view.setTag(str(test_id))
527
- if isinstance(state, dict):
528
- delegate.setStateDisabled(state.get("disabled"))
529
- delegate.setStateSelected(state.get("selected"))
530
- delegate.setStateChecked(state.get("checked"))
531
- delegate.setStateBusy(state.get("busy"))
532
- delegate.setStateExpanded(state.get("expanded"))
533
- except Exception:
534
- diagnostics.swallowed("android._apply_accessibility")
535
-
536
-
537
- def _pointer_events_blocked(view: Any) -> bool:
538
- """Whether ``pointer_events`` disables the view's own touch handling.
539
-
540
- Consulted at the top of every touch-stream proxy (gesture, press)
541
- so listeners installed at create time honor the prop without being
542
- re-wired: ``"none"`` and ``"box_none"`` both mean the view itself
543
- must not observe or consume the stream.
544
- """
545
- merged = _state_of(view).get("props") or {}
546
- return merged.get("pointer_events") in ("none", "box_none")
547
-
548
-
549
- def _apply_pointer_events(view: Any, value: Any) -> None:
550
- """Apply the ``pointer_events`` prop (``None`` restores ``"auto"``).
551
-
552
- Two cooperating layers implement the modes:
553
-
554
- - **Native interception** (preferred): containers built by
555
- [`_new_container`][pythonnative.native_views.android._new_container]
556
- are the template's ``PNFrameLayout``, whose
557
- ``setPointerEventsMode(String)`` drives real
558
- ``dispatchTouchEvent`` / ``onInterceptTouchEvent`` overrides.
559
- ``"none"`` declines the whole dispatch so touches pass through
560
- to views underneath, ``"box_only"`` intercepts events away from
561
- children, and ``"box_none"`` never intercepts. The call is
562
- guarded so older generated projects without the class (and
563
- non-container views) fall back cleanly.
564
- - **Python-side muting**: ``setClickable`` toggles whether the
565
- view claims events, and the touch-stream proxies consult
566
- [`_pointer_events_blocked`][pythonnative.native_views.android._pointer_events_blocked]
567
- so gesture/press wiring goes quiet without being torn down.
568
- ``"auto"`` / removal restores the original clickability
569
- captured before the first override.
570
-
571
- On a plain ``FrameLayout`` (old template) the fallback keeps the
572
- previous approximation: ``"none"`` silences the view itself but
573
- interactive children keep receiving touches, and ``"box_only"``
574
- only claims events no interactive child consumed. ``"box_none"``
575
- is exact either way, since children need no help.
576
- """
577
- mode = value if value is not None else "auto"
578
- state = _state_of(view)
579
- try:
580
- view.setPointerEventsMode(str(mode))
581
- except Exception:
582
- # Plain FrameLayout, a leaf widget, or an older template
583
- # without PNFrameLayout; the Python-side fallback still runs.
584
- pass
585
- try:
586
- if mode in ("none", "box_none"):
587
- state.setdefault("pe_was_clickable", bool(view.isClickable()))
588
- view.setClickable(False)
589
- elif mode == "box_only":
590
- state.setdefault("pe_was_clickable", bool(view.isClickable()))
591
- view.setClickable(True)
592
- elif "pe_was_clickable" in state:
593
- view.setClickable(bool(state.pop("pe_was_clickable")))
594
- except Exception:
595
- diagnostics.swallowed("android._apply_pointer_events")
596
-
597
-
598
- def _hit_slop_insets(value: Any) -> Optional[Tuple[float, float, float, float]]:
599
- """Normalize a ``hit_slop`` prop into ``(top, left, bottom, right)`` points.
600
-
601
- Accepts a uniform number or a dict with any of ``top`` / ``left``
602
- / ``bottom`` / ``right``. Returns ``None`` for absent or
603
- unparseable values.
604
- """
605
- if value is None:
606
- return None
607
- if isinstance(value, dict):
608
- try:
609
- return (
610
- float(value.get("top") or 0.0),
611
- float(value.get("left") or 0.0),
612
- float(value.get("bottom") or 0.0),
613
- float(value.get("right") or 0.0),
614
- )
615
- except Exception:
616
- return None
617
- try:
618
- uniform = float(value)
619
- except Exception:
620
- return None
621
- return (uniform, uniform, uniform, uniform)
622
-
623
-
624
- def _update_hit_slop(view: Any) -> None:
625
- """Extend the view's touch target via a ``TouchDelegate`` on its parent.
626
-
627
- The standard Android mechanism: the parent forwards touches
628
- landing inside an expanded ``Rect`` (the view's frame plus the
629
- slop insets, in parent-relative pixels) to the view. Parents are
630
- PythonNative-managed ``FrameLayout``s, so installing the delegate
631
- is safe; a parent only holds one delegate, so among siblings the
632
- last ``hit_slop`` applied wins (matching the platform limitation).
633
-
634
- Requires a known frame, so
635
- [`set_frame`][pythonnative.native_views.android.AndroidViewHandler.set_frame]
636
- records the frame and re-invokes this; prop-only updates before
637
- the first frame defer until layout runs.
638
- """
639
- state = _state_of(view)
640
- frame = state.get("frame")
641
- if frame is None:
642
- return
643
- merged = state.get("props") or {}
644
- insets = _hit_slop_insets(merged.get("hit_slop"))
645
- try:
646
- parent = view.getParent()
647
- if parent is None:
648
- return
649
- if insets is None:
650
- if state.pop("hit_slop_active", None):
651
- parent.setTouchDelegate(None)
652
- return
653
- top, left, bottom, right = insets
654
- x, y, width, height = frame
655
- Rect = jclass("android.graphics.Rect")
656
- TouchDelegate = jclass("android.view.TouchDelegate")
657
- rect = Rect(
658
- _dp(x - left),
659
- _dp(y - top),
660
- _dp(x + width + right),
661
- _dp(y + height + bottom),
662
- )
663
- parent.setTouchDelegate(TouchDelegate(rect, view))
664
- state["hit_slop_active"] = True
665
- except Exception:
666
- diagnostics.swallowed("android._update_hit_slop")
667
-
668
-
669
- def _apply_common_visual(view: Any, props: Dict[str, Any]) -> None:
670
- """Apply visual properties shared across many handlers."""
671
- has_drawable_keys = any(k in props for k in _DRAWABLE_STYLE_KEYS)
672
- if has_drawable_keys:
673
- state = _state_of(view)
674
- visual_props = dict(state.get("visual") or {})
675
- for key in _DRAWABLE_STYLE_KEYS:
676
- if key in props:
677
- visual_props[key] = props[key]
678
- state["visual"] = visual_props
679
- _apply_border(view, visual_props)
680
- if "overflow" in props:
681
- clip = props["overflow"] == "hidden"
682
- try:
683
- view.setClipChildren(clip)
684
- view.setClipToPadding(clip)
685
- except Exception:
686
- diagnostics.swallowed("android._apply_common_visual")
687
- if "display" in props:
688
- # Layout already zeroes the subtree; ``INVISIBLE`` (not ``GONE``,
689
- # since Python owns layout) also drops it from touch delivery and
690
- # the accessibility tree (inactive tabs, covered stack screens).
691
- try:
692
- View = jclass("android.view.View")
693
- view.setVisibility(View.INVISIBLE if props["display"] == "none" else View.VISIBLE)
694
- except Exception:
695
- diagnostics.swallowed("android._apply_common_visual")
696
- if "opacity" in props and props["opacity"] is not None:
697
- try:
698
- view.setAlpha(float(props["opacity"]))
699
- except Exception:
700
- diagnostics.swallowed("android._apply_common_visual")
701
- if "z_index" in props:
702
- # ``setZ`` takes pixels, so scale like other coordinates; a
703
- # removal (``None``) resets the view to the default plane.
704
- try:
705
- z = props["z_index"]
706
- view.setZ(float(_dp(float(z))) if z is not None else 0.0)
707
- except Exception:
708
- diagnostics.swallowed("android._apply_common_visual")
709
- if "pointer_events" in props:
710
- _apply_pointer_events(view, props["pointer_events"])
711
- if "hit_slop" in props:
712
- _update_hit_slop(view)
713
- _apply_shadow(view, props)
714
- _apply_transform(view, props)
715
- _apply_accessibility(view, props)
716
-
717
-
718
- # ======================================================================
719
- # Gesture wiring (MotionEvent -> GestureArbiter -> dispatch_event)
720
- # ======================================================================
721
-
722
-
723
- def _wire_gestures(view: Any, specs: Any) -> None:
724
- """Feed ``MotionEvent`` streams on ``view`` into a `GestureArbiter`.
725
-
726
- The arbiter emits ``(gesture_index, payload)`` pairs which are
727
- forwarded as ``gesture:<i>`` events for this view's tag. Specs
728
- (including ``simultaneous`` / ``wait_for`` metadata and fling
729
- configs) pass through to the arbiter untouched; all arbitration
730
- and recognition live in ``pythonnative.gestures``. Time-based
731
- deadlines (long press, multi-tap windows) are polled with a
732
- main-looper ``Handler`` scheduled at the arbiter's
733
- ``next_deadline()``. When a pan activates, the parent is asked
734
- not to intercept so an enclosing ScrollView can't steal the drag.
735
- """
736
- state = _state_of(view)
737
- if not isinstance(specs, (list, tuple)) or not specs:
738
- state["arbiter"] = None
739
- return
740
-
741
- def _emit(index: int, payload: Dict[str, Any]) -> None:
742
- _fire(view, f"gesture:{index}", payload)
743
-
744
- arbiter = make_arbiter([s for s in specs if isinstance(s, dict)], _emit)
745
- state["arbiter"] = arbiter
746
- if state.get("gestures_bound"):
747
- return
748
- state["gestures_bound"] = True
749
- _bind_touch_stream(view, state)
750
-
751
-
752
- def _schedule_arbiter_poll(view: Any, state: Dict[str, Any]) -> None:
753
- """Schedule a main-looper poll at the arbiter's next deadline.
754
-
755
- Deadlines cover every time-based recognizer decision: long-press
756
- activation and the multi-tap windows that flush an Exclusive
757
- double-tap/single-tap buffer. Called after every pointer event
758
- (not just DOWN) because a deadline can appear on UP, when no
759
- further touch events arrive to drive the arbiter forward.
760
-
761
- A pending poll at or before the requested deadline is reused
762
- rather than stacking duplicate runnables; each poll reschedules
763
- itself for the following deadline until none remains.
764
- """
765
- arbiter = state.get("arbiter")
766
- if arbiter is None:
767
- return
768
- deadline = arbiter.next_deadline()
769
- if deadline is None:
770
- return
771
- pending = state.get("poll_deadline")
772
- if pending is not None and pending <= deadline + 0.001:
773
- return
774
- delay_ms = max(1, int((deadline - time.monotonic()) * 1000.0))
775
- try:
776
- Handler = jclass("android.os.Handler")
777
- Looper = jclass("android.os.Looper")
778
- Runnable = jclass("java.lang.Runnable")
779
- handler = state.get("poll_handler")
780
- if handler is None:
781
- handler = Handler(Looper.getMainLooper())
782
- state["poll_handler"] = handler
783
- token = state.get("poll_token", 0) + 1
784
- state["poll_token"] = token
785
- state["poll_deadline"] = deadline
786
-
787
- class _PollRunnable(dynamic_proxy(Runnable)):
788
- def run(self) -> None:
789
- if state.get("poll_token") == token:
790
- state["poll_deadline"] = None
791
- live = state.get("arbiter")
792
- if live is not None:
793
- live.poll(time.monotonic())
794
- _schedule_arbiter_poll(view, state)
795
-
796
- handler.postDelayed(_PollRunnable(), delay_ms)
797
- except Exception:
798
- state["poll_deadline"] = None
799
- diagnostics.swallowed("android._schedule_arbiter_poll")
800
-
801
-
802
- def _bind_touch_stream(view: Any, state: Dict[str, Any]) -> None:
803
- """Install one ``OnTouchListener`` that forwards every pointer to the arbiter."""
804
- try:
805
- OnTouchListener = jclass("android.view.View$OnTouchListener")
806
-
807
- class _GestureTouchProxy(dynamic_proxy(OnTouchListener)):
808
- def onTouch(self, v: Any, event: Any) -> bool:
809
- if _pointer_events_blocked(v):
810
- return False
811
- return bool(_feed_motion_event(v, state, event))
812
-
813
- view.setOnTouchListener(_GestureTouchProxy())
814
- except Exception:
815
- diagnostics.swallowed("android._bind_touch_stream")
816
-
817
-
818
- def _feed_motion_event(view: Any, state: Dict[str, Any], event: Any) -> bool:
819
- """Translate one ``MotionEvent`` into arbiter pointer calls.
820
-
821
- Returns ``True`` to keep receiving the stream while any gesture
822
- spec is wired.
823
- """
824
- arbiter = state.get("arbiter")
825
- if arbiter is None:
826
- return False
827
- try:
828
- action = int(event.getActionMasked())
829
- t = time.monotonic()
830
- density = _density() or 1.0
831
- if action in (0, 5): # DOWN / POINTER_DOWN
832
- idx = int(event.getActionIndex())
833
- pid = int(event.getPointerId(idx))
834
- arbiter.pointer_down(pid, float(event.getX(idx)) / density, float(event.getY(idx)) / density, t)
835
- elif action == 2: # MOVE
836
- for i in range(int(event.getPointerCount())):
837
- pid = int(event.getPointerId(i))
838
- arbiter.pointer_move(pid, float(event.getX(i)) / density, float(event.getY(i)) / density, t)
839
- if arbiter.has_active_pan():
840
- try:
841
- parent = view.getParent()
842
- if parent is not None:
843
- parent.requestDisallowInterceptTouchEvent(True)
844
- except Exception:
845
- diagnostics.swallowed("android._feed_motion_event")
846
- elif action in (1, 6): # UP / POINTER_UP
847
- idx = int(event.getActionIndex())
848
- pid = int(event.getPointerId(idx))
849
- arbiter.pointer_up(pid, float(event.getX(idx)) / density, float(event.getY(idx)) / density, t)
850
- elif action == 3: # CANCEL
851
- arbiter.cancel(t)
852
- # Every event can move the next time-based deadline (long-press
853
- # activation, multi-tap windows opened on UP), so reschedule
854
- # unconditionally; the scheduler dedups pending polls.
855
- _schedule_arbiter_poll(view, state)
856
- return True
857
- except Exception:
858
- return True
859
-
860
-
861
- # ======================================================================
862
- # Native-driven animations (ObjectAnimator)
863
- # ======================================================================
864
-
865
- _native_anims: Dict[int, Dict[str, Any]] = {}
866
-
867
-
868
- def _interpolator_for(easing: str) -> Any:
869
- mapping = {
870
- "linear": "android.view.animation.LinearInterpolator",
871
- "ease_in": "android.view.animation.AccelerateInterpolator",
872
- "ease_out": "android.view.animation.DecelerateInterpolator",
873
- "ease_in_out": "android.view.animation.AccelerateDecelerateInterpolator",
874
- }
875
- cls = mapping.get(easing, "android.view.animation.AccelerateDecelerateInterpolator")
876
- return jclass(cls)()
877
-
878
-
879
- _ANIMATABLE_FLOAT_PROPS = {
880
- "opacity": ("alpha", 1.0),
881
- "translate_x": ("translationX", None), # dp -> px scaling
882
- "translate_y": ("translationY", None),
883
- "scale_x": ("scaleX", 1.0),
884
- "scale_y": ("scaleY", 1.0),
885
- "rotate": ("rotation", 1.0),
886
- }
887
-
888
-
889
- def _read_animated_value(view: Any, prop_name: str) -> Any:
890
- """Read the current (presentation) value of an animatable property."""
891
- try:
892
- density = _density() or 1.0
893
- if prop_name == "opacity":
894
- return float(view.getAlpha())
895
- if prop_name == "translate_x":
896
- return float(view.getTranslationX()) / density
897
- if prop_name == "translate_y":
898
- return float(view.getTranslationY()) / density
899
- if prop_name in ("scale", "scale_x"):
900
- return float(view.getScaleX())
901
- if prop_name == "scale_y":
902
- return float(view.getScaleY())
903
- if prop_name == "rotate":
904
- return float(view.getRotation())
905
- except Exception:
906
- diagnostics.swallowed("android._read_animated_value")
907
- return None
908
-
909
-
910
- def _make_end_listener(anim_id: int) -> Any:
911
- """Build an ``Animator.AnimatorListener`` that reports completion to Python."""
912
- AnimatorListener = jclass("android.animation.Animator$AnimatorListener")
913
-
914
- class _EndProxy(dynamic_proxy(AnimatorListener)):
915
- # ``onAnimationStart`` / ``onAnimationEnd`` accept an optional
916
- # ``isReverse``: API 26 added default two-arg overloads to
917
- # ``AnimatorListener``, and ``ValueAnimator`` invokes those, so
918
- # the java.lang.reflect.Proxy forwards two arguments here.
919
-
920
- def __init__(self) -> None:
921
- super().__init__()
922
- self._cancelled = False
923
-
924
- def onAnimationStart(self, animation: Any, isReverse: bool = False) -> None:
925
- pass
926
-
927
- def onAnimationRepeat(self, animation: Any) -> None:
928
- pass
929
-
930
- def onAnimationCancel(self, animation: Any) -> None:
931
- self._cancelled = True
932
-
933
- def onAnimationEnd(self, animation: Any, isReverse: bool = False) -> None:
934
- entry = _native_anims.pop(anim_id, None)
935
- if entry is None:
936
- return
937
- try:
938
- from ..animated import native_animation_completed
939
-
940
- native_animation_completed(anim_id, not self._cancelled)
941
- except Exception:
942
- diagnostics.swallowed("android._make_end_listener._EndProxy.onAnimationEnd")
943
-
944
- return _EndProxy()
945
-
946
-
947
- def _start_native_timing(view: Any, anim_id: int, prop_name: str, spec: Dict[str, Any]) -> bool:
948
- """Drive a ``timing`` spec with ``ObjectAnimator``. Returns success."""
949
- try:
950
- ObjectAnimator = jclass("android.animation.ObjectAnimator")
951
- from_val = float(spec.get("from", 0.0))
952
- to_val = float(spec.get("to", 0.0))
953
- duration = max(0, int(float(spec.get("duration_ms", 300.0))))
954
- density = _density() or 1.0
955
-
956
- if prop_name == "background_color":
957
- animator = ObjectAnimator.ofArgb(
958
- view,
959
- "backgroundColor",
960
- parse_color_int(int(from_val)),
961
- parse_color_int(int(to_val)),
962
- )
963
- elif prop_name == "scale":
964
- AnimatorSet = jclass("android.animation.AnimatorSet")
965
- sx = ObjectAnimator.ofFloat(view, "scaleX", from_val, to_val)
966
- sy = ObjectAnimator.ofFloat(view, "scaleY", from_val, to_val)
967
- group = AnimatorSet()
968
- group.playTogether([sx, sy])
969
- group.setDuration(duration)
970
- group.setInterpolator(_interpolator_for(str(spec.get("easing", "ease_in_out"))))
971
- group.addListener(_make_end_listener(anim_id))
972
- _native_anims[anim_id] = {"animator": group, "view": view, "prop": prop_name}
973
- group.start()
974
- return True
975
- else:
976
- java_prop, scale = _ANIMATABLE_FLOAT_PROPS.get(prop_name, (None, None))
977
- if java_prop is None:
978
- return False
979
- factor = density if scale is None else scale
980
- animator = ObjectAnimator.ofFloat(view, java_prop, from_val * factor, to_val * factor)
981
-
982
- animator.setDuration(duration)
983
- animator.setInterpolator(_interpolator_for(str(spec.get("easing", "ease_in_out"))))
984
- animator.addListener(_make_end_listener(anim_id))
985
- _native_anims[anim_id] = {"animator": animator, "view": view, "prop": prop_name}
986
- animator.start()
987
- return True
988
- except Exception:
989
- _native_anims.pop(anim_id, None)
990
- return False
991
-
992
-
993
- # ======================================================================
994
- # Base class with shared frame/measure/animation implementations
995
- # ======================================================================
996
-
997
-
998
- class AndroidViewHandler(ViewHandler):
999
- """Base class providing the shared protocol implementation.
1000
-
1001
- Subclasses implement
1002
- [`_build`][pythonnative.native_views.android.AndroidViewHandler._build]
1003
- (construct the widget) and
1004
- [`_apply`][pythonnative.native_views.android.AndroidViewHandler._apply]
1005
- (apply visual props); the base class owns tag registration,
1006
- gesture wiring, frame application, intrinsic measurement, and the
1007
- animation hooks.
1008
- """
1009
-
1010
- def create(self, tag: int, props: Dict[str, Any]) -> Any:
1011
- view = self._build(props)
1012
- _remember(view, tag)
1013
- _state_of(view)["props"] = dict(props)
1014
- self._apply(view, props, initial=True)
1015
- if props.get("gestures"):
1016
- _wire_gestures(view, props.get("gestures"))
1017
- return view
1018
-
1019
- def update(self, native_view: Any, changed_props: Dict[str, Any]) -> None:
1020
- state = _state_of(native_view)
1021
- merged = state.setdefault("props", {})
1022
- merged.update(changed_props)
1023
- self._apply(native_view, changed_props, initial=False)
1024
- if "gestures" in changed_props:
1025
- _wire_gestures(native_view, changed_props.get("gestures"))
1026
-
1027
- def destroy(self, native_view: Any) -> None:
1028
- self._teardown(native_view)
1029
- try:
1030
- parent = native_view.getParent()
1031
- if parent is not None:
1032
- parent.removeView(native_view)
1033
- except Exception:
1034
- diagnostics.swallowed("android.AndroidViewHandler.destroy")
1035
- _forget(native_view)
1036
-
1037
- def _build(self, props: Dict[str, Any]) -> Any:
1038
- raise NotImplementedError
1039
-
1040
- def _apply(self, view: Any, props: Dict[str, Any], initial: bool) -> None:
1041
- _apply_common_visual(view, props)
1042
-
1043
- def _teardown(self, native_view: Any) -> None:
1044
- """Subclass hook for extra cleanup before the view is released."""
1045
-
1046
- def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
1047
- if native_view is None:
1048
- return
1049
- try:
1050
- px_x = _dp(x)
1051
- px_y = _dp(y)
1052
- px_w = max(0, _dp(width))
1053
- px_h = max(0, _dp(height))
1054
- lp = native_view.getLayoutParams()
1055
- if lp is None:
1056
- FrameLP = jclass("android.widget.FrameLayout$LayoutParams")
1057
- lp = FrameLP(px_w, px_h)
1058
- else:
1059
- try:
1060
- lp.width = px_w
1061
- lp.height = px_h
1062
- except Exception:
1063
- diagnostics.swallowed("android.AndroidViewHandler.set_frame")
1064
- try:
1065
- lp.leftMargin = px_x
1066
- lp.topMargin = px_y
1067
- lp.rightMargin = 0
1068
- lp.bottomMargin = 0
1069
- except Exception:
1070
- diagnostics.swallowed("android.AndroidViewHandler.set_frame")
1071
- native_view.setLayoutParams(lp)
1072
- # ``hit_slop`` needs the frame (and an attached parent, which
1073
- # exists by the time frames are applied) to size its
1074
- # TouchDelegate rect, so record the frame and recompute here.
1075
- state = _state_of(native_view)
1076
- state["frame"] = (x, y, width, height)
1077
- if (state.get("props") or {}).get("hit_slop") is not None or state.get("hit_slop_active"):
1078
- _update_hit_slop(native_view)
1079
- except Exception:
1080
- diagnostics.swallowed("android.AndroidViewHandler.set_frame")
1081
-
1082
- def measure_intrinsic(
1083
- self,
1084
- native_view: Any,
1085
- max_width: float,
1086
- max_height: float,
1087
- ) -> Tuple[float, float]:
1088
- try:
1089
- density = _density()
1090
- View = jclass("android.view.View")
1091
- MeasureSpec = View.MeasureSpec
1092
- w_spec = (
1093
- MeasureSpec.makeMeasureSpec(int(_safe_max(max_width) * density), MeasureSpec.AT_MOST)
1094
- if math.isfinite(max_width)
1095
- else MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
1096
- )
1097
- h_spec = (
1098
- MeasureSpec.makeMeasureSpec(int(_safe_max(max_height) * density), MeasureSpec.AT_MOST)
1099
- if math.isfinite(max_height)
1100
- else MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
1101
- )
1102
- native_view.measure(w_spec, h_spec)
1103
- return (
1104
- native_view.getMeasuredWidth() / density,
1105
- native_view.getMeasuredHeight() / density,
1106
- )
1107
- except Exception:
1108
- return (0.0, 0.0)
1109
-
1110
- def set_animated_property(self, native_view: Any, prop_name: str, value: Any) -> None:
1111
- """Apply one Python-driven animation frame immediately.
1112
-
1113
- Handles float transform props (dp-scaled translations, scales,
1114
- rotation degrees, opacity) and interpolated color frames for
1115
- ``background_color`` / ``color`` ("#AARRGGBB" / "#RRGGBB"
1116
- strings or ints). Unknown props are silently ignored.
1117
- """
1118
- if native_view is None:
1119
- return
1120
- try:
1121
- if prop_name == "opacity":
1122
- native_view.setAlpha(float(value))
1123
- elif prop_name == "translate_x":
1124
- native_view.setTranslationX(float(_dp(float(value))))
1125
- elif prop_name == "translate_y":
1126
- native_view.setTranslationY(float(_dp(float(value))))
1127
- elif prop_name == "scale":
1128
- native_view.setScaleX(float(value))
1129
- native_view.setScaleY(float(value))
1130
- elif prop_name == "scale_x":
1131
- native_view.setScaleX(float(value))
1132
- elif prop_name == "scale_y":
1133
- native_view.setScaleY(float(value))
1134
- elif prop_name == "rotate":
1135
- native_view.setRotation(float(value))
1136
- elif prop_name == "background_color":
1137
- _set_animated_background(native_view, value)
1138
- elif prop_name == "color":
1139
- # Interpolations emit "#AARRGGBB" strings; parse_color_int
1140
- # accepts strings and raw ints alike. Views without
1141
- # setTextColor fall into the except below and skip.
1142
- native_view.setTextColor(parse_color_int(value))
1143
- except Exception:
1144
- diagnostics.swallowed("android.AndroidViewHandler.set_animated_property")
1145
-
1146
- def start_animation(
1147
- self,
1148
- native_view: Any,
1149
- anim_id: int,
1150
- prop_name: str,
1151
- spec: Dict[str, Any],
1152
- ) -> bool:
1153
- """Run ``timing`` specs on ``ObjectAnimator``; reject the rest.
1154
-
1155
- Springs and decay need the exact physics integration the
1156
- Python ticker implements, so they return ``False`` and fall
1157
- back rather than approximating with an interpolator.
1158
- """
1159
- if native_view is None or not isinstance(spec, dict):
1160
- return False
1161
- if str(spec.get("kind", "")) != "timing":
1162
- return False
1163
- return _start_native_timing(native_view, anim_id, prop_name, spec)
1164
-
1165
- def cancel_animation(self, native_view: Any, anim_id: int) -> Any:
1166
- entry = _native_anims.pop(anim_id, None)
1167
- if entry is None:
1168
- return None
1169
- try:
1170
- entry["animator"].cancel()
1171
- except Exception:
1172
- diagnostics.swallowed("android.AndroidViewHandler.cancel_animation")
1173
- return _read_animated_value(entry.get("view"), str(entry.get("prop", "")))
1174
-
1175
-
1176
- # ======================================================================
1177
- # Flex container handler (shared by Column, Row, View)
1178
- # ======================================================================
1179
-
1180
-
1181
- class FlexContainerHandler(AndroidViewHandler):
1182
- """Container for flex layout, a bare `FrameLayout`.
1183
-
1184
- All flex semantics (direction, alignment, distribution, padding)
1185
- are computed by the layout engine and applied via
1186
- [`set_frame`][pythonnative.native_views.android.AndroidViewHandler.set_frame].
1187
- The container itself is just a positioning surface (the template's
1188
- ``PNFrameLayout`` when available, so ``pointer_events`` gets real
1189
- touch interception).
1190
- """
1191
-
1192
- def _build(self, props: Dict[str, Any]) -> Any:
1193
- return _new_container()
1194
-
1195
- def insert_child(self, parent: Any, child: Any, index: int) -> None:
1196
- _insert_view(parent, child, index)
1197
-
1198
- def remove_child(self, parent: Any, child: Any) -> None:
1199
- try:
1200
- parent.removeView(child)
1201
- except Exception:
1202
- diagnostics.swallowed("android.FlexContainerHandler.remove_child")
1203
-
1204
-
1205
- def _insert_view(parent: Any, child: Any, index: int) -> None:
1206
- """Move-aware indexed insert into any ``ViewGroup``."""
1207
- try:
1208
- current_parent = child.getParent()
1209
- count = int(parent.getChildCount())
1210
- if current_parent is not None and _java_id(current_parent) == _java_id(parent):
1211
- current_index = int(parent.indexOfChild(child))
1212
- target = max(0, min(index, count - 1))
1213
- if current_index == target:
1214
- return
1215
- parent.removeView(child)
1216
- parent.addView(child, max(0, min(target, int(parent.getChildCount()))))
1217
- return
1218
- if current_parent is not None:
1219
- current_parent.removeView(child)
1220
- FrameLP = jclass("android.widget.FrameLayout$LayoutParams")
1221
- if child.getLayoutParams() is None:
1222
- child.setLayoutParams(FrameLP(0, 0))
1223
- parent.addView(child, max(0, min(index, int(parent.getChildCount()))))
1224
- except Exception:
1225
- try:
1226
- parent.addView(child)
1227
- except Exception:
1228
- diagnostics.swallowed("android._insert_view")
1229
-
1230
-
1231
- # ======================================================================
1232
- # Leaf handlers
1233
- # ======================================================================
1234
-
1235
-
1236
- def _typeface_for(weight: Any, italic: bool) -> Any:
1237
- Typeface = jclass("android.graphics.Typeface")
1238
- style = Typeface.NORMAL
1239
- is_bold = False
1240
- if isinstance(weight, str):
1241
- is_bold = weight.lower() in ("bold", "semibold", "black", "heavy", "extrabold")
1242
- elif isinstance(weight, (int, float)):
1243
- is_bold = float(weight) >= 600
1244
- if is_bold and italic:
1245
- style = Typeface.BOLD_ITALIC
1246
- elif is_bold:
1247
- style = Typeface.BOLD
1248
- elif italic:
1249
- style = Typeface.ITALIC
1250
- return style
1251
-
1252
-
1253
- def _build_spannable(spans: Any) -> Any:
1254
- """Build a ``SpannableStringBuilder`` from a rich-text span list.
1255
-
1256
- Each span dict carries ``text`` plus optional per-span overrides
1257
- (``color``, ``background_color``, ``font_size``, ``font_family``,
1258
- ``bold`` / ``italic`` / ``font_weight``, ``text_decoration``).
1259
- Spans without overrides inherit the TextView's base styling.
1260
- """
1261
- SpannableStringBuilder = jclass("android.text.SpannableStringBuilder")
1262
- Typeface = jclass("android.graphics.Typeface")
1263
- FLAG = 33 # Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1264
-
1265
- builder = SpannableStringBuilder()
1266
- for span in spans:
1267
- if not isinstance(span, dict):
1268
- continue
1269
- text = str(span.get("text", ""))
1270
- if not text:
1271
- continue
1272
- start = builder.length()
1273
- builder.append(text)
1274
- end = builder.length()
1275
-
1276
- def _set(obj: Any) -> None:
1277
- builder.setSpan(obj, start, end, FLAG)
1278
-
1279
- try:
1280
- if span.get("color") is not None:
1281
- ForegroundColorSpan = jclass("android.text.style.ForegroundColorSpan")
1282
- _set(ForegroundColorSpan(parse_color_int(span["color"])))
1283
- if span.get("background_color") is not None:
1284
- BackgroundColorSpan = jclass("android.text.style.BackgroundColorSpan")
1285
- _set(BackgroundColorSpan(parse_color_int(span["background_color"])))
1286
- if span.get("font_size") is not None:
1287
- AbsoluteSizeSpan = jclass("android.text.style.AbsoluteSizeSpan")
1288
- _set(AbsoluteSizeSpan(int(float(span["font_size"])), True)) # dip units
1289
- bold = bool(span.get("bold"))
1290
- weight = span.get("font_weight")
1291
- if not bold and weight is not None:
1292
- try:
1293
- bold = str(weight) in ("bold", "600", "700", "800", "900")
1294
- except Exception:
1295
- bold = False
1296
- italic = bool(span.get("italic"))
1297
- if bold or italic:
1298
- StyleSpan = jclass("android.text.style.StyleSpan")
1299
- if bold and italic:
1300
- _set(StyleSpan(Typeface.BOLD_ITALIC))
1301
- elif bold:
1302
- _set(StyleSpan(Typeface.BOLD))
1303
- else:
1304
- _set(StyleSpan(Typeface.ITALIC))
1305
- if span.get("font_family"):
1306
- TypefaceSpan = jclass("android.text.style.TypefaceSpan")
1307
- _set(TypefaceSpan(str(span["font_family"])))
1308
- decoration = span.get("text_decoration")
1309
- if decoration == "underline":
1310
- UnderlineSpan = jclass("android.text.style.UnderlineSpan")
1311
- _set(UnderlineSpan())
1312
- elif decoration == "line_through":
1313
- StrikethroughSpan = jclass("android.text.style.StrikethroughSpan")
1314
- _set(StrikethroughSpan())
1315
- except Exception:
1316
- diagnostics.swallowed("android._build_spannable")
1317
- return builder
1318
-
1319
-
1320
- class TextHandler(AndroidViewHandler):
1321
- def _build(self, props: Dict[str, Any]) -> Any:
1322
- return jclass("android.widget.TextView")(_ctx())
1323
-
1324
- def _apply(self, tv: Any, props: Dict[str, Any], initial: bool) -> None:
1325
- if "spans" in props or "text" in props or "text_transform" in props:
1326
- # ``update()`` merges changed props into the view state
1327
- # before calling ``_apply``, so the merged dict always has
1328
- # the latest text/spans pair (covering rich -> plain
1329
- # transitions where only ``spans`` changed).
1330
- merged = _state_of(tv).get("props") or props
1331
- transform = merged.get("text_transform")
1332
- spans = merged.get("spans")
1333
- if spans:
1334
- try:
1335
- tv.setText(_build_spannable(transform_spans(spans, transform)))
1336
- except Exception:
1337
- tv.setText(transform_text(merged.get("text"), transform))
1338
- else:
1339
- tv.setText(transform_text(merged.get("text"), transform))
1340
- if "font_size" in props and props["font_size"] is not None:
1341
- tv.setTextSize(float(props["font_size"]))
1342
- if "color" in props and props["color"] is not None:
1343
- tv.setTextColor(parse_color_int(props["color"]))
1344
- if any(k in props for k in ("font_family", "font_weight", "italic", "bold")):
1345
- try:
1346
- Typeface = jclass("android.graphics.Typeface")
1347
- merged = _state_of(tv).get("props") or props
1348
- family = merged.get("font_family")
1349
- weight = merged.get("font_weight") or ("bold" if merged.get("bold") else None)
1350
- italic = bool(merged.get("italic"))
1351
- style = _typeface_for(weight, italic)
1352
- if family:
1353
- base = Typeface.create(str(family), style)
1354
- tv.setTypeface(base)
1355
- else:
1356
- tv.setTypeface(tv.getTypeface(), style)
1357
- except Exception:
1358
- diagnostics.swallowed("android.TextHandler._apply")
1359
- if "max_lines" in props and props["max_lines"] is not None:
1360
- tv.setMaxLines(int(props["max_lines"]))
1361
- if "text_align" in props:
1362
- Gravity = jclass("android.view.Gravity")
1363
- mapping = {"left": Gravity.START, "center": Gravity.CENTER, "right": Gravity.END}
1364
- tv.setGravity(mapping.get(props["text_align"], Gravity.START))
1365
- if "letter_spacing" in props and props["letter_spacing"] is not None:
1366
- try:
1367
- # Android expects letter_spacing as ems (a unitless ratio of font size).
1368
- # Convert from points by dividing by ~font_size; if no font size, use 16.
1369
- merged = _state_of(tv).get("props") or props
1370
- size = float(merged.get("font_size") or 16.0)
1371
- tv.setLetterSpacing(float(props["letter_spacing"]) / max(size, 1.0))
1372
- except Exception:
1373
- diagnostics.swallowed("android.TextHandler._apply")
1374
- if "line_height" in props and props["line_height"] is not None:
1375
- try:
1376
- merged = _state_of(tv).get("props") or props
1377
- size = float(merged.get("font_size") or 16.0)
1378
- tv.setLineSpacing(0.0, float(props["line_height"]) / max(size, 1.0))
1379
- except Exception:
1380
- diagnostics.swallowed("android.TextHandler._apply")
1381
- if "text_decoration" in props:
1382
- try:
1383
- Paint = jclass("android.graphics.Paint")
1384
- flags = tv.getPaintFlags() & ~Paint.UNDERLINE_TEXT_FLAG & ~Paint.STRIKE_THRU_TEXT_FLAG
1385
- if props["text_decoration"] == "underline":
1386
- flags |= Paint.UNDERLINE_TEXT_FLAG
1387
- elif props["text_decoration"] == "line_through":
1388
- flags |= Paint.STRIKE_THRU_TEXT_FLAG
1389
- tv.setPaintFlags(flags)
1390
- except Exception:
1391
- diagnostics.swallowed("android.TextHandler._apply")
1392
- if any(k in props for k in TEXT_SHADOW_STYLE_KEYS):
1393
- self._apply_text_shadow(tv, _state_of(tv).get("props") or props)
1394
- _apply_common_visual(tv, props)
1395
-
1396
- @staticmethod
1397
- def _apply_text_shadow(tv: Any, merged: Dict[str, Any]) -> None:
1398
- """Map the ``text_shadow_*`` keys onto ``TextView.setShadowLayer``.
1399
-
1400
- The offset and radius are in dp (converted to pixels like every
1401
- other length); the platform disables the shadow when every
1402
- parameter is zero, which is also what clearing the keys does.
1403
- """
1404
- try:
1405
- if not any(merged.get(k) is not None for k in TEXT_SHADOW_STYLE_KEYS):
1406
- tv.setShadowLayer(0.0, 0.0, 0.0, 0)
1407
- return
1408
- color = merged.get("text_shadow_color")
1409
- argb = parse_color_int(color if color is not None else "#000000")
1410
- dx, dy = shadow_offset_xy(merged.get("text_shadow_offset"))
1411
- radius = merged.get("text_shadow_radius")
1412
- radius_px = float(_dp(float(radius))) if radius is not None else 0.0
1413
- # A zero radius with a non-zero offset would hide the shadow
1414
- # on some renderers; use a hairline blur so it still draws.
1415
- if radius_px <= 0.0 and (dx or dy):
1416
- radius_px = 0.01
1417
- tv.setShadowLayer(radius_px, float(_dp(dx)), float(_dp(dy)), argb)
1418
- except Exception:
1419
- diagnostics.swallowed("android.TextHandler._apply_text_shadow")
1420
-
1421
-
1422
- class ButtonHandler(AndroidViewHandler):
1423
- def _build(self, props: Dict[str, Any]) -> Any:
1424
- btn = jclass("android.widget.Button")(_ctx())
1425
-
1426
- class ClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
1427
- def onClick(self, view: Any) -> None:
1428
- _fire(view, "on_press")
1429
-
1430
- btn.setOnClickListener(ClickProxy())
1431
- return btn
1432
-
1433
- def _apply(self, btn: Any, props: Dict[str, Any], initial: bool) -> None:
1434
- if "title" in props:
1435
- btn.setText(str(props["title"]) if props["title"] is not None else "")
1436
- if "font_size" in props and props["font_size"] is not None:
1437
- btn.setTextSize(float(props["font_size"]))
1438
- if "color" in props and props["color"] is not None:
1439
- btn.setTextColor(parse_color_int(props["color"]))
1440
- if "enabled" in props:
1441
- btn.setEnabled(bool(props["enabled"]))
1442
- _apply_common_visual(btn, props)
1443
-
1444
-
1445
- class ScrollViewHandler(AndroidViewHandler):
1446
- """Scroll container: wraps a single child whose height is unbounded.
1447
-
1448
- Uses ``androidx.core.widget.NestedScrollView`` (vertical) or
1449
- ``android.widget.HorizontalScrollView`` so nested scroll views
1450
- cooperate instead of fighting over gestures.
1451
-
1452
- When a ``refresh_control`` prop is provided at creation, the scroll
1453
- view is wrapped in a ``SwipeRefreshLayout`` (the returned view is
1454
- the wrapper, and child management forwards into the inner scroll
1455
- view) so pull-to-refresh matches the iOS ``UIRefreshControl`` path.
1456
-
1457
- Scroll offsets are reported as ``on_scroll`` events with a
1458
- ``{"x": pts, "y": pts}`` payload. Imperative commands:
1459
- ``scroll_to_offset`` / ``scroll_to_end`` / ``get_scroll_offset``.
1460
- """
1461
-
1462
- def create(self, tag: int, props: Dict[str, Any]) -> Any:
1463
- horizontal = props.get("scroll_axis") == "horizontal"
1464
- if horizontal:
1465
- sv = jclass("android.widget.HorizontalScrollView")(_ctx())
1466
- else:
1467
- try:
1468
- sv = jclass("androidx.core.widget.NestedScrollView")(_ctx())
1469
- except Exception:
1470
- sv = jclass("android.widget.ScrollView")(_ctx())
1471
-
1472
- # Vertical scroll views are *always* wrapped in a (disabled)
1473
- # SwipeRefreshLayout. Wrapping later is impossible; the
1474
- # reconciler may reuse this view for a screen that adds a
1475
- # ``refresh_control`` prop afterwards (e.g. navigation swapping
1476
- # screens of the same shape), and re-parenting a mounted view
1477
- # mid-update is not safe. ``_apply_refresh`` simply toggles the
1478
- # wrapper's enabled state as the prop comes and goes.
1479
- outer = sv
1480
- if not horizontal:
1481
- wrapper = self._wrap_in_refresh(sv)
1482
- if wrapper is not None:
1483
- outer = wrapper
1484
-
1485
- _remember(outer, tag)
1486
- state = _state_of(outer)
1487
- state["props"] = dict(props)
1488
- state["scroll"] = sv
1489
- state["horizontal"] = horizontal
1490
- if outer is not sv:
1491
- state["refresh"] = outer
1492
- self._bind_refresh_listener(outer)
1493
- if not props.get("refresh_control"):
1494
- try:
1495
- outer.setEnabled(False)
1496
- except Exception:
1497
- diagnostics.swallowed("android.ScrollViewHandler.create")
1498
- self._bind_scroll_listener(outer, sv)
1499
- self._apply(outer, props, initial=True)
1500
- if props.get("gestures"):
1501
- _wire_gestures(outer, props.get("gestures"))
1502
- return outer
1503
-
1504
- def _apply(self, outer: Any, props: Dict[str, Any], initial: bool) -> None:
1505
- state = _state_of(outer)
1506
- sv = state.get("scroll", outer)
1507
- _apply_common_visual(sv, props)
1508
- if "shows_scroll_indicator" in props:
1509
- # Only present when ``False`` (hide); a removal restores bars.
1510
- show = props["shows_scroll_indicator"] is not False
1511
- try:
1512
- sv.setVerticalScrollBarEnabled(show)
1513
- sv.setHorizontalScrollBarEnabled(show)
1514
- except Exception:
1515
- diagnostics.swallowed("android.ScrollViewHandler._apply")
1516
- if "bounces" in props:
1517
- # ``bounces`` is only present when ``False``; map it to the
1518
- # closest analogue, the over-scroll (glow) mode.
1519
- try:
1520
- View = jclass("android.view.View")
1521
- mode = View.OVER_SCROLL_NEVER if props["bounces"] is False else View.OVER_SCROLL_IF_CONTENT_SCROLLS
1522
- sv.setOverScrollMode(mode)
1523
- except Exception:
1524
- diagnostics.swallowed("android.ScrollViewHandler._apply")
1525
- if "refresh_control" in props and state.get("refresh") is not None:
1526
- self._apply_refresh(outer, props)
1527
- # ``paging_enabled`` and ``keyboard_dismiss_mode`` have no clean
1528
- # NestedScrollView analogue, so they are intentionally skipped
1529
- # rather than approximated poorly.
1530
-
1531
- def insert_child(self, parent: Any, child: Any, index: int) -> None:
1532
- sv = _state_of(parent).get("scroll", parent)
1533
- _insert_view(sv, child, index)
1534
-
1535
- def remove_child(self, parent: Any, child: Any) -> None:
1536
- sv = _state_of(parent).get("scroll", parent)
1537
- try:
1538
- sv.removeView(child)
1539
- except Exception:
1540
- diagnostics.swallowed("android.ScrollViewHandler.remove_child")
1541
-
1542
- def command(self, native_view: Any, name: str, args: Dict[str, Any]) -> Any:
1543
- state = _state_of(native_view)
1544
- sv = state.get("scroll", native_view)
1545
- density = _density() or 1.0
1546
- if name == "scroll_to_offset":
1547
- x_px = int(float(args.get("x", 0.0) or 0.0) * density)
1548
- y_px = int(float(args.get("y", 0.0) or 0.0) * density)
1549
- animated = args.get("animated", True) is not False
1550
- try:
1551
- if animated and hasattr(sv, "smoothScrollTo"):
1552
- sv.smoothScrollTo(x_px, y_px)
1553
- else:
1554
- sv.scrollTo(x_px, y_px)
1555
- except Exception:
1556
- diagnostics.swallowed("android.ScrollViewHandler.command")
1557
- return None
1558
- if name == "scroll_to_end":
1559
- try:
1560
- child = sv.getChildAt(0) if int(sv.getChildCount()) > 0 else None
1561
- if child is None:
1562
- return None
1563
- if state.get("horizontal"):
1564
- target_x = max(0, int(child.getWidth()) - int(sv.getWidth()))
1565
- sv.smoothScrollTo(target_x, 0)
1566
- else:
1567
- target_y = max(0, int(child.getHeight()) - int(sv.getHeight()))
1568
- sv.smoothScrollTo(0, target_y)
1569
- except Exception:
1570
- diagnostics.swallowed("android.ScrollViewHandler.command")
1571
- return None
1572
- if name == "get_scroll_offset":
1573
- try:
1574
- return {
1575
- "x": float(sv.getScrollX()) / density,
1576
- "y": float(sv.getScrollY()) / density,
1577
- }
1578
- except Exception:
1579
- return {"x": 0.0, "y": 0.0}
1580
- return None
1581
-
1582
- def _bind_scroll_listener(self, outer: Any, sv: Any) -> None:
1583
- try:
1584
- if jclass("android.os.Build$VERSION").SDK_INT < 23:
1585
- return
1586
-
1587
- class _ScrollChangeProxy(dynamic_proxy(jclass("android.view.View").OnScrollChangeListener)):
1588
- def onScrollChange(
1589
- self,
1590
- v: Any,
1591
- scroll_x: int,
1592
- scroll_y: int,
1593
- old_x: int,
1594
- old_y: int,
1595
- ) -> None:
1596
- try:
1597
- density = _density() or 1.0
1598
- _fire(outer, "on_scroll", {"x": scroll_x / density, "y": scroll_y / density})
1599
- except Exception:
1600
- diagnostics.swallowed(
1601
- "android.ScrollViewHandler._bind_scroll_listener._ScrollChangeProxy.onScrollChange"
1602
- )
1603
-
1604
- sv.setOnScrollChangeListener(_ScrollChangeProxy())
1605
- except Exception:
1606
- diagnostics.swallowed("android.ScrollViewHandler._bind_scroll_listener")
1607
-
1608
- def _wrap_in_refresh(self, sv: Any) -> Any:
1609
- try:
1610
- SwipeRefreshLayout = jclass("androidx.swiperefreshlayout.widget.SwipeRefreshLayout")
1611
- srl = SwipeRefreshLayout(_ctx())
1612
- LP = jclass("android.view.ViewGroup$LayoutParams")
1613
- srl.addView(sv, LP(LP.MATCH_PARENT, LP.MATCH_PARENT))
1614
- return srl
1615
- except Exception:
1616
- return None
1617
-
1618
- def _bind_refresh_listener(self, outer: Any) -> None:
1619
- try:
1620
- srl = _state_of(outer).get("refresh")
1621
-
1622
- class _RefreshProxy(
1623
- dynamic_proxy(jclass("androidx.swiperefreshlayout.widget.SwipeRefreshLayout").OnRefreshListener)
1624
- ):
1625
- def onRefresh(self) -> None:
1626
- _fire(outer, "on_refresh")
1627
-
1628
- srl.setOnRefreshListener(_RefreshProxy())
1629
- except Exception:
1630
- diagnostics.swallowed("android.ScrollViewHandler._bind_refresh_listener")
1631
-
1632
- def _apply_refresh(self, outer: Any, props: Dict[str, Any]) -> None:
1633
- srl = _state_of(outer).get("refresh")
1634
- if srl is None:
1635
- return
1636
- spec = props.get("refresh_control")
1637
- if not isinstance(spec, dict):
1638
- try:
1639
- srl.setEnabled(False)
1640
- except Exception:
1641
- diagnostics.swallowed("android.ScrollViewHandler._apply_refresh")
1642
- return
1643
- try:
1644
- srl.setEnabled(True)
1645
- except Exception:
1646
- diagnostics.swallowed("android.ScrollViewHandler._apply_refresh")
1647
- if spec.get("tint_color"):
1648
- try:
1649
- srl.setColorSchemeColors([parse_color_int(spec["tint_color"])])
1650
- except Exception:
1651
- diagnostics.swallowed("android.ScrollViewHandler._apply_refresh")
1652
- try:
1653
- srl.setRefreshing(bool(spec.get("refreshing")))
1654
- except Exception:
1655
- diagnostics.swallowed("android.ScrollViewHandler._apply_refresh")
1656
-
1657
-
1658
- class TextInputHandler(AndroidViewHandler):
1659
- def _build(self, props: Dict[str, Any]) -> Any:
1660
- et = jclass("android.widget.EditText")(_ctx())
1661
- # Default to single-line so pressing Enter triggers IME_ACTION_DONE
1662
- # (submit / dismiss) instead of inserting a newline. ``_apply``
1663
- # overrides this when ``multiline=True``.
1664
- try:
1665
- if not props.get("multiline"):
1666
- et.setSingleLine(True)
1667
- except Exception:
1668
- diagnostics.swallowed("android.TextInputHandler._build")
1669
- self._bind_listeners(et, props)
1670
- return et
1671
-
1672
- def _bind_listeners(self, et: Any, props: Dict[str, Any]) -> None:
1673
- # Text watcher: dispatches on_change unless a programmatic
1674
- # setText is in flight. State lookups happen at dispatch time,
1675
- # after the registry has assigned the tag.
1676
- try:
1677
- TextWatcher = jclass("android.text.TextWatcher")
1678
-
1679
- class ChangeProxy(dynamic_proxy(TextWatcher)):
1680
- def afterTextChanged(self, s: Any) -> None:
1681
- st = _state_of(et)
1682
- if st.get("suppress"):
1683
- return
1684
- _fire(et, "on_change", str(s))
1685
-
1686
- def beforeTextChanged(self, s: Any, start: int, count: int, after: int) -> None:
1687
- pass
1688
-
1689
- def onTextChanged(self, s: Any, start: int, before: int, count: int) -> None:
1690
- pass
1691
-
1692
- et.addTextChangedListener(ChangeProxy())
1693
- except Exception:
1694
- diagnostics.swallowed("android.TextInputHandler._bind_listeners")
1695
- try:
1696
-
1697
- class _FocusProxy(dynamic_proxy(jclass("android.view.View").OnFocusChangeListener)):
1698
- def onFocusChange(self, view: Any, has_focus: bool) -> None:
1699
- _fire(view, "on_focus" if has_focus else "on_blur")
1700
-
1701
- et.setOnFocusChangeListener(_FocusProxy())
1702
- except Exception:
1703
- diagnostics.swallowed("android.TextInputHandler._bind_listeners")
1704
-
1705
- def _apply(self, et: Any, props: Dict[str, Any], initial: bool) -> None:
1706
- state = _state_of(et)
1707
- if "value" in props:
1708
- incoming = str(props["value"]) if props["value"] is not None else ""
1709
- try:
1710
- before = str(et.getText())
1711
- except Exception:
1712
- before = "<unavailable>"
1713
- if before != incoming:
1714
- selection_start = len(incoming)
1715
- selection_end = len(incoming)
1716
- try:
1717
- selection_start = et.getSelectionStart()
1718
- selection_end = et.getSelectionEnd()
1719
- except Exception:
1720
- diagnostics.swallowed("android.TextInputHandler._apply")
1721
- state["suppress"] = True
1722
- try:
1723
- et.setText(incoming)
1724
- try:
1725
- max_pos = len(incoming)
1726
- start = max(0, min(int(selection_start), max_pos))
1727
- end = max(0, min(int(selection_end), max_pos))
1728
- if start == end:
1729
- et.setSelection(start)
1730
- else:
1731
- et.setSelection(start, end)
1732
- except Exception:
1733
- diagnostics.swallowed("android.TextInputHandler._apply")
1734
- finally:
1735
- state["suppress"] = False
1736
- if "placeholder" in props:
1737
- et.setHint(str(props["placeholder"]) if props["placeholder"] is not None else "")
1738
- if "placeholder_color" in props and props["placeholder_color"] is not None:
1739
- try:
1740
- et.setHintTextColor(parse_color_int(props["placeholder_color"]))
1741
- except Exception:
1742
- diagnostics.swallowed("android.TextInputHandler._apply")
1743
- if "font_size" in props and props["font_size"] is not None:
1744
- et.setTextSize(float(props["font_size"]))
1745
- if "color" in props and props["color"] is not None:
1746
- et.setTextColor(parse_color_int(props["color"]))
1747
- if any(k in props for k in ("multiline", "secure", "keyboard_type", "auto_capitalize")):
1748
- merged = state.get("props") or props
1749
- try:
1750
- InputType = jclass("android.text.InputType")
1751
- base = InputType.TYPE_CLASS_TEXT
1752
- if merged.get("secure"):
1753
- base = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD
1754
- else:
1755
- kt = merged.get("keyboard_type")
1756
- if kt == "email_address":
1757
- base = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS
1758
- elif kt == "number_pad" or kt == "decimal_pad":
1759
- base = InputType.TYPE_CLASS_NUMBER
1760
- if kt == "decimal_pad":
1761
- base |= InputType.TYPE_NUMBER_FLAG_DECIMAL
1762
- elif kt == "phone_pad":
1763
- base = InputType.TYPE_CLASS_PHONE
1764
- elif kt == "url":
1765
- base = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI
1766
- auto_cap = merged.get("auto_capitalize")
1767
- if auto_cap == "sentences":
1768
- base |= InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
1769
- elif auto_cap == "words":
1770
- base |= InputType.TYPE_TEXT_FLAG_CAP_WORDS
1771
- elif auto_cap == "characters":
1772
- base |= InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS
1773
- if merged.get("multiline"):
1774
- base |= InputType.TYPE_TEXT_FLAG_MULTI_LINE
1775
- et.setSingleLine(False)
1776
- else:
1777
- et.setSingleLine(True)
1778
- et.setInputType(base)
1779
- except Exception:
1780
- diagnostics.swallowed("android.TextInputHandler._apply")
1781
- if "max_length" in props and props["max_length"] is not None:
1782
- try:
1783
- InputFilter = jclass("android.text.InputFilter$LengthFilter")
1784
- et.setFilters([InputFilter(int(props["max_length"]))])
1785
- except Exception:
1786
- diagnostics.swallowed("android.TextInputHandler._apply")
1787
- if "auto_focus" in props and props["auto_focus"]:
1788
- try:
1789
- et.requestFocus()
1790
- except Exception:
1791
- diagnostics.swallowed("android.TextInputHandler._apply")
1792
- if "editable" in props:
1793
- # ``editable`` is only present when ``False`` (read-only); a
1794
- # removal (``None``) restores editing. We keep the field
1795
- # visible (not greyed) by toggling focusability rather than
1796
- # ``setEnabled``.
1797
- editable = props["editable"] is not False
1798
- try:
1799
- et.setFocusable(editable)
1800
- et.setFocusableInTouchMode(editable)
1801
- et.setCursorVisible(editable)
1802
- et.setLongClickable(editable)
1803
- except Exception:
1804
- diagnostics.swallowed("android.TextInputHandler._apply")
1805
- if "selection_color" in props and props["selection_color"] is not None:
1806
- try:
1807
- et.setHighlightColor(parse_color_int(props["selection_color"]))
1808
- except Exception:
1809
- diagnostics.swallowed("android.TextInputHandler._apply")
1810
- if "text_content_type" in props and props["text_content_type"] is not None:
1811
- self._apply_autofill(et, str(props["text_content_type"]))
1812
- if "clear_button" in props:
1813
- self._apply_clear_button(et, bool(props.get("clear_button")))
1814
- if "return_key_type" in props and props["return_key_type"] is not None:
1815
- # Map the cross-platform ``return_key_type`` to Android's
1816
- # ``EditorInfo.IME_ACTION_*`` so the soft keyboard renders the
1817
- # right action key. iOS has a richer set (Google / Yahoo /
1818
- # Join / Route) with no direct AOSP equivalents; fall back
1819
- # to ``IME_ACTION_DONE`` for those.
1820
- try:
1821
- EditorInfo = jclass("android.view.inputmethod.EditorInfo")
1822
- rkt_mapping = {
1823
- "default": EditorInfo.IME_ACTION_UNSPECIFIED,
1824
- "go": EditorInfo.IME_ACTION_GO,
1825
- "google": EditorInfo.IME_ACTION_DONE,
1826
- "join": EditorInfo.IME_ACTION_DONE,
1827
- "next": EditorInfo.IME_ACTION_NEXT,
1828
- "route": EditorInfo.IME_ACTION_DONE,
1829
- "search": EditorInfo.IME_ACTION_SEARCH,
1830
- "send": EditorInfo.IME_ACTION_SEND,
1831
- "yahoo": EditorInfo.IME_ACTION_DONE,
1832
- "done": EditorInfo.IME_ACTION_DONE,
1833
- }
1834
- action = rkt_mapping.get(props["return_key_type"], EditorInfo.IME_ACTION_DONE)
1835
- et.setImeOptions(action)
1836
- except Exception:
1837
- diagnostics.swallowed("android.TextInputHandler._apply")
1838
- if initial or "multiline" in props:
1839
- self._apply_editor_action(et)
1840
- _apply_common_visual(et, props)
1841
-
1842
- def _apply_editor_action(self, et: Any) -> None:
1843
- """Install the IME action listener for submit + keyboard dismissal.
1844
-
1845
- Single-line inputs always dismiss the keyboard on the action
1846
- key (matching React Native's Android default) and fire
1847
- ``on_submit`` first. Multi-line inputs only consume the action
1848
- when an ``on_submit`` handler exists; otherwise Enter inserts
1849
- a newline.
1850
- """
1851
- try:
1852
- EditorListener = jclass("android.widget.TextView$OnEditorActionListener")
1853
- Context = jclass("android.content.Context")
1854
-
1855
- class SubmitProxy(dynamic_proxy(EditorListener)):
1856
- def onEditorAction(self, view: Any, action_id: int, event: Any) -> bool:
1857
- merged = _state_of(view).get("props") or {}
1858
- multiline = bool(merged.get("multiline"))
1859
- has_submit = _has_event(view, "on_submit")
1860
- if multiline and not has_submit:
1861
- return False
1862
- if has_submit:
1863
- try:
1864
- _fire(view, "on_submit", str(view.getText()))
1865
- except Exception:
1866
- diagnostics.swallowed(
1867
- "android.TextInputHandler._apply_editor_action.SubmitProxy.onEditorAction"
1868
- )
1869
- if not multiline:
1870
- try:
1871
- view.clearFocus()
1872
- ctx = view.getContext()
1873
- imm = ctx.getSystemService(Context.INPUT_METHOD_SERVICE)
1874
- imm.hideSoftInputFromWindow(view.getWindowToken(), 0)
1875
- except Exception:
1876
- diagnostics.swallowed(
1877
- "android.TextInputHandler._apply_editor_action.SubmitProxy.onEditorAction"
1878
- )
1879
- return True
1880
-
1881
- et.setOnEditorActionListener(SubmitProxy())
1882
- except Exception:
1883
- diagnostics.swallowed("android.TextInputHandler._apply_editor_action")
1884
-
1885
- @staticmethod
1886
- def _autofill_hint(content_type: str) -> Optional[str]:
1887
- mapping = {
1888
- "username": "username",
1889
- "password": "password",
1890
- "new_password": "newPassword",
1891
- "email": "emailAddress",
1892
- "email_address": "emailAddress",
1893
- "name": "name",
1894
- "given_name": "personGivenName",
1895
- "family_name": "personFamilyName",
1896
- "telephone": "phone",
1897
- "phone": "phone",
1898
- "phone_number": "phone",
1899
- "postal_code": "postalCode",
1900
- "street_address": "postalAddress",
1901
- "credit_card_number": "creditCardNumber",
1902
- "one_time_code": "smsOTPCode",
1903
- }
1904
- return mapping.get(content_type)
1905
-
1906
- def _apply_autofill(self, et: Any, content_type: str) -> None:
1907
- # Autofill hints are an API 26+ concept; older devices ignore them.
1908
- try:
1909
- if jclass("android.os.Build$VERSION").SDK_INT < 26:
1910
- return
1911
- hint = self._autofill_hint(content_type)
1912
- if hint:
1913
- et.setAutofillHints([hint])
1914
- except Exception:
1915
- diagnostics.swallowed("android.TextInputHandler._apply_autofill")
1916
-
1917
- def _apply_clear_button(self, et: Any, enabled: bool) -> None:
1918
- # Best-effort drawableEnd "X": shows a system clear icon and wires
1919
- # a touch listener that clears the field when the icon is tapped.
1920
- try:
1921
- state = _state_of(et)
1922
- if not enabled:
1923
- et.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0)
1924
- return
1925
- icon_id = int(getattr(jclass("android.R$drawable"), "ic_menu_close_clear_cancel", 0))
1926
- if icon_id:
1927
- et.setCompoundDrawablesWithIntrinsicBounds(0, 0, icon_id, 0)
1928
- if state.get("clear_bound"):
1929
- return
1930
- state["clear_bound"] = True
1931
-
1932
- class _ClearTouchProxy(dynamic_proxy(jclass("android.view.View").OnTouchListener)):
1933
- def onTouch(self, view: Any, event: Any) -> bool:
1934
- try:
1935
- if event.getAction() == 1: # ACTION_UP
1936
- drawables = view.getCompoundDrawables()
1937
- right = drawables[2] if drawables is not None and len(drawables) > 2 else None
1938
- if right is not None:
1939
- threshold = view.getWidth() - view.getPaddingRight() - right.getBounds().width()
1940
- if event.getX() >= threshold:
1941
- view.setText("")
1942
- return True
1943
- except Exception:
1944
- diagnostics.swallowed("android.TextInputHandler._apply_clear_button._ClearTouchProxy.onTouch")
1945
- return False
1946
-
1947
- et.setOnTouchListener(_ClearTouchProxy())
1948
- except Exception:
1949
- diagnostics.swallowed("android.TextInputHandler._apply_clear_button")
1950
-
1951
-
1952
- class ImageHandler(AndroidViewHandler):
1953
- def _build(self, props: Dict[str, Any]) -> Any:
1954
- return jclass("android.widget.ImageView")(_ctx())
1955
-
1956
- def _apply(self, iv: Any, props: Dict[str, Any], initial: bool) -> None:
1957
- if "tint_color" in props and props["tint_color"] is not None:
1958
- try:
1959
- ColorStateList = jclass("android.content.res.ColorStateList")
1960
- iv.setImageTintList(ColorStateList.valueOf(parse_color_int(props["tint_color"])))
1961
- except Exception:
1962
- diagnostics.swallowed("android.ImageHandler._apply")
1963
- if "placeholder_color" in props and props["placeholder_color"] is not None:
1964
- try:
1965
- iv.setBackgroundColor(parse_color_int(props["placeholder_color"]))
1966
- except Exception:
1967
- diagnostics.swallowed("android.ImageHandler._apply")
1968
- if "source" in props and props["source"]:
1969
- self._load_source(iv, str(props["source"]))
1970
- if "scale_type" in props and props["scale_type"]:
1971
- ScaleType = jclass("android.widget.ImageView$ScaleType")
1972
- mapping = {
1973
- "cover": ScaleType.CENTER_CROP,
1974
- "contain": ScaleType.FIT_CENTER,
1975
- "stretch": ScaleType.FIT_XY,
1976
- "center": ScaleType.CENTER,
1977
- }
1978
- st = mapping.get(props["scale_type"])
1979
- if st:
1980
- iv.setScaleType(st)
1981
- _apply_common_visual(iv, props)
1982
-
1983
- def _load_source(self, iv: Any, source: str) -> None:
1984
- try:
1985
- if source.startswith("data:"):
1986
- self._load_data_uri(iv, source)
1987
- elif source.startswith(("http://", "https://")):
1988
- from ..images import fetch
1989
-
1990
- state = _state_of(iv)
1991
- state["pending_uri"] = source
1992
-
1993
- def _on_ready(path: str) -> None:
1994
- if _state_of(iv).get("pending_uri") != source:
1995
- return # a newer source superseded this load
1996
- bitmap = self._decode_downsampled(iv, path)
1997
- if bitmap is None:
1998
- _fire(iv, "on_error", "decode failed")
1999
- return
2000
- try:
2001
- iv.setImageBitmap(bitmap)
2002
- _fire(iv, "on_load")
2003
- except Exception:
2004
- diagnostics.swallowed("android.ImageHandler._load_source._on_ready")
2005
-
2006
- def _on_error(message: str) -> None:
2007
- if _state_of(iv).get("pending_uri") == source:
2008
- _fire(iv, "on_error", message)
2009
-
2010
- fetch(source, _on_ready, _on_error)
2011
- elif source.startswith("/"):
2012
- bitmap = self._decode_downsampled(iv, source)
2013
- if bitmap is not None:
2014
- iv.setImageBitmap(bitmap)
2015
- _fire(iv, "on_load")
2016
- else:
2017
- _fire(iv, "on_error", "decode failed")
2018
- else:
2019
- ctx = _ctx()
2020
- res = ctx.getResources()
2021
- pkg = ctx.getPackageName()
2022
- res_name = source.rsplit(".", 1)[0] if "." in source else source
2023
- res_id = res.getIdentifier(res_name, "drawable", pkg)
2024
- if res_id != 0:
2025
- iv.setImageResource(res_id)
2026
- _fire(iv, "on_load")
2027
- else:
2028
- _fire(iv, "on_error", f"drawable {res_name!r} not found")
2029
- except Exception:
2030
- diagnostics.swallowed("android.ImageHandler._load_source")
2031
-
2032
- def _load_data_uri(self, iv: Any, source: str) -> None:
2033
- """Decode an inline ``data:`` URI (base64 payload) into the view."""
2034
- try:
2035
- import base64
2036
-
2037
- payload = source.split(",", 1)[1] if "," in source else ""
2038
- raw = base64.b64decode(payload)
2039
- BitmapFactory = jclass("android.graphics.BitmapFactory")
2040
- bitmap = BitmapFactory.decodeByteArray(raw, 0, len(raw))
2041
- if bitmap is not None:
2042
- iv.setImageBitmap(bitmap)
2043
- _fire(iv, "on_load")
2044
- else:
2045
- _fire(iv, "on_error", "data URI decode failed")
2046
- except Exception:
2047
- _fire(iv, "on_error", "data URI decode failed")
2048
-
2049
- def _decode_downsampled(self, iv: Any, path: str) -> Any:
2050
- """Decode ``path`` with ``inSampleSize`` sized to the target view.
2051
-
2052
- A 4000px photo displayed in a 200dp thumbnail should not hold a
2053
- 48 MB bitmap; ``inSampleSize`` decodes at the nearest power-of-2
2054
- fraction that still covers the view (falling back to the screen
2055
- width when the view hasn't been laid out yet).
2056
- """
2057
- try:
2058
- BitmapFactory = jclass("android.graphics.BitmapFactory")
2059
- Options = jclass("android.graphics.BitmapFactory$Options")
2060
-
2061
- bounds = Options()
2062
- bounds.inJustDecodeBounds = True
2063
- BitmapFactory.decodeFile(path, bounds)
2064
- src_w = int(bounds.outWidth)
2065
- src_h = int(bounds.outHeight)
2066
- if src_w <= 0 or src_h <= 0:
2067
- return None
2068
-
2069
- target_w = 0
2070
- target_h = 0
2071
- try:
2072
- target_w = int(iv.getWidth())
2073
- target_h = int(iv.getHeight())
2074
- except Exception:
2075
- diagnostics.swallowed("android.ImageHandler._decode_downsampled")
2076
- if target_w <= 0:
2077
- try:
2078
- metrics = _ctx().getResources().getDisplayMetrics()
2079
- target_w = int(metrics.widthPixels)
2080
- target_h = int(metrics.heightPixels)
2081
- except Exception:
2082
- target_w, target_h = src_w, src_h
2083
- if target_h <= 0:
2084
- target_h = target_w
2085
-
2086
- sample = 1
2087
- while (src_w // (sample * 2)) >= target_w and (src_h // (sample * 2)) >= target_h:
2088
- sample *= 2
2089
-
2090
- opts = Options()
2091
- opts.inSampleSize = sample
2092
- return BitmapFactory.decodeFile(path, opts)
2093
- except Exception:
2094
- return None
2095
-
2096
-
2097
- class SwitchHandler(AndroidViewHandler):
2098
- def _build(self, props: Dict[str, Any]) -> Any:
2099
- sw = jclass("android.widget.Switch")(_ctx())
2100
-
2101
- class CheckedProxy(dynamic_proxy(jclass("android.widget.CompoundButton").OnCheckedChangeListener)):
2102
- def onCheckedChanged(self, button: Any, checked: bool) -> None:
2103
- if _state_of(button).get("suppress"):
2104
- return
2105
- _fire(button, "on_change", bool(checked))
2106
-
2107
- sw.setOnCheckedChangeListener(CheckedProxy())
2108
- return sw
2109
-
2110
- def _apply(self, sw: Any, props: Dict[str, Any], initial: bool) -> None:
2111
- if "value" in props:
2112
- state = _state_of(sw)
2113
- state["suppress"] = True
2114
- try:
2115
- sw.setChecked(bool(props["value"]))
2116
- finally:
2117
- state["suppress"] = False
2118
- _apply_accessibility(sw, props)
2119
-
2120
-
2121
- class ProgressBarHandler(AndroidViewHandler):
2122
- def _build(self, props: Dict[str, Any]) -> Any:
2123
- style = jclass("android.R$attr").progressBarStyleHorizontal
2124
- pb = jclass("android.widget.ProgressBar")(_ctx(), None, 0, style)
2125
- pb.setMax(1000)
2126
- return pb
2127
-
2128
- def _apply(self, pb: Any, props: Dict[str, Any], initial: bool) -> None:
2129
- if "value" in props and props["value"] is not None:
2130
- pb.setProgress(int(float(props["value"]) * 1000))
2131
- if "color" in props and props["color"] is not None:
2132
- try:
2133
- ColorStateList = jclass("android.content.res.ColorStateList")
2134
- pb.setProgressTintList(ColorStateList.valueOf(parse_color_int(props["color"])))
2135
- except Exception:
2136
- diagnostics.swallowed("android.ProgressBarHandler._apply")
2137
- if "track_color" in props and props["track_color"] is not None:
2138
- try:
2139
- ColorStateList = jclass("android.content.res.ColorStateList")
2140
- track = ColorStateList.valueOf(parse_color_int(props["track_color"]))
2141
- pb.setProgressBackgroundTintList(track)
2142
- pb.setSecondaryProgressTintList(track)
2143
- except Exception:
2144
- diagnostics.swallowed("android.ProgressBarHandler._apply")
2145
- if "indeterminate" in props:
2146
- try:
2147
- pb.setIndeterminate(bool(props["indeterminate"]))
2148
- except Exception:
2149
- diagnostics.swallowed("android.ProgressBarHandler._apply")
2150
-
2151
-
2152
- class ActivityIndicatorHandler(AndroidViewHandler):
2153
- def _build(self, props: Dict[str, Any]) -> Any:
2154
- return jclass("android.widget.ProgressBar")(_ctx())
2155
-
2156
- def _apply(self, pb: Any, props: Dict[str, Any], initial: bool) -> None:
2157
- if "animating" in props:
2158
- View = jclass("android.view.View")
2159
- pb.setVisibility(View.VISIBLE if props["animating"] else View.GONE)
2160
- if "color" in props and props["color"] is not None:
2161
- try:
2162
- ColorStateList = jclass("android.content.res.ColorStateList")
2163
- pb.setIndeterminateTintList(ColorStateList.valueOf(parse_color_int(props["color"])))
2164
- except Exception:
2165
- diagnostics.swallowed("android.ActivityIndicatorHandler._apply")
2166
- if "size" in props and props["size"] is not None:
2167
- # The framework ProgressBar has no runtime size switch, so
2168
- # approximate "large" by scaling the indeterminate drawable.
2169
- try:
2170
- scale = 1.5 if str(props["size"]) == "large" else 1.0
2171
- pb.setScaleX(scale)
2172
- pb.setScaleY(scale)
2173
- except Exception:
2174
- diagnostics.swallowed("android.ActivityIndicatorHandler._apply")
2175
-
2176
-
2177
- def _make_web_client(wv: Any) -> Any:
2178
- """Best-effort ``WebViewClient`` proxy driving the WebView events.
2179
-
2180
- ``android.webkit.WebViewClient`` is an abstract *class*, not an
2181
- interface, so Chaquopy's ``dynamic_proxy`` may be unable to subclass
2182
- it at runtime. We attempt it and return ``None`` on failure, in
2183
- which case the caller falls back to the default client and page
2184
- loading still works.
2185
-
2186
- When the proxy succeeds it fires ``on_navigation_state_change``
2187
- (``onPageStarted``), ``on_load`` (``onPageFinished``), evaluates
2188
- ``inject_javascript`` after each load, and bridges ``on_message``
2189
- via a ``pythonnative://`` URL scheme plus a small JS shim installed
2190
- as ``window.pythonnative.postMessage``.
2191
- """
2192
- scheme = "pythonnative://message/"
2193
- try:
2194
-
2195
- class _WebClientProxy(dynamic_proxy(jclass("android.webkit.WebViewClient"))):
2196
- def onPageStarted(self, view: Any, url: Any, favicon: Any) -> None:
2197
- _fire(wv, "on_navigation_state_change", str(url))
2198
-
2199
- def onPageFinished(self, view: Any, url: Any) -> None:
2200
- _fire(wv, "on_load", str(url))
2201
- if _has_event(wv, "on_message"):
2202
- try:
2203
- shim = (
2204
- "(function(){window.pythonnative=window.pythonnative||{};"
2205
- "window.pythonnative.postMessage=function(m){"
2206
- "window.location.href='" + scheme + "'+encodeURIComponent(m);};})();"
2207
- )
2208
- view.evaluateJavascript(shim, None)
2209
- except Exception:
2210
- diagnostics.swallowed("android._make_web_client._WebClientProxy.onPageFinished")
2211
- inject_js = (_state_of(wv).get("props") or {}).get("inject_javascript")
2212
- if inject_js:
2213
- try:
2214
- view.evaluateJavascript(str(inject_js), None)
2215
- except Exception:
2216
- diagnostics.swallowed("android._make_web_client._WebClientProxy.onPageFinished")
2217
-
2218
- def shouldOverrideUrlLoading(self, view: Any, request: Any) -> bool:
2219
- try:
2220
- url = request if isinstance(request, str) else str(request.getUrl())
2221
- except Exception:
2222
- url = ""
2223
- if url.startswith(scheme):
2224
- try:
2225
- from urllib.parse import unquote
2226
-
2227
- _fire(wv, "on_message", unquote(url[len(scheme) :]))
2228
- except Exception:
2229
- diagnostics.swallowed("android._make_web_client._WebClientProxy.shouldOverrideUrlLoading")
2230
- return True
2231
- return False
2232
-
2233
- return _WebClientProxy()
2234
- except Exception:
2235
- return None
2236
-
2237
-
2238
- class WebViewHandler(AndroidViewHandler):
2239
- def _build(self, props: Dict[str, Any]) -> Any:
2240
- return jclass("android.webkit.WebView")(_ctx())
2241
-
2242
- def _apply(self, wv: Any, props: Dict[str, Any], initial: bool) -> None:
2243
- merged = _state_of(wv).get("props") or props
2244
- needs_js = bool(merged.get("inject_javascript")) or bool(event_names(merged))
2245
- if needs_js:
2246
- try:
2247
- wv.getSettings().setJavaScriptEnabled(True)
2248
- except Exception:
2249
- diagnostics.swallowed("android.WebViewHandler._apply")
2250
-
2251
- if initial:
2252
- client = _make_web_client(wv)
2253
- if client is not None:
2254
- try:
2255
- wv.setWebViewClient(client)
2256
- except Exception:
2257
- diagnostics.swallowed("android.WebViewHandler._apply")
2258
-
2259
- # ``html`` takes precedence over ``url`` when both are present.
2260
- if "html" in props and props["html"]:
2261
- try:
2262
- wv.loadDataWithBaseURL(None, str(props["html"]), "text/html", "utf-8", None)
2263
- except Exception:
2264
- diagnostics.swallowed("android.WebViewHandler._apply")
2265
- elif "url" in props and props["url"]:
2266
- try:
2267
- wv.loadUrl(str(props["url"]))
2268
- except Exception:
2269
- diagnostics.swallowed("android.WebViewHandler._apply")
2270
-
2271
- if "scroll_enabled" in props:
2272
- self._apply_scroll_enabled(wv, props["scroll_enabled"])
2273
-
2274
- def _apply_scroll_enabled(self, wv: Any, scroll_enabled: Any) -> None:
2275
- try:
2276
- if scroll_enabled is False:
2277
-
2278
- class _NoScrollProxy(dynamic_proxy(jclass("android.view.View").OnTouchListener)):
2279
- def onTouch(self, view: Any, event: Any) -> bool:
2280
- return event.getAction() == 2 # consume ACTION_MOVE
2281
-
2282
- wv.setOnTouchListener(_NoScrollProxy())
2283
- else:
2284
- wv.setOnTouchListener(None)
2285
- except Exception:
2286
- diagnostics.swallowed("android.WebViewHandler._apply_scroll_enabled")
2287
-
2288
-
2289
- class SpacerHandler(AndroidViewHandler):
2290
- """Empty layout placeholder used as a flexible gap.
2291
-
2292
- All sizing semantics live in the layout engine; ``Spacer``
2293
- behaves identically to a `View` with the same style props (e.g.,
2294
- ``flex: 1`` for an expanding spacer, ``size`` for a fixed gap).
2295
- """
2296
-
2297
- def _build(self, props: Dict[str, Any]) -> Any:
2298
- return jclass("android.view.View")(_ctx())
2299
-
2300
- def _apply(self, view: Any, props: Dict[str, Any], initial: bool) -> None:
2301
- pass
2302
-
2303
-
2304
- class SafeAreaViewHandler(FlexContainerHandler):
2305
- """Safe-area container using FrameLayout with ``fitsSystemWindows``."""
2306
-
2307
- def _build(self, props: Dict[str, Any]) -> Any:
2308
- fl = _new_container()
2309
- fl.setFitsSystemWindows(True)
2310
- return fl
2311
-
2312
-
2313
- # ======================================================================
2314
- # Modal: actually presents a Dialog with the children inside
2315
- # ======================================================================
2316
-
2317
-
2318
- class ModalHandler(AndroidViewHandler):
2319
- """Real modal presentation backed by an Android `Dialog`.
2320
-
2321
- The on-tree placeholder is a hidden ``View`` (so the layout
2322
- engine can ignore it). When ``visible`` flips to ``True``, a
2323
- ``Dialog`` is created with a ``FrameLayout`` as its content view;
2324
- the reconciler's ``insert_child`` calls are forwarded into that
2325
- content view.
2326
- """
2327
-
2328
- def _build(self, props: Dict[str, Any]) -> Any:
2329
- placeholder = jclass("android.view.View")(_ctx())
2330
- placeholder.setVisibility(jclass("android.view.View").GONE)
2331
- return placeholder
2332
-
2333
- def _apply(self, placeholder: Any, props: Dict[str, Any], initial: bool) -> None:
2334
- state = _state_of(placeholder)
2335
- # ``update`` only delivers the *changed* props. When ``visible`` is
2336
- # not among them the presentation state must be left untouched: a
2337
- # re-render that happens while the modal is open (e.g. an
2338
- # ``on_show`` callback bumping some state) must NOT be read as
2339
- # ``visible=False`` and tear the dialog down.
2340
- if "visible" in props:
2341
- visible = bool(props["visible"])
2342
- if visible and state.get("dialog") is None:
2343
- self._present(placeholder, state)
2344
- elif not visible and state.get("dialog") is not None:
2345
- self._dismiss(placeholder, state)
2346
- dialog = state.get("dialog")
2347
- if dialog is not None and "dismiss_on_backdrop" in props:
2348
- try:
2349
- dialog.setCanceledOnTouchOutside(props["dismiss_on_backdrop"] is not False)
2350
- except Exception:
2351
- diagnostics.swallowed("android.ModalHandler._apply")
2352
-
2353
- def insert_child(self, parent: Any, child: Any, index: int) -> None:
2354
- state = _state_of(parent)
2355
- content = state.get("content_view")
2356
- if content is not None:
2357
- _insert_view(content, child, index)
2358
- else:
2359
- state.setdefault("pending", []).insert(index, child)
2360
-
2361
- def remove_child(self, parent: Any, child: Any) -> None:
2362
- state = _state_of(parent)
2363
- content = state.get("content_view")
2364
- if content is not None:
2365
- try:
2366
- content.removeView(child)
2367
- except Exception:
2368
- diagnostics.swallowed("android.ModalHandler.remove_child")
2369
- else:
2370
- buf = state.get("pending")
2371
- if buf and child in buf:
2372
- buf.remove(child)
2373
-
2374
- def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
2375
- return
2376
-
2377
- def _teardown(self, placeholder: Any) -> None:
2378
- state = _state_of(placeholder)
2379
- if state.get("dialog") is not None:
2380
- self._dismiss(placeholder, state)
2381
-
2382
- def _present(self, placeholder: Any, state: Dict[str, Any]) -> None:
2383
- try:
2384
- props = state.get("props") or {}
2385
- Dialog = jclass("android.app.Dialog")
2386
- FrameLayout = jclass("android.widget.FrameLayout")
2387
- LayoutParams = jclass("android.view.ViewGroup$LayoutParams")
2388
- dialog = Dialog(_ctx())
2389
- content = FrameLayout(_ctx())
2390
- # ``overlay`` (or ``transparent``) keeps the dialog see-through
2391
- # with a dimmed backdrop so children float over the host UI;
2392
- # every other presentation style is the opaque, fullscreen-ish
2393
- # sheet. The content stays MATCH_PARENT either way so the layout
2394
- # engine keeps positioning children by absolute frame.
2395
- presentation = props.get("presentation_style", "page_sheet")
2396
- is_overlay = presentation == "overlay" or bool(props.get("transparent"))
2397
- content.setBackgroundColor(parse_color_int("#00FFFFFF" if is_overlay else "#FFFFFF"))
2398
- dialog.setContentView(
2399
- content,
2400
- LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT),
2401
- )
2402
- try:
2403
- window = dialog.getWindow()
2404
- if window is not None:
2405
- if is_overlay:
2406
- ColorDrawable = jclass("android.graphics.drawable.ColorDrawable")
2407
- window.setBackgroundDrawable(ColorDrawable(parse_color_int("#00000000")))
2408
- window.setDimAmount(0.5)
2409
- else:
2410
- WMLP = jclass("android.view.WindowManager$LayoutParams")
2411
- window.setLayout(WMLP.MATCH_PARENT, WMLP.MATCH_PARENT)
2412
- except Exception:
2413
- diagnostics.swallowed("android.ModalHandler._present")
2414
- try:
2415
- dialog.setCanceledOnTouchOutside(props.get("dismiss_on_backdrop") is not False)
2416
- except Exception:
2417
- diagnostics.swallowed("android.ModalHandler._present")
2418
- state["dialog"] = dialog
2419
- state["content_view"] = content
2420
- for child in state.pop("pending", []):
2421
- try:
2422
- content.addView(child)
2423
- except Exception:
2424
- diagnostics.swallowed("android.ModalHandler._present")
2425
-
2426
- OnShowListener = jclass("android.content.DialogInterface$OnShowListener")
2427
-
2428
- class _ShowProxy(dynamic_proxy(OnShowListener)):
2429
- def onShow(self, di: Any) -> None:
2430
- _fire(placeholder, "on_show")
2431
-
2432
- dialog.setOnShowListener(_ShowProxy())
2433
-
2434
- OnDismissListener = jclass("android.content.DialogInterface$OnDismissListener")
2435
-
2436
- class _DismissProxy(dynamic_proxy(OnDismissListener)):
2437
- def onDismiss(self, di: Any) -> None:
2438
- _fire(placeholder, "on_dismiss")
2439
-
2440
- dialog.setOnDismissListener(_DismissProxy())
2441
- dialog.show()
2442
- except Exception:
2443
- diagnostics.swallowed("android.ModalHandler._present")
2444
-
2445
- def _dismiss(self, placeholder: Any, state: Dict[str, Any]) -> None:
2446
- dialog = state.pop("dialog", None)
2447
- state.pop("content_view", None)
2448
- if dialog is not None:
2449
- try:
2450
- dialog.dismiss()
2451
- except Exception:
2452
- diagnostics.swallowed("android.ModalHandler._dismiss")
2453
-
2454
-
2455
- # ======================================================================
2456
- # Portal: floats its children over the screen in a top-level overlay
2457
- # ======================================================================
2458
-
2459
-
2460
- class PortalHandler(AndroidViewHandler):
2461
- """Overlay container hosting [`Portal`][pythonnative.Portal] children.
2462
-
2463
- The portal's native view is a full-size, non-clickable
2464
- ``FrameLayout`` added directly to the screen's fragment container
2465
- (the same parent as the screen root, so portal coordinates equal
2466
- viewport coordinates). It is appended last, which stacks it above
2467
- the main content; because the overlay itself never consumes
2468
- touches, taps on empty areas fall through to the screen below
2469
- while the portal's own children stay interactive.
2470
-
2471
- The overlay attaches lazily on the first child insert and re-homes
2472
- itself if the fragment container was rebuilt (e.g. after popping
2473
- back to a previously mounted screen).
2474
- """
2475
-
2476
- def _build(self, props: Dict[str, Any]) -> Any:
2477
- overlay = jclass("android.widget.FrameLayout")(_ctx())
2478
- overlay.setClickable(False)
2479
- return overlay
2480
-
2481
- def _apply(self, overlay: Any, props: Dict[str, Any], initial: bool) -> None:
2482
- _apply_common_visual(overlay, props)
2483
- if not initial:
2484
- self._ensure_attached(overlay)
2485
-
2486
- def _ensure_attached(self, overlay: Any) -> None:
2487
- try:
2488
- from ..utils import get_android_fragment_container
2489
-
2490
- container = get_android_fragment_container()
2491
- except Exception:
2492
- return
2493
- try:
2494
- parent = overlay.getParent()
2495
- if parent is not None and _java_id(parent) == _java_id(container):
2496
- overlay.bringToFront()
2497
- return
2498
- if parent is not None:
2499
- parent.removeView(overlay)
2500
- LayoutParams = jclass("android.view.ViewGroup$LayoutParams")
2501
- lp = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
2502
- container.addView(overlay, lp)
2503
- except Exception:
2504
- diagnostics.swallowed("android.PortalHandler._ensure_attached")
2505
-
2506
- def insert_child(self, parent: Any, child: Any, index: int) -> None:
2507
- self._ensure_attached(parent)
2508
- _insert_view(parent, child, index)
2509
-
2510
- def remove_child(self, parent: Any, child: Any) -> None:
2511
- try:
2512
- parent.removeView(child)
2513
- except Exception:
2514
- diagnostics.swallowed("android.PortalHandler.remove_child")
2515
-
2516
- def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
2517
- # The overlay fills its container via MATCH_PARENT layout params;
2518
- # the engine frames only the portal's children.
2519
- return
2520
-
2521
- def _teardown(self, overlay: Any) -> None:
2522
- try:
2523
- parent = overlay.getParent()
2524
- if parent is not None:
2525
- parent.removeView(overlay)
2526
- except Exception:
2527
- diagnostics.swallowed("android.PortalHandler._teardown")
2528
-
2529
-
2530
- class SliderHandler(AndroidViewHandler):
2531
- def _build(self, props: Dict[str, Any]) -> Any:
2532
- sb = jclass("android.widget.SeekBar")(_ctx())
2533
- sb.setMax(1000)
2534
-
2535
- class SeekProxy(dynamic_proxy(jclass("android.widget.SeekBar").OnSeekBarChangeListener)):
2536
- def onProgressChanged(self, seekBar: Any, progress: int, fromUser: bool) -> None:
2537
- if not fromUser:
2538
- return
2539
- merged = _state_of(seekBar).get("props") or {}
2540
- mn = float(merged.get("min_value", 0))
2541
- mx = float(merged.get("max_value", 1))
2542
- rng = mx - mn if mx != mn else 1
2543
- _fire(seekBar, "on_change", mn + (progress / 1000.0) * rng)
2544
-
2545
- def onStartTrackingTouch(self, seekBar: Any) -> None:
2546
- pass
2547
-
2548
- def onStopTrackingTouch(self, seekBar: Any) -> None:
2549
- pass
2550
-
2551
- sb.setOnSeekBarChangeListener(SeekProxy())
2552
- return sb
2553
-
2554
- def _apply(self, sb: Any, props: Dict[str, Any], initial: bool) -> None:
2555
- merged = _state_of(sb).get("props") or props
2556
- min_val = float(merged.get("min_value", 0))
2557
- max_val = float(merged.get("max_value", 1))
2558
- rng = max_val - min_val if max_val != min_val else 1
2559
- if "value" in props and props["value"] is not None:
2560
- normalized = (float(props["value"]) - min_val) / rng
2561
- sb.setProgress(int(normalized * 1000))
2562
- _apply_accessibility(sb, props)
2563
-
2564
-
2565
- class TabBarHandler(AndroidViewHandler):
2566
- """Native tab bar using ``BottomNavigationView`` from Material Components.
2567
-
2568
- Falls back to a horizontal ``LinearLayout`` with ``Button`` children
2569
- when Material Components is unavailable.
2570
- """
2571
-
2572
- _LABEL_VISIBILITY_LABELED = 1
2573
-
2574
- def _build(self, props: Dict[str, Any]) -> Any:
2575
- try:
2576
- bnv = jclass("com.google.android.material.bottomnavigation.BottomNavigationView")(_ctx())
2577
- bnv.setBackgroundColor(parse_color_int("#FFFFFF"))
2578
- try:
2579
- bnv.setLabelVisibilityMode(self._LABEL_VISIBILITY_LABELED)
2580
- except Exception:
2581
- diagnostics.swallowed("android.TabBarHandler._build")
2582
- return bnv
2583
- except Exception:
2584
- LinearLayout = jclass("android.widget.LinearLayout")
2585
- ll = LinearLayout(_ctx())
2586
- ll.setOrientation(LinearLayout.HORIZONTAL)
2587
- ll.setBackgroundColor(parse_color_int("#F8F8F8"))
2588
- return ll
2589
-
2590
- def create(self, tag: int, props: Dict[str, Any]) -> Any:
2591
- view = super().create(tag, props)
2592
- state = _state_of(view)
2593
- state["is_material"] = "LinearLayout" not in str(type(view))
2594
- try:
2595
- state["is_material"] = bool(view.getMenu() is not None)
2596
- except Exception:
2597
- state["is_material"] = False
2598
- if state["is_material"]:
2599
- self._bind_material_listener(view)
2600
- # Re-run the items now that we know which flavor we hold.
2601
- self._apply(view, props, initial=True)
2602
- return view
2603
-
2604
- def _apply(self, view: Any, props: Dict[str, Any], initial: bool) -> None:
2605
- state = _state_of(view)
2606
- if "is_material" not in state:
2607
- return # create() re-invokes once flavor detection is done.
2608
- if state.get("is_material"):
2609
- if "items" in props:
2610
- self._set_menu(view, props["items"] or [])
2611
- if "active_tab" in props:
2612
- items = (state.get("props") or {}).get("items") or []
2613
- self._set_active(view, props["active_tab"], items)
2614
- else:
2615
- self._apply_fallback(view, props)
2616
-
2617
- def _bind_material_listener(self, bnv: Any) -> None:
2618
- try:
2619
- listener_cls = jclass("com.google.android.material.navigation.NavigationBarView$OnItemSelectedListener")
2620
-
2621
- class _TabSelectProxy(dynamic_proxy(listener_cls)):
2622
- def onNavigationItemSelected(self, menu_item: Any) -> bool:
2623
- idx = menu_item.getItemId()
2624
- items = (_state_of(bnv).get("props") or {}).get("items") or []
2625
- if 0 <= idx < len(items):
2626
- _fire(bnv, "on_tab_select", items[idx].get("name", ""))
2627
- return True
2628
-
2629
- bnv.setOnItemSelectedListener(_TabSelectProxy())
2630
- except Exception:
2631
- diagnostics.swallowed("android.TabBarHandler._bind_material_listener")
2632
-
2633
- def _set_menu(self, bnv: Any, items: list) -> None:
2634
- try:
2635
- menu = bnv.getMenu()
2636
- menu.clear()
2637
- for i, item in enumerate(items):
2638
- title = item.get("title", item.get("name", ""))
2639
- menu_item = menu.add(0, i, i, str(title))
2640
- res_id = self._resolve_icon(item.get("icon"))
2641
- if res_id:
2642
- try:
2643
- menu_item.setIcon(res_id)
2644
- except Exception:
2645
- diagnostics.swallowed("android.TabBarHandler._set_menu")
2646
- except Exception:
2647
- diagnostics.swallowed("android.TabBarHandler._set_menu")
2648
-
2649
- def _resolve_icon(self, icon: Any) -> int:
2650
- """Resolve a tab icon spec to an `android.R.drawable.*` res id.
2651
-
2652
- Accepts a bare string (treated as the drawable's field name on
2653
- ``android.R.drawable``) or a dict of the form
2654
- ``{"ios": "...", "android": "ic_menu_home"}``. Returns ``0``
2655
- when the icon can't be resolved, which the caller treats as
2656
- "no icon".
2657
- """
2658
- if icon is None:
2659
- return 0
2660
- name: Any = None
2661
- if isinstance(icon, str):
2662
- name = icon
2663
- elif isinstance(icon, dict):
2664
- name = icon.get("android")
2665
- if not name:
2666
- return 0
2667
- try:
2668
- RDrawable = jclass("android.R$drawable")
2669
- res_id = getattr(RDrawable, str(name), 0)
2670
- return int(res_id) if res_id else 0
2671
- except Exception:
2672
- return 0
2673
-
2674
- def _set_active(self, bnv: Any, active: Any, items: list) -> None:
2675
- if active and items:
2676
- for i, item in enumerate(items):
2677
- if item.get("name") == active:
2678
- try:
2679
- bnv.setSelectedItemId(i)
2680
- except Exception:
2681
- diagnostics.swallowed("android.TabBarHandler._set_active")
2682
- break
2683
-
2684
- def _apply_fallback(self, ll: Any, props: Dict[str, Any]) -> None:
2685
- merged = _state_of(ll).get("props") or props
2686
- items = merged.get("items", []) or []
2687
- active = merged.get("active_tab")
2688
- if "items" in props or "active_tab" in props:
2689
- ll.removeAllViews()
2690
- for item in items:
2691
- name = item.get("name", "")
2692
- title = item.get("title", name)
2693
- btn = jclass("android.widget.Button")(_ctx())
2694
- btn.setText(str(title))
2695
- btn.setEnabled(name != active)
2696
-
2697
- class _ClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
2698
- def __init__(self, tab_name: str) -> None:
2699
- super().__init__()
2700
- self.tab_name = tab_name
2701
-
2702
- def onClick(self, view: Any) -> None:
2703
- _fire(ll, "on_tab_select", self.tab_name)
2704
-
2705
- btn.setOnClickListener(_ClickProxy(name))
2706
- ll.addView(btn)
2707
-
2708
-
2709
- # ======================================================================
2710
- # Pressable: visual feedback + tap callbacks + gestures
2711
- # ======================================================================
2712
-
2713
-
2714
- class PressableHandler(FlexContainerHandler):
2715
- """Container that dispatches press events through one touch stream.
2716
-
2717
- A single ``OnTouchListener`` drives the entire interaction:
2718
- ``on_press_in`` at finger-down (plus the opacity dip),
2719
- ``on_long_press`` on a 500 ms hold, ``on_press`` on a clean
2720
- release, and ``on_press_out`` when the finger lifts or the touch
2721
- cancels. The same stream feeds the gesture arbiter when
2722
- ``gestures`` are attached, so press feedback and pan/pinch
2723
- recognition coexist on one view.
2724
- """
2725
-
2726
- _LONG_PRESS_MS = 500
2727
- _TAP_SLOP_DP = 12.0
2728
-
2729
- def _build(self, props: Dict[str, Any]) -> Any:
2730
- fl = _new_container()
2731
- fl.setClickable(True)
2732
- fl.setFocusable(True)
2733
- self._bind_press_stream(fl)
2734
- return fl
2735
-
2736
- def create(self, tag: int, props: Dict[str, Any]) -> Any:
2737
- view = super().create(tag, props)
2738
- # Press handling owns the touch listener; gesture specs are fed
2739
- # from inside the press stream rather than a second listener.
2740
- _state_of(view)["gestures_bound"] = True
2741
- return view
2742
-
2743
- def update(self, native_view: Any, changed_props: Dict[str, Any]) -> None:
2744
- _state_of(native_view)["gestures_bound"] = True
2745
- super().update(native_view, changed_props)
2746
-
2747
- def _bind_press_stream(self, fl: Any) -> None:
2748
- try:
2749
- OnTouchListener = jclass("android.view.View$OnTouchListener")
2750
- Handler = jclass("android.os.Handler")
2751
- Looper = jclass("android.os.Looper")
2752
- Runnable = jclass("java.lang.Runnable")
2753
- handler = Handler(Looper.getMainLooper())
2754
- slop = self._TAP_SLOP_DP
2755
- long_ms = self._LONG_PRESS_MS
2756
-
2757
- class _PressTouchProxy(dynamic_proxy(OnTouchListener)):
2758
- def onTouch(self, view: Any, event: Any) -> bool:
2759
- if _pointer_events_blocked(view):
2760
- return False
2761
- state = _state_of(view)
2762
- _feed_motion_event(view, state, event)
2763
- action = int(event.getActionMasked())
2764
- density = _density() or 1.0
2765
- x_dp = float(event.getX()) / density
2766
- y_dp = float(event.getY()) / density
2767
- if action == 0: # DOWN
2768
- press = {
2769
- "down": (x_dp, y_dp),
2770
- "moved": False,
2771
- "long_fired": False,
2772
- "seq": state.get("press_seq", 0) + 1,
2773
- }
2774
- state["press"] = press
2775
- state["press_seq"] = press["seq"]
2776
- _fire(view, "on_press_in")
2777
- merged = state.get("props") or {}
2778
- opacity = float(merged.get("pressed_opacity", 0.6))
2779
- if opacity < 1.0:
2780
- try:
2781
- view.animate().alpha(opacity).setDuration(50).start()
2782
- except Exception:
2783
- diagnostics.swallowed(
2784
- "android.PressableHandler._bind_press_stream._PressTouchProxy.onTouch"
2785
- )
2786
- if _has_event(view, "on_long_press"):
2787
- seq = press["seq"]
2788
-
2789
- class _LongRunnable(dynamic_proxy(Runnable)):
2790
- def run(self) -> None:
2791
- live = _state_of(view).get("press")
2792
- if live is None or live.get("seq") != seq:
2793
- return
2794
- if live.get("moved") or live.get("long_fired"):
2795
- return
2796
- live["long_fired"] = True
2797
- _fire(view, "on_long_press")
2798
-
2799
- handler.postDelayed(_LongRunnable(), long_ms)
2800
- return True
2801
- press = state.get("press")
2802
- if press is None:
2803
- return True
2804
- if action == 2: # MOVE
2805
- dx = x_dp - press["down"][0]
2806
- dy = y_dp - press["down"][1]
2807
- if math.hypot(dx, dy) > slop:
2808
- press["moved"] = True
2809
- return True
2810
- if action in (1, 3): # UP / CANCEL
2811
- state["press"] = None
2812
- _fire(view, "on_press_out")
2813
- try:
2814
- view.animate().alpha(1.0).setDuration(100).start()
2815
- except Exception:
2816
- diagnostics.swallowed(
2817
- "android.PressableHandler._bind_press_stream._PressTouchProxy.onTouch"
2818
- )
2819
- if action == 1 and not press["moved"] and not press["long_fired"]:
2820
- _fire(view, "on_press")
2821
- return True
2822
- return True
2823
-
2824
- fl.setOnTouchListener(_PressTouchProxy())
2825
- except Exception:
2826
- diagnostics.swallowed("android.PressableHandler._bind_press_stream")
2827
-
2828
-
2829
- # ======================================================================
2830
- # StatusBar: global side effect
2831
- # ======================================================================
2832
-
2833
-
2834
- class StatusBarHandler(AndroidViewHandler):
2835
- """Apply status-bar background color / style on the host activity."""
2836
-
2837
- def _build(self, props: Dict[str, Any]) -> Any:
2838
- v = jclass("android.view.View")(_ctx())
2839
- v.setVisibility(jclass("android.view.View").GONE)
2840
- return v
2841
-
2842
- def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
2843
- return
2844
-
2845
- def _apply(self, view: Any, props: Dict[str, Any], initial: bool) -> None:
2846
- try:
2847
- ctx = _ctx()
2848
- window = ctx.getWindow()
2849
- if window is None:
2850
- return
2851
- if "background_color" in props and props["background_color"] is not None:
2852
- window.setStatusBarColor(parse_color_int(props["background_color"]))
2853
- View = jclass("android.view.View")
2854
- decor = window.getDecorView()
2855
- if "bar_style" in props and props["bar_style"] is not None:
2856
- # API 23+: setSystemUiVisibility with SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
2857
- # for dark-content (light backgrounds), 0 for light-content.
2858
- bar_style = props["bar_style"]
2859
- flags = decor.getSystemUiVisibility()
2860
- if bar_style in ("dark", "default"):
2861
- flags |= View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
2862
- else:
2863
- flags &= ~View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
2864
- decor.setSystemUiVisibility(flags)
2865
- if "hidden" in props and props["hidden"] is not None:
2866
- flags = decor.getSystemUiVisibility()
2867
- if props["hidden"]:
2868
- flags |= View.SYSTEM_UI_FLAG_FULLSCREEN
2869
- else:
2870
- flags &= ~View.SYSTEM_UI_FLAG_FULLSCREEN
2871
- decor.setSystemUiVisibility(flags)
2872
- except Exception as exc:
2873
- from .. import diagnostics
2874
-
2875
- diagnostics.warn_once(f"StatusBar: could not apply props on Android: {exc!r}")
2876
-
2877
-
2878
- class KeyboardAvoidingViewHandler(FlexContainerHandler):
2879
- """Vanilla container; the user-land component computes the offset."""
2880
-
2881
-
2882
- # ======================================================================
2883
- # Imperative Alert helper
2884
- # ======================================================================
2885
-
2886
-
2887
- def _present_alert(
2888
- *,
2889
- title: str,
2890
- message: Optional[str],
2891
- buttons: list,
2892
- style: str = "alert",
2893
- on_result: Callable[[int], None] = lambda _idx: None,
2894
- ) -> None:
2895
- """Present an AlertDialog or BottomSheet (``style='action_sheet'``).
2896
-
2897
- Safe to call from any thread; the AlertDialog work is automatically
2898
- marshalled to the main looper via
2899
- [`pythonnative.runtime.call_on_main_thread`][pythonnative.runtime.call_on_main_thread].
2900
- Returns immediately; the dialog appears on the next main-loop tick.
2901
-
2902
- ``buttons`` is a list of ``{"label": str, "style":
2903
- "default"|"cancel"|"destructive"}`` dicts (no ``on_press``).
2904
- Exactly one ``on_result(index)`` is invoked when the user picks a
2905
- button; a dismiss delivers ``-1``. ``on_result`` always runs on
2906
- the Android main thread; if it needs to wake an asyncio.Future,
2907
- use
2908
- [`pythonnative.runtime.resolve_future`][pythonnative.runtime.resolve_future]
2909
- to hop back onto the loop thread.
2910
- """
2911
- del style # AlertDialog has no distinct action-sheet style on Android.
2912
- from ..runtime import call_on_main_thread
2913
-
2914
- delivered = [False]
2915
-
2916
- def _deliver(index: int) -> None:
2917
- if delivered[0]:
2918
- return
2919
- delivered[0] = True
2920
- try:
2921
- on_result(index)
2922
- except Exception:
2923
- diagnostics.swallowed("android._present_alert._deliver")
2924
-
2925
- def _present_on_main() -> None:
2926
- try:
2927
- AlertDialog = jclass("android.app.AlertDialog$Builder")
2928
- builder = AlertDialog(_ctx())
2929
- builder.setTitle(str(title or ""))
2930
- if message is not None:
2931
- builder.setMessage(str(message))
2932
- button_specs = buttons or [{"label": "OK", "style": "default"}]
2933
-
2934
- # AlertDialog only has three slots (positive/negative/neutral).
2935
- # Assign the first matching style class to the conventional
2936
- # slot, then spill any leftovers into whichever slot is free.
2937
- slot_for: dict = {}
2938
- free_slots = ["positive", "negative", "neutral"]
2939
- for i, spec in enumerate(button_specs):
2940
- kind = spec.get("style", "default")
2941
- preferred = {
2942
- "default": "positive",
2943
- "cancel": "negative",
2944
- "destructive": "neutral",
2945
- }.get(kind, "positive")
2946
- if preferred in free_slots:
2947
- slot_for[i] = preferred
2948
- free_slots.remove(preferred)
2949
- for i, spec in enumerate(button_specs):
2950
- if i in slot_for:
2951
- continue
2952
- if not free_slots:
2953
- break
2954
- slot_for[i] = free_slots.pop(0)
2955
-
2956
- OnClickListener = jclass("android.content.DialogInterface$OnClickListener")
2957
-
2958
- def make_listener(button_index: int) -> Any:
2959
- class _Proxy(dynamic_proxy(OnClickListener)):
2960
- def onClick(self, dialog: Any, which: int) -> None:
2961
- _deliver(button_index)
2962
-
2963
- return _Proxy()
2964
-
2965
- for i, spec in enumerate(button_specs):
2966
- slot = slot_for.get(i)
2967
- if slot is None:
2968
- continue
2969
- label = str(spec.get("label", ""))
2970
- listener = make_listener(i)
2971
- if slot == "positive":
2972
- builder.setPositiveButton(label, listener)
2973
- elif slot == "negative":
2974
- builder.setNegativeButton(label, listener)
2975
- else:
2976
- builder.setNeutralButton(label, listener)
2977
-
2978
- OnCancelListener = jclass("android.content.DialogInterface$OnCancelListener")
2979
-
2980
- class _CancelProxy(dynamic_proxy(OnCancelListener)):
2981
- def onCancel(self, dialog: Any) -> None:
2982
- _deliver(-1)
2983
-
2984
- builder.setOnCancelListener(_CancelProxy())
2985
- builder.show()
2986
- except Exception:
2987
- _deliver(-1)
2988
-
2989
- call_on_main_thread(_present_on_main)
2990
-
2991
-
2992
- # ======================================================================
2993
- # Picker: native dropdown / select widget
2994
- # ======================================================================
2995
-
2996
-
2997
- class PickerHandler(AndroidViewHandler):
2998
- """``Picker`` element handler, native ``Spinner`` dropdown."""
2999
-
3000
- def _build(self, props: Dict[str, Any]) -> Any:
3001
- sp = jclass("android.widget.Spinner")(_ctx())
3002
-
3003
- class _PickerListener(dynamic_proxy(jclass("android.widget.AdapterView").OnItemSelectedListener)):
3004
- def onItemSelected(
3005
- self,
3006
- parent: Any,
3007
- view: Any, # noqa: ARG002
3008
- position: int,
3009
- id_: int, # noqa: ARG002
3010
- ) -> None:
3011
- state = _state_of(parent)
3012
- if state.get("suppress"):
3013
- return
3014
- items = (state.get("props") or {}).get("items") or []
3015
- if 0 <= position < len(items):
3016
- item = items[position]
3017
- v = item.get("value") if isinstance(item, dict) else item
3018
- _fire(parent, "on_change", v)
3019
-
3020
- def onNothingSelected(self, parent: Any) -> None: # noqa: ARG002
3021
- pass
3022
-
3023
- sp.setOnItemSelectedListener(_PickerListener())
3024
- return sp
3025
-
3026
- def _apply(self, sp: Any, props: Dict[str, Any], initial: bool) -> None:
3027
- state = _state_of(sp)
3028
- merged = state.get("props") or props
3029
-
3030
- if "items" in props or initial:
3031
- items = list(merged.get("items") or [])
3032
- labels = []
3033
- for item in items:
3034
- if isinstance(item, dict):
3035
- labels.append(str(item.get("label", item.get("value", ""))))
3036
- else:
3037
- labels.append(str(item))
3038
- ArrayAdapter = jclass("android.widget.ArrayAdapter")
3039
- R = jclass("android.R")
3040
- adapter = ArrayAdapter(_ctx(), R.layout.simple_spinner_item, labels)
3041
- adapter.setDropDownViewResource(R.layout.simple_spinner_dropdown_item)
3042
- state["suppress"] = True
3043
- try:
3044
- sp.setAdapter(adapter)
3045
- finally:
3046
- state["suppress"] = False
3047
-
3048
- if "value" in props or initial:
3049
- items = list(merged.get("items") or [])
3050
- value = merged.get("value")
3051
- target_index = -1
3052
- for i, item in enumerate(items):
3053
- v = item.get("value") if isinstance(item, dict) else item
3054
- if v == value:
3055
- target_index = i
3056
- break
3057
- if target_index >= 0 and sp.getSelectedItemPosition() != target_index:
3058
- state["suppress"] = True
3059
- try:
3060
- sp.setSelection(target_index, False)
3061
- finally:
3062
- state["suppress"] = False
3063
-
3064
-
3065
- # ======================================================================
3066
- # Checkbox: native CheckBox with an optional inline label
3067
- # ======================================================================
3068
-
3069
-
3070
- class CheckboxHandler(AndroidViewHandler):
3071
- """``Checkbox`` element handler, native ``CheckBox`` widget.
3072
-
3073
- Programmatic ``value`` updates are wrapped in a per-view
3074
- "suppress" guard so pushing a new state via ``setChecked`` never
3075
- re-fires the user's ``on_change``.
3076
- """
3077
-
3078
- def _build(self, props: Dict[str, Any]) -> Any:
3079
- cb = jclass("android.widget.CheckBox")(_ctx())
3080
-
3081
- class _CheckedProxy(dynamic_proxy(jclass("android.widget.CompoundButton").OnCheckedChangeListener)):
3082
- def onCheckedChanged(self, button: Any, is_checked: bool) -> None:
3083
- if _state_of(button).get("suppress"):
3084
- return
3085
- _fire(button, "on_change", bool(is_checked))
3086
-
3087
- cb.setOnCheckedChangeListener(_CheckedProxy())
3088
- return cb
3089
-
3090
- def _apply(self, cb: Any, props: Dict[str, Any], initial: bool) -> None:
3091
- state = _state_of(cb)
3092
- if "label" in props:
3093
- cb.setText(str(props["label"]) if props["label"] is not None else "")
3094
- if "value" in props:
3095
- state["suppress"] = True
3096
- try:
3097
- cb.setChecked(bool(props["value"]))
3098
- finally:
3099
- state["suppress"] = False
3100
- if "disabled" in props:
3101
- # ``disabled`` is only present when truthy; a removal (``None``)
3102
- # re-enables the control.
3103
- cb.setEnabled(not bool(props["disabled"]))
3104
- if "color" in props and props["color"] is not None:
3105
- try:
3106
- ColorStateList = jclass("android.content.res.ColorStateList")
3107
- cb.setButtonTintList(ColorStateList.valueOf(parse_color_int(props["color"])))
3108
- except Exception:
3109
- diagnostics.swallowed("android.CheckboxHandler._apply")
3110
- _apply_accessibility(cb, props)
3111
-
3112
-
3113
- # ======================================================================
3114
- # SegmentedControl: horizontal toggle row (no UISegmentedControl on AOSP)
3115
- # ======================================================================
3116
-
3117
-
3118
- class SegmentedControlHandler(AndroidViewHandler):
3119
- """``SegmentedControl`` element, a horizontal row of toggle buttons.
3120
-
3121
- Android has no ``UISegmentedControl`` equivalent, so the control is
3122
- built from a horizontal ``LinearLayout`` holding one ``Button`` per
3123
- segment. The selected segment is filled with the ``tint_color`` (or
3124
- a default accent); the rest are drawn outlined. The control owns
3125
- its own subviews, so ``insert_child`` / ``remove_child`` are
3126
- intentional no-ops.
3127
- """
3128
-
3129
- _DEFAULT_ACCENT = "#007AFF"
3130
-
3131
- def _build(self, props: Dict[str, Any]) -> Any:
3132
- LinearLayout = jclass("android.widget.LinearLayout")
3133
- ll = LinearLayout(_ctx())
3134
- ll.setOrientation(LinearLayout.HORIZONTAL)
3135
- return ll
3136
-
3137
- def insert_child(self, parent: Any, child: Any, index: int) -> None:
3138
- return
3139
-
3140
- def remove_child(self, parent: Any, child: Any) -> None:
3141
- return
3142
-
3143
- def _apply(self, ll: Any, props: Dict[str, Any], initial: bool) -> None:
3144
- state = _state_of(ll)
3145
- merged = state.get("props") or props
3146
-
3147
- segments_changed = False
3148
- if "segments" in props or initial:
3149
- raw = merged.get("segments")
3150
- new_segments = [str(s) for s in raw] if raw else []
3151
- if initial or new_segments != state.get("segments"):
3152
- state["segments"] = new_segments
3153
- segments_changed = True
3154
-
3155
- if "selected_index" in props and props["selected_index"] is not None:
3156
- state["selected_index"] = int(props["selected_index"])
3157
-
3158
- if segments_changed:
3159
- self._rebuild(ll, state)
3160
- else:
3161
- self._restyle(ll, state)
3162
-
3163
- _apply_accessibility(ll, props)
3164
-
3165
- def _rebuild(self, ll: Any, state: Dict[str, Any]) -> None:
3166
- try:
3167
- ll.removeAllViews()
3168
- except Exception:
3169
- diagnostics.swallowed("android.SegmentedControlHandler._rebuild")
3170
- state["buttons"] = []
3171
- LL_LP = jclass("android.widget.LinearLayout$LayoutParams")
3172
- restyle = self._restyle
3173
- for index, label in enumerate(state.get("segments") or []):
3174
- btn = jclass("android.widget.Button")(_ctx())
3175
- btn.setText(str(label))
3176
- try:
3177
- btn.setAllCaps(False)
3178
- except Exception:
3179
- diagnostics.swallowed("android.SegmentedControlHandler._rebuild")
3180
- # Equal-width segments: zero base width + weight 1, full height.
3181
- btn.setLayoutParams(LL_LP(0, LL_LP.MATCH_PARENT, 1.0))
3182
- enabled = (state.get("props") or {}).get("enabled") is not False
3183
- btn.setEnabled(enabled)
3184
-
3185
- class _SegmentClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
3186
- def __init__(self, seg_index: int) -> None:
3187
- super().__init__()
3188
- self._seg_index = seg_index
3189
-
3190
- def onClick(self, view: Any) -> None:
3191
- st = _state_of(ll)
3192
- if (st.get("props") or {}).get("enabled") is False:
3193
- return
3194
- st["selected_index"] = self._seg_index
3195
- restyle(ll, st)
3196
- _fire(ll, "on_change", self._seg_index)
3197
-
3198
- btn.setOnClickListener(_SegmentClickProxy(index))
3199
- ll.addView(btn)
3200
- state["buttons"].append(btn)
3201
- self._restyle(ll, state)
3202
-
3203
- def _restyle(self, ll: Any, state: Dict[str, Any]) -> None:
3204
- merged = state.get("props") or {}
3205
- accent = merged.get("tint_color") or self._DEFAULT_ACCENT
3206
- selected = state.get("selected_index", int(merged.get("selected_index", 0) or 0))
3207
- enabled = merged.get("enabled") is not False
3208
- for i, btn in enumerate(state.get("buttons") or []):
3209
- self._style_segment(btn, i == selected, accent, enabled)
3210
-
3211
- def _style_segment(self, btn: Any, selected: bool, accent: Any, enabled: bool) -> None:
3212
- try:
3213
- GradientDrawable = jclass("android.graphics.drawable.GradientDrawable")
3214
- drawable = GradientDrawable()
3215
- drawable.setCornerRadius(float(_dp(6)))
3216
- accent_int = parse_color_int(accent)
3217
- drawable.setStroke(_dp(1), accent_int)
3218
- if selected:
3219
- drawable.setColor(accent_int)
3220
- btn.setTextColor(parse_color_int("#FFFFFF"))
3221
- else:
3222
- drawable.setColor(parse_color_int("#00FFFFFF"))
3223
- btn.setTextColor(accent_int)
3224
- btn.setBackground(drawable)
3225
- btn.setEnabled(enabled)
3226
- except Exception:
3227
- diagnostics.swallowed("android.SegmentedControlHandler._style_segment")
3228
-
3229
- def measure_intrinsic(
3230
- self,
3231
- native_view: Any,
3232
- max_width: float,
3233
- max_height: float,
3234
- ) -> Tuple[float, float]:
3235
- # Weighted children measure to ~0 under an unspecified spec, so
3236
- # size to the sum of the segments' natural widths instead.
3237
- try:
3238
- density = _density()
3239
- View = jclass("android.view.View")
3240
- MeasureSpec = View.MeasureSpec
3241
- spec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
3242
- count = native_view.getChildCount()
3243
- if count == 0:
3244
- return (0.0, 0.0)
3245
- total_w = 0
3246
- max_h = 0
3247
- for i in range(count):
3248
- child = native_view.getChildAt(i)
3249
- child.measure(spec, spec)
3250
- total_w += child.getMeasuredWidth()
3251
- max_h = max(max_h, child.getMeasuredHeight())
3252
- return (total_w / density, max_h / density)
3253
- except Exception:
3254
- return (0.0, 0.0)
3255
-
3256
-
3257
- # ======================================================================
3258
- # DatePicker: trigger button opening native date/time dialogs
3259
- # ======================================================================
3260
-
3261
-
3262
- class DatePickerHandler(AndroidViewHandler):
3263
- """``DatePicker`` element, a trigger ``Button`` opening native dialogs.
3264
-
3265
- The button text reflects the current ISO ``value`` (or a
3266
- placeholder). Tapping it opens a ``DatePickerDialog`` (``mode``
3267
- ``"date"``), a ``TimePickerDialog`` (``"time"``), or a chained
3268
- date→time flow (``"datetime"``). Values are parsed / formatted with
3269
- ``java.util.Calendar`` + ``java.text.SimpleDateFormat`` using
3270
- per-mode ISO patterns, and the confirmed value is reported through
3271
- the ``on_change`` event.
3272
- """
3273
-
3274
- _PATTERNS = {"date": "yyyy-MM-dd", "time": "HH:mm", "datetime": "yyyy-MM-dd'T'HH:mm"}
3275
- _PLACEHOLDERS = {"date": "Select date", "time": "Select time", "datetime": "Select date & time"}
3276
-
3277
- def _build(self, props: Dict[str, Any]) -> Any:
3278
- btn = jclass("android.widget.Button")(_ctx())
3279
- try:
3280
- btn.setAllCaps(False)
3281
- except Exception:
3282
- diagnostics.swallowed("android.DatePickerHandler._build")
3283
- open_dialog = self._open_dialog
3284
-
3285
- class _DateTriggerProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
3286
- def onClick(self, view: Any) -> None:
3287
- st = _state_of(view)
3288
- if (st.get("props") or {}).get("enabled") is False:
3289
- return
3290
- open_dialog(view, st)
3291
-
3292
- btn.setOnClickListener(_DateTriggerProxy())
3293
- return btn
3294
-
3295
- def _apply(self, btn: Any, props: Dict[str, Any], initial: bool) -> None:
3296
- state = _state_of(btn)
3297
- if "enabled" in props:
3298
- btn.setEnabled(props["enabled"] is not False)
3299
- if "value" in props or "mode" in props or initial:
3300
- self._refresh_label(btn, state)
3301
- _apply_accessibility(btn, props)
3302
-
3303
- def _refresh_label(self, btn: Any, state: Dict[str, Any]) -> None:
3304
- merged = state.get("props") or {}
3305
- value = merged.get("value")
3306
- if value:
3307
- btn.setText(str(value))
3308
- else:
3309
- btn.setText(self._PLACEHOLDERS.get(str(merged.get("mode", "date")), "Select"))
3310
-
3311
- def _open_dialog(self, btn: Any, state: Dict[str, Any]) -> None:
3312
- merged = state.get("props") or {}
3313
- mode = str(merged.get("mode", "date"))
3314
- cal = self._parse_to_calendar(merged.get("value"), mode)
3315
- if mode == "time":
3316
- self._open_time(btn, state, cal)
3317
- elif mode == "datetime":
3318
- self._open_date(btn, state, cal, then_time=True)
3319
- else:
3320
- self._open_date(btn, state, cal, then_time=False)
3321
-
3322
- def _open_date(self, btn: Any, state: Dict[str, Any], cal: Any, then_time: bool) -> None:
3323
- Calendar = jclass("java.util.Calendar")
3324
- DatePickerDialog = jclass("android.app.DatePickerDialog")
3325
- commit = self._commit
3326
- open_time = self._open_time
3327
-
3328
- class _DateSetProxy(dynamic_proxy(jclass("android.app.DatePickerDialog").OnDateSetListener)):
3329
- def __init__(self, owner_state: Dict[str, Any], trigger: Any, base_cal: Any) -> None:
3330
- super().__init__()
3331
- self._owner_state = owner_state
3332
- self._trigger = trigger
3333
- self._cal = base_cal
3334
-
3335
- def onDateSet(self, view: Any, year: int, month: int, day: int) -> None:
3336
- self._cal.set(Calendar.YEAR, int(year))
3337
- self._cal.set(Calendar.MONTH, int(month))
3338
- self._cal.set(Calendar.DAY_OF_MONTH, int(day))
3339
- if then_time:
3340
- open_time(self._trigger, self._owner_state, self._cal)
3341
- else:
3342
- commit(self._trigger, self._owner_state, self._cal)
3343
-
3344
- dialog = DatePickerDialog(
3345
- _ctx(),
3346
- _DateSetProxy(state, btn, cal),
3347
- cal.get(Calendar.YEAR),
3348
- cal.get(Calendar.MONTH),
3349
- cal.get(Calendar.DAY_OF_MONTH),
3350
- )
3351
- self._apply_min_max(dialog, state)
3352
- dialog.show()
3353
-
3354
- def _open_time(self, btn: Any, state: Dict[str, Any], cal: Any) -> None:
3355
- Calendar = jclass("java.util.Calendar")
3356
- TimePickerDialog = jclass("android.app.TimePickerDialog")
3357
- commit = self._commit
3358
-
3359
- class _TimeSetProxy(dynamic_proxy(jclass("android.app.TimePickerDialog").OnTimeSetListener)):
3360
- def __init__(self, owner_state: Dict[str, Any], trigger: Any, base_cal: Any) -> None:
3361
- super().__init__()
3362
- self._owner_state = owner_state
3363
- self._trigger = trigger
3364
- self._cal = base_cal
3365
-
3366
- def onTimeSet(self, view: Any, hour: int, minute: int) -> None:
3367
- self._cal.set(Calendar.HOUR_OF_DAY, int(hour))
3368
- self._cal.set(Calendar.MINUTE, int(minute))
3369
- commit(self._trigger, self._owner_state, self._cal)
3370
-
3371
- dialog = TimePickerDialog(
3372
- _ctx(),
3373
- _TimeSetProxy(state, btn, cal),
3374
- cal.get(Calendar.HOUR_OF_DAY),
3375
- cal.get(Calendar.MINUTE),
3376
- True,
3377
- )
3378
- dialog.show()
3379
-
3380
- def _apply_min_max(self, dialog: Any, state: Dict[str, Any]) -> None:
3381
- try:
3382
- merged = state.get("props") or {}
3383
- mode = str(merged.get("mode", "date"))
3384
- picker = dialog.getDatePicker()
3385
- minimum = merged.get("minimum")
3386
- maximum = merged.get("maximum")
3387
- if minimum:
3388
- picker.setMinDate(self._parse_to_calendar(minimum, mode).getTimeInMillis())
3389
- if maximum:
3390
- picker.setMaxDate(self._parse_to_calendar(maximum, mode).getTimeInMillis())
3391
- except Exception:
3392
- diagnostics.swallowed("android.DatePickerHandler._apply_min_max")
3393
-
3394
- def _parse_to_calendar(self, value: Any, mode: str) -> Any:
3395
- Calendar = jclass("java.util.Calendar")
3396
- cal = Calendar.getInstance()
3397
- if value:
3398
- try:
3399
- SimpleDateFormat = jclass("java.text.SimpleDateFormat")
3400
- Locale = jclass("java.util.Locale")
3401
- fmt = SimpleDateFormat(self._PATTERNS.get(mode, self._PATTERNS["date"]), Locale.US)
3402
- cal.setTime(fmt.parse(str(value)))
3403
- except Exception:
3404
- diagnostics.swallowed("android.DatePickerHandler._parse_to_calendar")
3405
- return cal
3406
-
3407
- def _format_calendar(self, cal: Any, mode: str) -> str:
3408
- SimpleDateFormat = jclass("java.text.SimpleDateFormat")
3409
- Locale = jclass("java.util.Locale")
3410
- fmt = SimpleDateFormat(self._PATTERNS.get(mode, self._PATTERNS["date"]), Locale.US)
3411
- return str(fmt.format(cal.getTime()))
3412
-
3413
- def _commit(self, btn: Any, state: Dict[str, Any], cal: Any) -> None:
3414
- merged = state.get("props") or {}
3415
- mode = str(merged.get("mode", "date"))
3416
- try:
3417
- iso = self._format_calendar(cal, mode)
3418
- except Exception:
3419
- return
3420
- merged["value"] = iso
3421
- try:
3422
- btn.setText(iso)
3423
- except Exception:
3424
- diagnostics.swallowed("android.DatePickerHandler._commit")
3425
- _fire(btn, "on_change", iso)
3426
-
3427
-
3428
- # ======================================================================
3429
- # VirtualList — RecyclerView-backed native virtualization
3430
- # ======================================================================
3431
- #
3432
- # The heavy lifting lives in the template's ``PNVirtualListView.java``:
3433
- # Chaquopy cannot subclass abstract Java classes (RecyclerView.Adapter,
3434
- # RecyclerView.ViewHolder), so the Java side owns the adapter and view
3435
- # holders and calls back into Python through the small ``Delegate``
3436
- # interface. Each visible row hosts a nested-reconciler subtree (see
3437
- # ``pythonnative.virtual_rows``): the delegate's ``mountRow`` renders
3438
- # the row element and attaches its native root to the recycled cell
3439
- # container; ``onRowRecycled`` tears the subtree down.
3440
-
3441
- # Per-list mutable state, keyed by the RecyclerView's Java identity.
3442
- # The delegate proxy closes over this dict so prop updates (new
3443
- # ``render_row``, new ``count``) take effect without re-wiring Java.
3444
- _pn_vlist_info: Dict[int, Dict[str, Any]] = {}
3445
-
3446
-
3447
- class VirtualListHandler(AndroidViewHandler):
3448
- """Natively virtualized list backed by ``RecyclerView``.
3449
-
3450
- Expects props:
3451
-
3452
- - ``count``: total number of rows.
3453
- - ``row_height``: uniform row extent in points, or
3454
- - ``row_heights``: per-row extents (section lists interleave
3455
- headers and items of different heights).
3456
- - ``render_row``: ``render_row(index) -> Element`` producing one
3457
- row's subtree. Called lazily as rows enter the viewport.
3458
- - ``shows_scroll_indicator``: hide the scroll bar when ``False``.
3459
-
3460
- Events (dispatched by tag): ``on_row_press`` with the row index,
3461
- ``on_scroll`` with ``{"x", "y", "extent", "range"}`` in points.
3462
-
3463
- Commands: ``scroll_to_offset`` / ``scroll_to_index`` /
3464
- ``scroll_to_end`` / ``get_scroll_offset``.
3465
- """
3466
-
3467
- def _build(self, props: Dict[str, Any]) -> Any:
3468
- from ..virtual_rows import RowHostPool
3469
-
3470
- PNVirtualListView = _pn_runtime_class("PNVirtualListView")
3471
- info: Dict[str, Any] = {
3472
- "count": int(props.get("count", 0) or 0),
3473
- "row_height": float(props.get("row_height", 44.0) or 44.0),
3474
- "row_heights": props.get("row_heights"),
3475
- "render_row": props.get("render_row"),
3476
- "pool": RowHostPool(),
3477
- }
3478
-
3479
- class _Delegate(dynamic_proxy(PNVirtualListView.Delegate)):
3480
- def getCount(self) -> int:
3481
- return int(info["count"])
3482
-
3483
- def getRowHeightDp(self, position: int) -> float:
3484
- heights = info.get("row_heights")
3485
- if heights and 0 <= position < len(heights):
3486
- return float(heights[position])
3487
- return float(info["row_height"])
3488
-
3489
- def mountRow(self, position: int, container: Any, width_dp: float, height_dp: float) -> None:
3490
- render_row = info.get("render_row")
3491
- if render_row is None:
3492
- return
3493
- try:
3494
- pool = info["pool"]
3495
- root = pool.bind(
3496
- _java_id(container),
3497
- lambda: render_row(int(position)),
3498
- float(width_dp),
3499
- float(height_dp),
3500
- )
3501
- if root is not None:
3502
- _insert_view(container, root, 0)
3503
- # The layout engine frames only the *descendants*
3504
- # of a subtree root (screen hosts normally size
3505
- # the root themselves), so the row root would
3506
- # otherwise keep the 0x0 params ``_insert_view``
3507
- # assigns and render as nothing. Fill the cell.
3508
- FrameLP = jclass("android.widget.FrameLayout$LayoutParams")
3509
- root.setLayoutParams(FrameLP(FrameLP.MATCH_PARENT, FrameLP.MATCH_PARENT))
3510
- except Exception:
3511
- import traceback
3512
-
3513
- traceback.print_exc()
3514
-
3515
- def onRowPress(self, position: int) -> None:
3516
- view = info.get("view")
3517
- if view is not None:
3518
- _fire(view, "on_row_press", int(position))
3519
-
3520
- def onRowRecycled(self, container: Any) -> None:
3521
- try:
3522
- info["pool"].release(_java_id(container))
3523
- except Exception:
3524
- diagnostics.swallowed("android.VirtualListHandler._build._Delegate.onRowRecycled")
3525
-
3526
- def onScrolled(self, offset_dp: float, extent_dp: float, range_dp: float) -> None:
3527
- view = info.get("view")
3528
- if view is not None:
3529
- _fire(
3530
- view,
3531
- "on_scroll",
3532
- {
3533
- "x": 0.0,
3534
- "y": float(offset_dp),
3535
- "extent": float(extent_dp),
3536
- "range": float(range_dp),
3537
- },
3538
- )
3539
-
3540
- delegate = _Delegate()
3541
- view = PNVirtualListView(_ctx(), delegate)
3542
- info["view"] = view
3543
- # Keep the proxy alive: Java holds only a weak-ish reference
3544
- # through the field, and Chaquopy proxies are collectible once
3545
- # the Python side drops them.
3546
- info["delegate"] = delegate
3547
- _pn_vlist_info[_java_id(view)] = info
3548
- return view
3549
-
3550
- def _apply(self, view: Any, props: Dict[str, Any], initial: bool) -> None:
3551
- _apply_common_visual(view, props)
3552
- info = _pn_vlist_info.get(_java_id(view))
3553
- if info is None:
3554
- return
3555
- data_changed = False
3556
- if "count" in props:
3557
- info["count"] = int(props.get("count") or 0)
3558
- data_changed = True
3559
- if "row_height" in props and props["row_height"] is not None:
3560
- info["row_height"] = float(props["row_height"])
3561
- data_changed = True
3562
- if "row_heights" in props:
3563
- info["row_heights"] = props.get("row_heights")
3564
- data_changed = True
3565
- if "render_row" in props:
3566
- info["render_row"] = props.get("render_row")
3567
- data_changed = True
3568
- if "shows_scroll_indicator" in props:
3569
- show = props["shows_scroll_indicator"] is not False
3570
- try:
3571
- view.setVerticalScrollBarEnabled(show)
3572
- except Exception:
3573
- diagnostics.swallowed("android.VirtualListHandler._apply")
3574
- if data_changed and not initial:
3575
- try:
3576
- view.notifyDataChanged()
3577
- except Exception:
3578
- diagnostics.swallowed("android.VirtualListHandler._apply")
3579
-
3580
- def _teardown(self, native_view: Any) -> None:
3581
- info = _pn_vlist_info.pop(_java_id(native_view), None)
3582
- if info is not None:
3583
- info.get("pool").release_all()
3584
- info["view"] = None
3585
-
3586
- def measure_intrinsic(
3587
- self,
3588
- native_view: Any,
3589
- max_width: float,
3590
- max_height: float,
3591
- ) -> Tuple[float, float]:
3592
- # Fill the available space, like a ScrollView clamped to its
3593
- # parent. Inside an unbounded axis (nested in another scroll
3594
- # view) collapse to 0, matching React Native's behavior for
3595
- # nested virtualized lists.
3596
- w = max_width if math.isfinite(max_width) else 0.0
3597
- h = max_height if math.isfinite(max_height) else 0.0
3598
- return (max(0.0, w), max(0.0, h))
3599
-
3600
- def command(self, native_view: Any, name: str, args: Dict[str, Any]) -> Any:
3601
- density = _density() or 1.0
3602
- if name == "scroll_to_offset":
3603
- try:
3604
- native_view.scrollToOffsetDp(
3605
- float(args.get("y", 0.0) or 0.0),
3606
- args.get("animated", True) is not False,
3607
- )
3608
- except Exception:
3609
- diagnostics.swallowed("android.VirtualListHandler.command")
3610
- return None
3611
- if name == "scroll_to_index":
3612
- try:
3613
- native_view.scrollToIndex(
3614
- int(args.get("index", 0) or 0),
3615
- args.get("animated", True) is not False,
3616
- )
3617
- except Exception:
3618
- diagnostics.swallowed("android.VirtualListHandler.command")
3619
- return None
3620
- if name == "scroll_to_end":
3621
- info = _pn_vlist_info.get(_java_id(native_view))
3622
- count = int(info.get("count", 0)) if info else 0
3623
- try:
3624
- native_view.scrollToIndex(max(0, count - 1), args.get("animated", True) is not False)
3625
- except Exception:
3626
- diagnostics.swallowed("android.VirtualListHandler.command")
3627
- return None
3628
- if name == "get_scroll_offset":
3629
- try:
3630
- return {
3631
- "x": 0.0,
3632
- "y": float(native_view.computeVerticalScrollOffset()) / density,
3633
- }
3634
- except Exception:
3635
- return {"x": 0.0, "y": 0.0}
3636
- return None
3637
-
3638
-
3639
- # ======================================================================
3640
- # Registration
3641
- # ======================================================================
3642
-
3643
-
3644
- def register_handlers(registry: Any) -> None:
3645
- """Register all Android view handlers with the given registry."""
3646
- flex = FlexContainerHandler()
3647
- registry.register("Text", TextHandler())
3648
- registry.register("Button", ButtonHandler())
3649
- registry.register("Column", flex)
3650
- registry.register("Row", flex)
3651
- registry.register("View", flex)
3652
- registry.register("ScrollView", ScrollViewHandler())
3653
- registry.register("TextInput", TextInputHandler())
3654
- registry.register("Image", ImageHandler())
3655
- registry.register("Switch", SwitchHandler())
3656
- registry.register("ProgressBar", ProgressBarHandler())
3657
- registry.register("ActivityIndicator", ActivityIndicatorHandler())
3658
- registry.register("WebView", WebViewHandler())
3659
- registry.register("Spacer", SpacerHandler())
3660
- registry.register("SafeAreaView", SafeAreaViewHandler())
3661
- registry.register("Modal", ModalHandler())
3662
- registry.register("Portal", PortalHandler())
3663
- registry.register("Slider", SliderHandler())
3664
- registry.register("TabBar", TabBarHandler())
3665
- registry.register("Pressable", PressableHandler())
3666
- registry.register("StatusBar", StatusBarHandler())
3667
- registry.register("KeyboardAvoidingView", KeyboardAvoidingViewHandler())
3668
- registry.register("Picker", PickerHandler())
3669
- registry.register("Checkbox", CheckboxHandler())
3670
- registry.register("SegmentedControl", SegmentedControlHandler())
3671
- registry.register("DatePicker", DatePickerHandler())
3672
- registry.register("VirtualList", VirtualListHandler())
3673
-
3674
-
3675
- __all__ = [
3676
- "AndroidViewHandler",
3677
- "FlexContainerHandler",
3678
- "TextHandler",
3679
- "ButtonHandler",
3680
- "ScrollViewHandler",
3681
- "TextInputHandler",
3682
- "ImageHandler",
3683
- "SwitchHandler",
3684
- "ProgressBarHandler",
3685
- "ActivityIndicatorHandler",
3686
- "WebViewHandler",
3687
- "SpacerHandler",
3688
- "SafeAreaViewHandler",
3689
- "ModalHandler",
3690
- "PortalHandler",
3691
- "SliderHandler",
3692
- "TabBarHandler",
3693
- "PressableHandler",
3694
- "StatusBarHandler",
3695
- "KeyboardAvoidingViewHandler",
3696
- "PickerHandler",
3697
- "CheckboxHandler",
3698
- "SegmentedControlHandler",
3699
- "DatePickerHandler",
3700
- "VirtualListHandler",
3701
- "register_handlers",
3702
- ]