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.
- pythonnative/__init__.py +6 -4
- pythonnative/_ios_log.py +1 -1
- pythonnative/alerts.py +34 -88
- pythonnative/bootstrap.py +90 -0
- pythonnative/bridge/__init__.py +306 -0
- pythonnative/bridge/android.py +78 -0
- pythonnative/bridge/codec.py +164 -0
- pythonnative/bridge/fake.py +236 -0
- pythonnative/bridge/ios.py +138 -0
- pythonnative/cli/pn.py +24 -6
- pythonnative/components/layout.py +65 -3
- pythonnative/gestures.py +13 -10
- pythonnative/hooks.py +41 -26
- pythonnative/hosts/__init__.py +13 -36
- pythonnative/hosts/native.py +348 -0
- pythonnative/images.py +6 -5
- pythonnative/mutations.py +21 -0
- pythonnative/native_modules/__init__.py +30 -29
- pythonnative/native_modules/app_state.py +10 -6
- pythonnative/native_modules/battery.py +20 -84
- pythonnative/native_modules/biometrics.py +11 -124
- pythonnative/native_modules/camera.py +15 -291
- pythonnative/native_modules/clipboard.py +14 -109
- pythonnative/native_modules/desktop.py +322 -0
- pythonnative/native_modules/file_system.py +15 -21
- pythonnative/native_modules/haptics.py +19 -129
- pythonnative/native_modules/linking.py +24 -137
- pythonnative/native_modules/location.py +28 -180
- pythonnative/native_modules/net_info.py +25 -135
- pythonnative/native_modules/notifications.py +36 -257
- pythonnative/native_modules/permissions.py +20 -184
- pythonnative/native_modules/registry.py +513 -0
- pythonnative/native_modules/secure_store.py +18 -155
- pythonnative/native_modules/share.py +8 -113
- pythonnative/native_views/__init__.py +67 -86
- pythonnative/native_views/bridge_backend.py +264 -0
- pythonnative/platform.py +19 -15
- pythonnative/platform_metrics.py +3 -3
- pythonnative/project/android.py +0 -3
- pythonnative/project/builder.py +38 -16
- pythonnative/project/config.py +12 -2
- pythonnative/project/plugins.py +366 -0
- pythonnative/py.typed +0 -0
- pythonnative/runtime.py +35 -203
- pythonnative/sdk/__init__.py +48 -33
- pythonnative/sdk/_components.py +57 -76
- pythonnative/storage.py +18 -243
- pythonnative/templates/android_template/app/build.gradle +6 -9
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt +34 -166
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/ScreenFragment.kt +13 -151
- pythonnative/templates/android_template/pythonnative/build.gradle +48 -0
- pythonnative/templates/android_template/pythonnative/src/main/AndroidManifest.xml +11 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/PNBridge.kt +214 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/PythonHost.kt +20 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/animation/AnimationSpecs.kt +141 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/animation/PNAnimator.kt +249 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/bridge/JsonUtil.kt +175 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/bridge/MainThread.kt +32 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/bridge/PNLog.kt +33 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/bridge/PNRegistry.kt +82 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/bridge/PNTransaction.kt +80 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/bridge/TransactionApplier.kt +115 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/bridge/ViewRegistry.kt +74 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/BuiltinComponents.kt +35 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/ButtonManager.kt +33 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/ComponentManager.kt +213 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/ContainerManagers.kt +69 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/ControlManagers.kt +186 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/DatePickerManager.kt +123 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/ImageLoader.kt +141 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/ImageManager.kt +156 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/ModalManager.kt +134 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/PNColor.kt +201 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/PickerManager.kt +87 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/PortalManager.kt +55 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/PressableManager.kt +122 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/ScrollViewManager.kt +214 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/SegmentedControlManager.kt +113 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/StatusBarManager.kt +52 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/TabBarManager.kt +131 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/TextInputManager.kt +245 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/TextManager.kt +233 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/ViewStyler.kt +355 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/VirtualListManager.kt +179 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/components/WebViewManager.kt +149 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/gestures/GestureArbiter.kt +233 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/gestures/GestureCoordinator.kt +127 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/gestures/GestureRecognizers.kt +527 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/BuiltinModules.kt +73 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/DeviceModules.kt +316 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/HostModule.kt +97 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/MediaModules.kt +200 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/NativeModule.kt +135 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/NotificationsModule.kt +100 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/PermissionsModule.kt +88 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/StorageModules.kt +89 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/SystemModules.kt +178 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/plugins/GeneratedPlugins.kt +16 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/screens/Navigator.kt +116 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/screens/PNScreenFragment.kt +223 -0
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/screens/ScreenRegistry.kt +80 -0
- pythonnative/templates/android_template/{app/src/main/java/com/pythonnative/android_template → pythonnative/src/main/java/com/pythonnative/runtime/views}/PNAccessibilityDelegate.kt +5 -4
- pythonnative/templates/android_template/{app/src/main/java/com/pythonnative/android_template → pythonnative/src/main/java/com/pythonnative/runtime/views}/PNBorderDrawable.kt +12 -3
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/views/PNEditText.kt +18 -0
- pythonnative/templates/android_template/{app/src/main/java/com/pythonnative/android_template → pythonnative/src/main/java/com/pythonnative/runtime/views}/PNFrameLayout.kt +9 -10
- pythonnative/templates/android_template/{app/src/main/java/com/pythonnative/android_template → pythonnative/src/main/java/com/pythonnative/runtime/views}/PNVirtualListView.java +9 -12
- pythonnative/templates/android_template/pythonnative/src/test/java/com/pythonnative/runtime/AnimationSpecsTest.kt +75 -0
- pythonnative/templates/android_template/pythonnative/src/test/java/com/pythonnative/runtime/GestureArbiterTest.kt +197 -0
- pythonnative/templates/android_template/pythonnative/src/test/java/com/pythonnative/runtime/PNColorTest.kt +47 -0
- pythonnative/templates/android_template/pythonnative/src/test/java/com/pythonnative/runtime/PNTransactionTest.kt +78 -0
- pythonnative/templates/android_template/pythonnative/src/test/java/com/pythonnative/runtime/PromiseTest.kt +75 -0
- pythonnative/templates/android_template/settings.gradle +1 -0
- pythonnative/templates/ios_template/PythonNativeKit/Package.swift +21 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Animation/PNAnimator.swift +301 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Bridge/PNBridge.swift +191 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Bridge/PNRegistry.swift +140 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Bridge/PNTransaction.swift +165 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Bridge/PNViewRegistry.swift +53 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNButtonManagers.swift +206 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNColor.swift +173 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNComponentManager.swift +183 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNContainerView.swift +56 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNControlManagers.swift +281 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNImageManager.swift +221 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNOverlayManagers.swift +243 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNPressableManager.swift +114 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNScrollViewManager.swift +197 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNTabBarManager.swift +106 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNTextInputManager.swift +289 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNTextManager.swift +230 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNTransform.swift +82 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNViewManager.swift +86 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNViewState.swift +87 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNViewStyler.swift +340 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNVirtualListManager.swift +257 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Components/PNWebViewManager.swift +124 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Gestures/PNGestureCoordinator.swift +260 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Modules/AlertModule.swift +92 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Modules/HostModule.swift +158 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Modules/LifecycleModules.swift +174 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Modules/MediaModules.swift +177 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Modules/PNNativeModule.swift +125 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Modules/PermissionModules.swift +251 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Modules/SystemModules.swift +251 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Plugins/PNPluginRegistration.swift +8 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Screens/PNScreenRegistry.swift +46 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Screens/PNViewController.swift +251 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Support/PNCompat.swift +46 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Support/PNJSON.swift +173 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Support/PNLog.swift +47 -0
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Support/PNWindow.swift +51 -0
- pythonnative/templates/ios_template/PythonNativeKit/Tests/PythonNativeKitTests/PNColorTests.swift +50 -0
- pythonnative/templates/ios_template/PythonNativeKit/Tests/PythonNativeKitTests/PNGestureTests.swift +101 -0
- pythonnative/templates/ios_template/PythonNativeKit/Tests/PythonNativeKitTests/PNManagerTests.swift +124 -0
- pythonnative/templates/ios_template/PythonNativeKit/Tests/PythonNativeKitTests/PNModuleTests.swift +98 -0
- pythonnative/templates/ios_template/PythonNativeKit/Tests/PythonNativeKitTests/PNTransactionTests.swift +91 -0
- pythonnative/templates/ios_template/ios_template/AppDelegate.swift +8 -50
- pythonnative/templates/ios_template/ios_template/Info.plist +4 -4
- pythonnative/templates/ios_template/ios_template/PythonRuntime.swift +38 -46
- pythonnative/templates/ios_template/ios_template/SceneDelegate.swift +11 -16
- pythonnative/templates/ios_template/ios_template/ViewController.swift +12 -142
- pythonnative/templates/ios_template/ios_template.xcodeproj/project.pbxproj +47 -1
- pythonnative/utils.py +4 -82
- pythonnative/virtual_rows.py +3 -3
- {pythonnative-0.35.0.dist-info → pythonnative-0.37.0.dist-info}/METADATA +3 -5
- pythonnative-0.37.0.dist-info/RECORD +263 -0
- pythonnative/hosts/android.py +0 -269
- pythonnative/hosts/ios.py +0 -404
- pythonnative/native_views/android.py +0 -3702
- pythonnative/native_views/ios.py +0 -5253
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/Navigator.kt +0 -43
- pythonnative-0.35.0.dist-info/RECORD +0 -155
- {pythonnative-0.35.0.dist-info → pythonnative-0.37.0.dist-info}/WHEEL +0 -0
- {pythonnative-0.35.0.dist-info → pythonnative-0.37.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.35.0.dist-info → pythonnative-0.37.0.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
]
|