pythonnative 0.22.1__py3-none-any.whl → 0.24.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 +43 -50
- pythonnative/animated.py +1 -1
- pythonnative/appearance.py +124 -0
- pythonnative/cli/pn.py +3 -3
- pythonnative/components.py +873 -466
- pythonnative/diagnostics.py +214 -0
- pythonnative/element.py +5 -2
- pythonnative/events.py +13 -8
- pythonnative/hooks.py +454 -49
- pythonnative/hot_reload.py +9 -1
- pythonnative/images.py +196 -0
- pythonnative/mutations.py +3 -12
- pythonnative/native_views/__init__.py +1 -7
- pythonnative/native_views/android.py +651 -46
- pythonnative/native_views/desktop.py +116 -20
- pythonnative/native_views/ios.py +974 -52
- pythonnative/navigation.py +41 -17
- pythonnative/preview.py +74 -7
- pythonnative/project/config.py +1 -9
- pythonnative/reconciler.py +863 -441
- pythonnative/screen.py +409 -27
- pythonnative/storage.py +3 -3
- pythonnative/style.py +148 -6
- pythonnative/templates/android_template/app/src/main/AndroidManifest.xml +2 -1
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNAccessibilityDelegate.kt +47 -0
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNBorderDrawable.kt +67 -0
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNVirtualListView.java +172 -0
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/ScreenFragment.kt +22 -0
- pythonnative/virtual_rows.py +137 -0
- {pythonnative-0.22.1.dist-info → pythonnative-0.24.0.dist-info}/METADATA +5 -4
- {pythonnative-0.22.1.dist-info → pythonnative-0.24.0.dist-info}/RECORD +35 -28
- {pythonnative-0.22.1.dist-info → pythonnative-0.24.0.dist-info}/WHEEL +1 -1
- {pythonnative-0.22.1.dist-info → pythonnative-0.24.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.22.1.dist-info → pythonnative-0.24.0.dist-info}/licenses/LICENSE +0 -0
- {pythonnative-0.22.1.dist-info → pythonnative-0.24.0.dist-info}/top_level.txt +0 -0
|
@@ -45,7 +45,26 @@ from ..gestures import make_arbiter
|
|
|
45
45
|
from ..utils import get_android_context
|
|
46
46
|
from .base import ViewHandler, _safe_max, parse_color_int
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
_SIDE_BORDER_WIDTH_KEYS = (
|
|
49
|
+
"border_left_width",
|
|
50
|
+
"border_top_width",
|
|
51
|
+
"border_right_width",
|
|
52
|
+
"border_bottom_width",
|
|
53
|
+
)
|
|
54
|
+
_SIDE_BORDER_COLOR_KEYS = (
|
|
55
|
+
"border_left_color",
|
|
56
|
+
"border_top_color",
|
|
57
|
+
"border_right_color",
|
|
58
|
+
"border_bottom_color",
|
|
59
|
+
)
|
|
60
|
+
_DRAWABLE_STYLE_KEYS = (
|
|
61
|
+
"background_color",
|
|
62
|
+
"border_radius",
|
|
63
|
+
"border_width",
|
|
64
|
+
"border_color",
|
|
65
|
+
*_SIDE_BORDER_WIDTH_KEYS,
|
|
66
|
+
*_SIDE_BORDER_COLOR_KEYS,
|
|
67
|
+
)
|
|
49
68
|
|
|
50
69
|
|
|
51
70
|
# ======================================================================
|
|
@@ -57,6 +76,35 @@ def _ctx() -> Any:
|
|
|
57
76
|
return get_android_context()
|
|
58
77
|
|
|
59
78
|
|
|
79
|
+
def _pn_runtime_class(class_name: str) -> Any:
|
|
80
|
+
"""Resolve a PythonNative Android helper class for the running app.
|
|
81
|
+
|
|
82
|
+
The Android template's helper classes (e.g.
|
|
83
|
+
``PNAccessibilityDelegate``, ``PNBorderDrawable``) live in the
|
|
84
|
+
app's own package, which the ``pn`` CLI relocates to the configured
|
|
85
|
+
``application_id`` at build time. Deriving the package from the
|
|
86
|
+
runtime ``Context`` (rather than hardcoding the template package)
|
|
87
|
+
keeps these lookups correct for any app id.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
class_name: The class name within the app package, e.g.
|
|
91
|
+
``"PNBorderDrawable"``.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
The resolved Java class.
|
|
95
|
+
"""
|
|
96
|
+
package = _ctx().getPackageName()
|
|
97
|
+
return jclass(f"{package}.{class_name}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _signed_color(value: int) -> int:
|
|
101
|
+
"""Clamp a Python color int into Java's signed 32-bit range."""
|
|
102
|
+
value &= 0xFFFFFFFF
|
|
103
|
+
if value >= 0x80000000:
|
|
104
|
+
value -= 0x100000000
|
|
105
|
+
return value
|
|
106
|
+
|
|
107
|
+
|
|
60
108
|
def _density() -> float:
|
|
61
109
|
return float(_ctx().getResources().getDisplayMetrics().density)
|
|
62
110
|
|
|
@@ -118,6 +166,52 @@ def _has_event(view: Any, name: str) -> bool:
|
|
|
118
166
|
return name in event_names(merged)
|
|
119
167
|
|
|
120
168
|
|
|
169
|
+
def _apply_side_border(view: Any, props: Dict[str, Any]) -> bool:
|
|
170
|
+
"""Apply per-side borders via the template's ``PNBorderDrawable``.
|
|
171
|
+
|
|
172
|
+
Activated when any ``border_<side>_width`` key is present. In that
|
|
173
|
+
mode the drawable owns all four edges: each side's width falls
|
|
174
|
+
back to the uniform ``border_width`` (else 0) and its color to
|
|
175
|
+
``border_color`` (else black), and the background fill and corner
|
|
176
|
+
radius are baked into the same drawable.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
``True`` if the drawable was applied; ``False`` when no
|
|
180
|
+
per-side key is present or the helper class is unavailable
|
|
181
|
+
(older generated projects), in which case the caller falls
|
|
182
|
+
back to the uniform ``GradientDrawable`` path.
|
|
183
|
+
"""
|
|
184
|
+
if not any(props.get(k) is not None for k in _SIDE_BORDER_WIDTH_KEYS):
|
|
185
|
+
return False
|
|
186
|
+
try:
|
|
187
|
+
PNBorderDrawable = _pn_runtime_class("PNBorderDrawable")
|
|
188
|
+
except Exception:
|
|
189
|
+
return False
|
|
190
|
+
try:
|
|
191
|
+
base_width = float(props.get("border_width") or 0.0)
|
|
192
|
+
base_color = props.get("border_color") or "#000000"
|
|
193
|
+
widths = [
|
|
194
|
+
float(_dp(float(props[k]))) if props.get(k) is not None else float(_dp(base_width))
|
|
195
|
+
for k in _SIDE_BORDER_WIDTH_KEYS
|
|
196
|
+
]
|
|
197
|
+
colors = [
|
|
198
|
+
_signed_color(parse_color_int(props[k] if props.get(k) is not None else base_color))
|
|
199
|
+
for k in _SIDE_BORDER_COLOR_KEYS
|
|
200
|
+
]
|
|
201
|
+
has_bg = props.get("background_color") is not None
|
|
202
|
+
bg = _signed_color(parse_color_int(props["background_color"])) if has_bg else 0
|
|
203
|
+
radius = float(_dp(float(props.get("border_radius") or 0.0)))
|
|
204
|
+
drawable = PNBorderDrawable(has_bg, bg, radius, widths, colors)
|
|
205
|
+
view.setBackground(drawable)
|
|
206
|
+
try:
|
|
207
|
+
view.invalidate()
|
|
208
|
+
except Exception:
|
|
209
|
+
pass
|
|
210
|
+
return True
|
|
211
|
+
except Exception:
|
|
212
|
+
return False
|
|
213
|
+
|
|
214
|
+
|
|
121
215
|
def _apply_border(view: Any, props: Dict[str, Any]) -> None:
|
|
122
216
|
"""Apply border_radius / border_width / border_color via a GradientDrawable.
|
|
123
217
|
|
|
@@ -126,7 +220,12 @@ def _apply_border(view: Any, props: Dict[str, Any]) -> None:
|
|
|
126
220
|
background to a ``GradientDrawable`` (the "shape" XML primitive)
|
|
127
221
|
that renders the corner radius and stroke. We preserve any
|
|
128
222
|
existing ``background_color`` by re-baking it into the drawable.
|
|
223
|
+
Per-side borders route through
|
|
224
|
+
[`_apply_side_border`][pythonnative.native_views.android._apply_side_border]
|
|
225
|
+
instead.
|
|
129
226
|
"""
|
|
227
|
+
if _apply_side_border(view, props):
|
|
228
|
+
return
|
|
130
229
|
has_border = any(k in props for k in ("border_radius", "border_width", "border_color"))
|
|
131
230
|
has_bg = "background_color" in props and props["background_color"] is not None
|
|
132
231
|
if not has_border and not has_bg:
|
|
@@ -170,16 +269,46 @@ def _apply_border(view: Any, props: Dict[str, Any]) -> None:
|
|
|
170
269
|
|
|
171
270
|
|
|
172
271
|
def _apply_shadow(view: Any, props: Dict[str, Any]) -> None:
|
|
173
|
-
"""Apply elevation
|
|
272
|
+
"""Apply shadow props via elevation plus tinted outline shadows.
|
|
273
|
+
|
|
274
|
+
Android renders view shadows from ``elevation``; iOS-style
|
|
275
|
+
``shadow_radius`` maps onto it so cross-platform styles work.
|
|
276
|
+
On API 28+ the ambient/spot shadow colors are tinted with
|
|
277
|
+
``shadow_color`` (with ``shadow_opacity`` baked into the alpha
|
|
278
|
+
channel), which is as close to iOS's layer shadows as the
|
|
279
|
+
platform allows. ``shadow_offset`` has no Android equivalent and
|
|
280
|
+
is ignored.
|
|
281
|
+
"""
|
|
174
282
|
elevation = props.get("elevation")
|
|
175
283
|
if elevation is None and "shadow_radius" in props:
|
|
176
284
|
elevation = props.get("shadow_radius")
|
|
285
|
+
if elevation is None and (props.get("shadow_color") is not None or props.get("shadow_opacity") is not None):
|
|
286
|
+
# Shadow requested without an explicit size: use a Material
|
|
287
|
+
# card-like default so the shadow is actually visible.
|
|
288
|
+
elevation = 4.0
|
|
177
289
|
if elevation is None:
|
|
178
290
|
return
|
|
179
291
|
try:
|
|
180
292
|
view.setElevation(float(_dp(float(elevation))))
|
|
181
293
|
except Exception:
|
|
182
294
|
pass
|
|
295
|
+
color = props.get("shadow_color")
|
|
296
|
+
if color is None:
|
|
297
|
+
return
|
|
298
|
+
try:
|
|
299
|
+
Build = jclass("android.os.Build")
|
|
300
|
+
if int(Build.VERSION.SDK_INT) < 28:
|
|
301
|
+
return
|
|
302
|
+
argb = parse_color_int(color) & 0xFFFFFFFF
|
|
303
|
+
opacity = props.get("shadow_opacity")
|
|
304
|
+
if opacity is not None:
|
|
305
|
+
alpha = int(max(0.0, min(1.0, float(opacity))) * 255)
|
|
306
|
+
argb = (argb & 0x00FFFFFF) | (alpha << 24)
|
|
307
|
+
signed = _signed_color(argb)
|
|
308
|
+
view.setOutlineAmbientShadowColor(signed)
|
|
309
|
+
view.setOutlineSpotShadowColor(signed)
|
|
310
|
+
except Exception:
|
|
311
|
+
pass
|
|
183
312
|
|
|
184
313
|
|
|
185
314
|
def _apply_transform(view: Any, props: Dict[str, Any]) -> None:
|
|
@@ -228,7 +357,7 @@ def _apply_transform(view: Any, props: Dict[str, Any]) -> None:
|
|
|
228
357
|
|
|
229
358
|
|
|
230
359
|
def _apply_accessibility(view: Any, props: Dict[str, Any]) -> None:
|
|
231
|
-
"""Apply
|
|
360
|
+
"""Apply accessibility props (label / accessible / state / live region / test_id)."""
|
|
232
361
|
if "accessible" in props:
|
|
233
362
|
try:
|
|
234
363
|
View = jclass("android.view.View")
|
|
@@ -243,10 +372,48 @@ def _apply_accessibility(view: Any, props: Dict[str, Any]) -> None:
|
|
|
243
372
|
view.setContentDescription(str(label) if label is not None else None)
|
|
244
373
|
except Exception:
|
|
245
374
|
pass
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
375
|
+
if "accessibility_live_region" in props:
|
|
376
|
+
try:
|
|
377
|
+
mode = {"none": 0, "polite": 1, "assertive": 2}.get(
|
|
378
|
+
str(props["accessibility_live_region"] or "none").lower(), 0
|
|
379
|
+
)
|
|
380
|
+
view.setAccessibilityLiveRegion(mode)
|
|
381
|
+
except Exception:
|
|
382
|
+
pass
|
|
383
|
+
|
|
384
|
+
state = props.get("accessibility_state")
|
|
385
|
+
if isinstance(state, dict) and "selected" in state:
|
|
386
|
+
try:
|
|
387
|
+
view.setSelected(bool(state["selected"]))
|
|
388
|
+
except Exception:
|
|
389
|
+
pass
|
|
390
|
+
test_id = props.get("test_id")
|
|
391
|
+
if test_id is None and not isinstance(state, dict):
|
|
392
|
+
return
|
|
393
|
+
# test_id (exposed as `resource-id` to UI Automator tools) and the
|
|
394
|
+
# remaining state flags need an AccessibilityDelegate, which Python
|
|
395
|
+
# can't subclass directly (Chaquopy proxies interfaces only); the
|
|
396
|
+
# template ships PNAccessibilityDelegate for this. Older generated
|
|
397
|
+
# projects without the class simply skip these props.
|
|
398
|
+
try:
|
|
399
|
+
vs = _state_of(view)
|
|
400
|
+
delegate = vs.get("a11y_delegate")
|
|
401
|
+
if delegate is None:
|
|
402
|
+
PNAccessibilityDelegate = _pn_runtime_class("PNAccessibilityDelegate")
|
|
403
|
+
delegate = PNAccessibilityDelegate()
|
|
404
|
+
view.setAccessibilityDelegate(delegate)
|
|
405
|
+
vs["a11y_delegate"] = delegate
|
|
406
|
+
if test_id is not None:
|
|
407
|
+
delegate.setTestId(str(test_id))
|
|
408
|
+
view.setTag(str(test_id))
|
|
409
|
+
if isinstance(state, dict):
|
|
410
|
+
delegate.setStateDisabled(state.get("disabled"))
|
|
411
|
+
delegate.setStateSelected(state.get("selected"))
|
|
412
|
+
delegate.setStateChecked(state.get("checked"))
|
|
413
|
+
delegate.setStateBusy(state.get("busy"))
|
|
414
|
+
delegate.setStateExpanded(state.get("expanded"))
|
|
415
|
+
except Exception:
|
|
416
|
+
pass
|
|
250
417
|
|
|
251
418
|
|
|
252
419
|
def _apply_common_visual(view: Any, props: Dict[str, Any]) -> None:
|
|
@@ -758,13 +925,93 @@ def _typeface_for(weight: Any, italic: bool) -> Any:
|
|
|
758
925
|
return style
|
|
759
926
|
|
|
760
927
|
|
|
928
|
+
def _build_spannable(spans: Any) -> Any:
|
|
929
|
+
"""Build a ``SpannableStringBuilder`` from a rich-text span list.
|
|
930
|
+
|
|
931
|
+
Each span dict carries ``text`` plus optional per-span overrides
|
|
932
|
+
(``color``, ``background_color``, ``font_size``, ``font_family``,
|
|
933
|
+
``bold`` / ``italic`` / ``font_weight``, ``text_decoration``).
|
|
934
|
+
Spans without overrides inherit the TextView's base styling.
|
|
935
|
+
"""
|
|
936
|
+
SpannableStringBuilder = jclass("android.text.SpannableStringBuilder")
|
|
937
|
+
Typeface = jclass("android.graphics.Typeface")
|
|
938
|
+
FLAG = 33 # Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
|
939
|
+
|
|
940
|
+
builder = SpannableStringBuilder()
|
|
941
|
+
for span in spans:
|
|
942
|
+
if not isinstance(span, dict):
|
|
943
|
+
continue
|
|
944
|
+
text = str(span.get("text", ""))
|
|
945
|
+
if not text:
|
|
946
|
+
continue
|
|
947
|
+
start = builder.length()
|
|
948
|
+
builder.append(text)
|
|
949
|
+
end = builder.length()
|
|
950
|
+
|
|
951
|
+
def _set(obj: Any) -> None:
|
|
952
|
+
builder.setSpan(obj, start, end, FLAG)
|
|
953
|
+
|
|
954
|
+
try:
|
|
955
|
+
if span.get("color") is not None:
|
|
956
|
+
ForegroundColorSpan = jclass("android.text.style.ForegroundColorSpan")
|
|
957
|
+
_set(ForegroundColorSpan(parse_color_int(span["color"])))
|
|
958
|
+
if span.get("background_color") is not None:
|
|
959
|
+
BackgroundColorSpan = jclass("android.text.style.BackgroundColorSpan")
|
|
960
|
+
_set(BackgroundColorSpan(parse_color_int(span["background_color"])))
|
|
961
|
+
if span.get("font_size") is not None:
|
|
962
|
+
AbsoluteSizeSpan = jclass("android.text.style.AbsoluteSizeSpan")
|
|
963
|
+
_set(AbsoluteSizeSpan(int(float(span["font_size"])), True)) # dip units
|
|
964
|
+
bold = bool(span.get("bold"))
|
|
965
|
+
weight = span.get("font_weight")
|
|
966
|
+
if not bold and weight is not None:
|
|
967
|
+
try:
|
|
968
|
+
bold = str(weight) in ("bold", "600", "700", "800", "900")
|
|
969
|
+
except Exception:
|
|
970
|
+
bold = False
|
|
971
|
+
italic = bool(span.get("italic"))
|
|
972
|
+
if bold or italic:
|
|
973
|
+
StyleSpan = jclass("android.text.style.StyleSpan")
|
|
974
|
+
if bold and italic:
|
|
975
|
+
_set(StyleSpan(Typeface.BOLD_ITALIC))
|
|
976
|
+
elif bold:
|
|
977
|
+
_set(StyleSpan(Typeface.BOLD))
|
|
978
|
+
else:
|
|
979
|
+
_set(StyleSpan(Typeface.ITALIC))
|
|
980
|
+
if span.get("font_family"):
|
|
981
|
+
TypefaceSpan = jclass("android.text.style.TypefaceSpan")
|
|
982
|
+
_set(TypefaceSpan(str(span["font_family"])))
|
|
983
|
+
decoration = span.get("text_decoration")
|
|
984
|
+
if decoration == "underline":
|
|
985
|
+
UnderlineSpan = jclass("android.text.style.UnderlineSpan")
|
|
986
|
+
_set(UnderlineSpan())
|
|
987
|
+
elif decoration == "line_through":
|
|
988
|
+
StrikethroughSpan = jclass("android.text.style.StrikethroughSpan")
|
|
989
|
+
_set(StrikethroughSpan())
|
|
990
|
+
except Exception:
|
|
991
|
+
pass
|
|
992
|
+
return builder
|
|
993
|
+
|
|
994
|
+
|
|
761
995
|
class TextHandler(AndroidViewHandler):
|
|
762
996
|
def _build(self, props: Dict[str, Any]) -> Any:
|
|
763
997
|
return jclass("android.widget.TextView")(_ctx())
|
|
764
998
|
|
|
765
999
|
def _apply(self, tv: Any, props: Dict[str, Any], initial: bool) -> None:
|
|
766
|
-
if "text" in props:
|
|
767
|
-
|
|
1000
|
+
if "spans" in props or "text" in props:
|
|
1001
|
+
# ``update()`` merges changed props into the view state
|
|
1002
|
+
# before calling ``_apply``, so the merged dict always has
|
|
1003
|
+
# the latest text/spans pair (covering rich -> plain
|
|
1004
|
+
# transitions where only ``spans`` changed).
|
|
1005
|
+
merged = _state_of(tv).get("props") or props
|
|
1006
|
+
spans = merged.get("spans")
|
|
1007
|
+
if spans:
|
|
1008
|
+
try:
|
|
1009
|
+
tv.setText(_build_spannable(spans))
|
|
1010
|
+
except Exception:
|
|
1011
|
+
tv.setText(str(merged.get("text") or ""))
|
|
1012
|
+
else:
|
|
1013
|
+
text = merged.get("text")
|
|
1014
|
+
tv.setText(str(text) if text is not None else "")
|
|
768
1015
|
if "font_size" in props and props["font_size"] is not None:
|
|
769
1016
|
tv.setTextSize(float(props["font_size"]))
|
|
770
1017
|
if "color" in props and props["color"] is not None:
|
|
@@ -826,7 +1073,7 @@ class ButtonHandler(AndroidViewHandler):
|
|
|
826
1073
|
|
|
827
1074
|
class ClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
|
|
828
1075
|
def onClick(self, view: Any) -> None:
|
|
829
|
-
_fire(view, "
|
|
1076
|
+
_fire(view, "on_press")
|
|
830
1077
|
|
|
831
1078
|
btn.setOnClickListener(ClickProxy())
|
|
832
1079
|
return btn
|
|
@@ -1355,8 +1602,13 @@ class ImageHandler(AndroidViewHandler):
|
|
|
1355
1602
|
iv.setImageTintList(ColorStateList.valueOf(parse_color_int(props["tint_color"])))
|
|
1356
1603
|
except Exception:
|
|
1357
1604
|
pass
|
|
1605
|
+
if "placeholder_color" in props and props["placeholder_color"] is not None:
|
|
1606
|
+
try:
|
|
1607
|
+
iv.setBackgroundColor(parse_color_int(props["placeholder_color"]))
|
|
1608
|
+
except Exception:
|
|
1609
|
+
pass
|
|
1358
1610
|
if "source" in props and props["source"]:
|
|
1359
|
-
self._load_source(iv, props["source"])
|
|
1611
|
+
self._load_source(iv, str(props["source"]))
|
|
1360
1612
|
if "scale_type" in props and props["scale_type"]:
|
|
1361
1613
|
ScaleType = jclass("android.widget.ImageView$ScaleType")
|
|
1362
1614
|
mapping = {
|
|
@@ -1372,43 +1624,39 @@ class ImageHandler(AndroidViewHandler):
|
|
|
1372
1624
|
|
|
1373
1625
|
def _load_source(self, iv: Any, source: str) -> None:
|
|
1374
1626
|
try:
|
|
1375
|
-
if source.startswith(
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
bitmap = BitmapFactory.decodeStream(stream)
|
|
1396
|
-
stream.close()
|
|
1397
|
-
|
|
1398
|
-
class SetImage(dynamic_proxy(Runnable)):
|
|
1399
|
-
def __init__(self, view: Any, bmp: Any) -> None:
|
|
1400
|
-
super().__init__()
|
|
1401
|
-
self.view = view
|
|
1402
|
-
self.bmp = bmp
|
|
1403
|
-
|
|
1404
|
-
def run(self) -> None:
|
|
1405
|
-
self.view.setImageBitmap(self.bmp)
|
|
1627
|
+
if source.startswith("data:"):
|
|
1628
|
+
self._load_data_uri(iv, source)
|
|
1629
|
+
elif source.startswith(("http://", "https://")):
|
|
1630
|
+
from ..images import fetch
|
|
1631
|
+
|
|
1632
|
+
state = _state_of(iv)
|
|
1633
|
+
state["pending_uri"] = source
|
|
1634
|
+
|
|
1635
|
+
def _on_ready(path: str) -> None:
|
|
1636
|
+
if _state_of(iv).get("pending_uri") != source:
|
|
1637
|
+
return # a newer source superseded this load
|
|
1638
|
+
bitmap = self._decode_downsampled(iv, path)
|
|
1639
|
+
if bitmap is None:
|
|
1640
|
+
_fire(iv, "on_error", "decode failed")
|
|
1641
|
+
return
|
|
1642
|
+
try:
|
|
1643
|
+
iv.setImageBitmap(bitmap)
|
|
1644
|
+
_fire(iv, "on_load")
|
|
1645
|
+
except Exception:
|
|
1646
|
+
pass
|
|
1406
1647
|
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1648
|
+
def _on_error(message: str) -> None:
|
|
1649
|
+
if _state_of(iv).get("pending_uri") == source:
|
|
1650
|
+
_fire(iv, "on_error", message)
|
|
1410
1651
|
|
|
1411
|
-
|
|
1652
|
+
fetch(source, _on_ready, _on_error)
|
|
1653
|
+
elif source.startswith("/"):
|
|
1654
|
+
bitmap = self._decode_downsampled(iv, source)
|
|
1655
|
+
if bitmap is not None:
|
|
1656
|
+
iv.setImageBitmap(bitmap)
|
|
1657
|
+
_fire(iv, "on_load")
|
|
1658
|
+
else:
|
|
1659
|
+
_fire(iv, "on_error", "decode failed")
|
|
1412
1660
|
else:
|
|
1413
1661
|
ctx = _ctx()
|
|
1414
1662
|
res = ctx.getResources()
|
|
@@ -1417,9 +1665,76 @@ class ImageHandler(AndroidViewHandler):
|
|
|
1417
1665
|
res_id = res.getIdentifier(res_name, "drawable", pkg)
|
|
1418
1666
|
if res_id != 0:
|
|
1419
1667
|
iv.setImageResource(res_id)
|
|
1668
|
+
_fire(iv, "on_load")
|
|
1669
|
+
else:
|
|
1670
|
+
_fire(iv, "on_error", f"drawable {res_name!r} not found")
|
|
1420
1671
|
except Exception:
|
|
1421
1672
|
pass
|
|
1422
1673
|
|
|
1674
|
+
def _load_data_uri(self, iv: Any, source: str) -> None:
|
|
1675
|
+
"""Decode an inline ``data:`` URI (base64 payload) into the view."""
|
|
1676
|
+
try:
|
|
1677
|
+
import base64
|
|
1678
|
+
|
|
1679
|
+
payload = source.split(",", 1)[1] if "," in source else ""
|
|
1680
|
+
raw = base64.b64decode(payload)
|
|
1681
|
+
BitmapFactory = jclass("android.graphics.BitmapFactory")
|
|
1682
|
+
bitmap = BitmapFactory.decodeByteArray(raw, 0, len(raw))
|
|
1683
|
+
if bitmap is not None:
|
|
1684
|
+
iv.setImageBitmap(bitmap)
|
|
1685
|
+
_fire(iv, "on_load")
|
|
1686
|
+
else:
|
|
1687
|
+
_fire(iv, "on_error", "data URI decode failed")
|
|
1688
|
+
except Exception:
|
|
1689
|
+
_fire(iv, "on_error", "data URI decode failed")
|
|
1690
|
+
|
|
1691
|
+
def _decode_downsampled(self, iv: Any, path: str) -> Any:
|
|
1692
|
+
"""Decode ``path`` with ``inSampleSize`` sized to the target view.
|
|
1693
|
+
|
|
1694
|
+
A 4000px photo displayed in a 200dp thumbnail should not hold a
|
|
1695
|
+
48 MB bitmap; ``inSampleSize`` decodes at the nearest power-of-2
|
|
1696
|
+
fraction that still covers the view (falling back to the screen
|
|
1697
|
+
width when the view hasn't been laid out yet).
|
|
1698
|
+
"""
|
|
1699
|
+
try:
|
|
1700
|
+
BitmapFactory = jclass("android.graphics.BitmapFactory")
|
|
1701
|
+
Options = jclass("android.graphics.BitmapFactory$Options")
|
|
1702
|
+
|
|
1703
|
+
bounds = Options()
|
|
1704
|
+
bounds.inJustDecodeBounds = True
|
|
1705
|
+
BitmapFactory.decodeFile(path, bounds)
|
|
1706
|
+
src_w = int(bounds.outWidth)
|
|
1707
|
+
src_h = int(bounds.outHeight)
|
|
1708
|
+
if src_w <= 0 or src_h <= 0:
|
|
1709
|
+
return None
|
|
1710
|
+
|
|
1711
|
+
target_w = 0
|
|
1712
|
+
target_h = 0
|
|
1713
|
+
try:
|
|
1714
|
+
target_w = int(iv.getWidth())
|
|
1715
|
+
target_h = int(iv.getHeight())
|
|
1716
|
+
except Exception:
|
|
1717
|
+
pass
|
|
1718
|
+
if target_w <= 0:
|
|
1719
|
+
try:
|
|
1720
|
+
metrics = _ctx().getResources().getDisplayMetrics()
|
|
1721
|
+
target_w = int(metrics.widthPixels)
|
|
1722
|
+
target_h = int(metrics.heightPixels)
|
|
1723
|
+
except Exception:
|
|
1724
|
+
target_w, target_h = src_w, src_h
|
|
1725
|
+
if target_h <= 0:
|
|
1726
|
+
target_h = target_w
|
|
1727
|
+
|
|
1728
|
+
sample = 1
|
|
1729
|
+
while (src_w // (sample * 2)) >= target_w and (src_h // (sample * 2)) >= target_h:
|
|
1730
|
+
sample *= 2
|
|
1731
|
+
|
|
1732
|
+
opts = Options()
|
|
1733
|
+
opts.inSampleSize = sample
|
|
1734
|
+
return BitmapFactory.decodeFile(path, opts)
|
|
1735
|
+
except Exception:
|
|
1736
|
+
return None
|
|
1737
|
+
|
|
1423
1738
|
|
|
1424
1739
|
class SwitchHandler(AndroidViewHandler):
|
|
1425
1740
|
def _build(self, props: Dict[str, Any]) -> Any:
|
|
@@ -1779,6 +2094,81 @@ class ModalHandler(AndroidViewHandler):
|
|
|
1779
2094
|
pass
|
|
1780
2095
|
|
|
1781
2096
|
|
|
2097
|
+
# ======================================================================
|
|
2098
|
+
# Portal: floats its children over the screen in a top-level overlay
|
|
2099
|
+
# ======================================================================
|
|
2100
|
+
|
|
2101
|
+
|
|
2102
|
+
class PortalHandler(AndroidViewHandler):
|
|
2103
|
+
"""Overlay container hosting [`Portal`][pythonnative.Portal] children.
|
|
2104
|
+
|
|
2105
|
+
The portal's native view is a full-size, non-clickable
|
|
2106
|
+
``FrameLayout`` added directly to the screen's fragment container
|
|
2107
|
+
(the same parent as the screen root, so portal coordinates equal
|
|
2108
|
+
viewport coordinates). It is appended last, which stacks it above
|
|
2109
|
+
the main content; because the overlay itself never consumes
|
|
2110
|
+
touches, taps on empty areas fall through to the screen below
|
|
2111
|
+
while the portal's own children stay interactive.
|
|
2112
|
+
|
|
2113
|
+
The overlay attaches lazily on the first child insert and re-homes
|
|
2114
|
+
itself if the fragment container was rebuilt (e.g. after popping
|
|
2115
|
+
back to a previously mounted screen).
|
|
2116
|
+
"""
|
|
2117
|
+
|
|
2118
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
2119
|
+
overlay = jclass("android.widget.FrameLayout")(_ctx())
|
|
2120
|
+
overlay.setClickable(False)
|
|
2121
|
+
return overlay
|
|
2122
|
+
|
|
2123
|
+
def _apply(self, overlay: Any, props: Dict[str, Any], initial: bool) -> None:
|
|
2124
|
+
_apply_common_visual(overlay, props)
|
|
2125
|
+
if not initial:
|
|
2126
|
+
self._ensure_attached(overlay)
|
|
2127
|
+
|
|
2128
|
+
def _ensure_attached(self, overlay: Any) -> None:
|
|
2129
|
+
try:
|
|
2130
|
+
from ..utils import get_android_fragment_container
|
|
2131
|
+
|
|
2132
|
+
container = get_android_fragment_container()
|
|
2133
|
+
except Exception:
|
|
2134
|
+
return
|
|
2135
|
+
try:
|
|
2136
|
+
parent = overlay.getParent()
|
|
2137
|
+
if parent is not None and _java_id(parent) == _java_id(container):
|
|
2138
|
+
overlay.bringToFront()
|
|
2139
|
+
return
|
|
2140
|
+
if parent is not None:
|
|
2141
|
+
parent.removeView(overlay)
|
|
2142
|
+
LayoutParams = jclass("android.view.ViewGroup$LayoutParams")
|
|
2143
|
+
lp = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
|
2144
|
+
container.addView(overlay, lp)
|
|
2145
|
+
except Exception:
|
|
2146
|
+
pass
|
|
2147
|
+
|
|
2148
|
+
def insert_child(self, parent: Any, child: Any, index: int) -> None:
|
|
2149
|
+
self._ensure_attached(parent)
|
|
2150
|
+
_insert_view(parent, child, index)
|
|
2151
|
+
|
|
2152
|
+
def remove_child(self, parent: Any, child: Any) -> None:
|
|
2153
|
+
try:
|
|
2154
|
+
parent.removeView(child)
|
|
2155
|
+
except Exception:
|
|
2156
|
+
pass
|
|
2157
|
+
|
|
2158
|
+
def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
|
|
2159
|
+
# The overlay fills its container via MATCH_PARENT layout params;
|
|
2160
|
+
# the engine frames only the portal's children.
|
|
2161
|
+
return
|
|
2162
|
+
|
|
2163
|
+
def _teardown(self, overlay: Any) -> None:
|
|
2164
|
+
try:
|
|
2165
|
+
parent = overlay.getParent()
|
|
2166
|
+
if parent is not None:
|
|
2167
|
+
parent.removeView(overlay)
|
|
2168
|
+
except Exception:
|
|
2169
|
+
pass
|
|
2170
|
+
|
|
2171
|
+
|
|
1782
2172
|
class SliderHandler(AndroidViewHandler):
|
|
1783
2173
|
def _build(self, props: Dict[str, Any]) -> Any:
|
|
1784
2174
|
sb = jclass("android.widget.SeekBar")(_ctx())
|
|
@@ -2662,6 +3052,217 @@ class DatePickerHandler(AndroidViewHandler):
|
|
|
2662
3052
|
_fire(btn, "on_change", iso)
|
|
2663
3053
|
|
|
2664
3054
|
|
|
3055
|
+
# ======================================================================
|
|
3056
|
+
# VirtualList — RecyclerView-backed native virtualization
|
|
3057
|
+
# ======================================================================
|
|
3058
|
+
#
|
|
3059
|
+
# The heavy lifting lives in the template's ``PNVirtualListView.java``:
|
|
3060
|
+
# Chaquopy cannot subclass abstract Java classes (RecyclerView.Adapter,
|
|
3061
|
+
# RecyclerView.ViewHolder), so the Java side owns the adapter and view
|
|
3062
|
+
# holders and calls back into Python through the small ``Delegate``
|
|
3063
|
+
# interface. Each visible row hosts a nested-reconciler subtree (see
|
|
3064
|
+
# ``pythonnative.virtual_rows``): the delegate's ``mountRow`` renders
|
|
3065
|
+
# the row element and attaches its native root to the recycled cell
|
|
3066
|
+
# container; ``onRowRecycled`` tears the subtree down.
|
|
3067
|
+
|
|
3068
|
+
# Per-list mutable state, keyed by the RecyclerView's Java identity.
|
|
3069
|
+
# The delegate proxy closes over this dict so prop updates (new
|
|
3070
|
+
# ``render_row``, new ``count``) take effect without re-wiring Java.
|
|
3071
|
+
_pn_vlist_info: Dict[int, Dict[str, Any]] = {}
|
|
3072
|
+
|
|
3073
|
+
|
|
3074
|
+
class VirtualListHandler(AndroidViewHandler):
|
|
3075
|
+
"""Natively virtualized list backed by ``RecyclerView``.
|
|
3076
|
+
|
|
3077
|
+
Expects props:
|
|
3078
|
+
|
|
3079
|
+
- ``count``: total number of rows.
|
|
3080
|
+
- ``row_height``: uniform row extent in points, or
|
|
3081
|
+
- ``row_heights``: per-row extents (section lists interleave
|
|
3082
|
+
headers and items of different heights).
|
|
3083
|
+
- ``render_row``: ``render_row(index) -> Element`` producing one
|
|
3084
|
+
row's subtree. Called lazily as rows enter the viewport.
|
|
3085
|
+
- ``shows_scroll_indicator``: hide the scroll bar when ``False``.
|
|
3086
|
+
|
|
3087
|
+
Events (dispatched by tag): ``on_row_press`` with the row index,
|
|
3088
|
+
``on_scroll`` with ``{"x", "y", "extent", "range"}`` in points.
|
|
3089
|
+
|
|
3090
|
+
Commands: ``scroll_to_offset`` / ``scroll_to_index`` /
|
|
3091
|
+
``scroll_to_end`` / ``get_scroll_offset``.
|
|
3092
|
+
"""
|
|
3093
|
+
|
|
3094
|
+
def _build(self, props: Dict[str, Any]) -> Any:
|
|
3095
|
+
from ..virtual_rows import RowHostPool
|
|
3096
|
+
|
|
3097
|
+
PNVirtualListView = _pn_runtime_class("PNVirtualListView")
|
|
3098
|
+
info: Dict[str, Any] = {
|
|
3099
|
+
"count": int(props.get("count", 0) or 0),
|
|
3100
|
+
"row_height": float(props.get("row_height", 44.0) or 44.0),
|
|
3101
|
+
"row_heights": props.get("row_heights"),
|
|
3102
|
+
"render_row": props.get("render_row"),
|
|
3103
|
+
"pool": RowHostPool(),
|
|
3104
|
+
}
|
|
3105
|
+
|
|
3106
|
+
class _Delegate(dynamic_proxy(PNVirtualListView.Delegate)):
|
|
3107
|
+
def getCount(self) -> int:
|
|
3108
|
+
return int(info["count"])
|
|
3109
|
+
|
|
3110
|
+
def getRowHeightDp(self, position: int) -> float:
|
|
3111
|
+
heights = info.get("row_heights")
|
|
3112
|
+
if heights and 0 <= position < len(heights):
|
|
3113
|
+
return float(heights[position])
|
|
3114
|
+
return float(info["row_height"])
|
|
3115
|
+
|
|
3116
|
+
def mountRow(self, position: int, container: Any, width_dp: float, height_dp: float) -> None:
|
|
3117
|
+
render_row = info.get("render_row")
|
|
3118
|
+
if render_row is None:
|
|
3119
|
+
return
|
|
3120
|
+
try:
|
|
3121
|
+
pool = info["pool"]
|
|
3122
|
+
root = pool.bind(
|
|
3123
|
+
_java_id(container),
|
|
3124
|
+
lambda: render_row(int(position)),
|
|
3125
|
+
float(width_dp),
|
|
3126
|
+
float(height_dp),
|
|
3127
|
+
)
|
|
3128
|
+
if root is not None:
|
|
3129
|
+
_insert_view(container, root, 0)
|
|
3130
|
+
# The layout engine frames only the *descendants*
|
|
3131
|
+
# of a subtree root (screen hosts normally size
|
|
3132
|
+
# the root themselves), so the row root would
|
|
3133
|
+
# otherwise keep the 0x0 params ``_insert_view``
|
|
3134
|
+
# assigns and render as nothing. Fill the cell.
|
|
3135
|
+
FrameLP = jclass("android.widget.FrameLayout$LayoutParams")
|
|
3136
|
+
root.setLayoutParams(FrameLP(FrameLP.MATCH_PARENT, FrameLP.MATCH_PARENT))
|
|
3137
|
+
except Exception:
|
|
3138
|
+
import traceback
|
|
3139
|
+
|
|
3140
|
+
traceback.print_exc()
|
|
3141
|
+
|
|
3142
|
+
def onRowPress(self, position: int) -> None:
|
|
3143
|
+
view = info.get("view")
|
|
3144
|
+
if view is not None:
|
|
3145
|
+
_fire(view, "on_row_press", int(position))
|
|
3146
|
+
|
|
3147
|
+
def onRowRecycled(self, container: Any) -> None:
|
|
3148
|
+
try:
|
|
3149
|
+
info["pool"].release(_java_id(container))
|
|
3150
|
+
except Exception:
|
|
3151
|
+
pass
|
|
3152
|
+
|
|
3153
|
+
def onScrolled(self, offset_dp: float, extent_dp: float, range_dp: float) -> None:
|
|
3154
|
+
view = info.get("view")
|
|
3155
|
+
if view is not None:
|
|
3156
|
+
_fire(
|
|
3157
|
+
view,
|
|
3158
|
+
"on_scroll",
|
|
3159
|
+
{
|
|
3160
|
+
"x": 0.0,
|
|
3161
|
+
"y": float(offset_dp),
|
|
3162
|
+
"extent": float(extent_dp),
|
|
3163
|
+
"range": float(range_dp),
|
|
3164
|
+
},
|
|
3165
|
+
)
|
|
3166
|
+
|
|
3167
|
+
delegate = _Delegate()
|
|
3168
|
+
view = PNVirtualListView(_ctx(), delegate)
|
|
3169
|
+
info["view"] = view
|
|
3170
|
+
# Keep the proxy alive: Java holds only a weak-ish reference
|
|
3171
|
+
# through the field, and Chaquopy proxies are collectible once
|
|
3172
|
+
# the Python side drops them.
|
|
3173
|
+
info["delegate"] = delegate
|
|
3174
|
+
_pn_vlist_info[_java_id(view)] = info
|
|
3175
|
+
return view
|
|
3176
|
+
|
|
3177
|
+
def _apply(self, view: Any, props: Dict[str, Any], initial: bool) -> None:
|
|
3178
|
+
_apply_common_visual(view, props)
|
|
3179
|
+
info = _pn_vlist_info.get(_java_id(view))
|
|
3180
|
+
if info is None:
|
|
3181
|
+
return
|
|
3182
|
+
data_changed = False
|
|
3183
|
+
if "count" in props:
|
|
3184
|
+
info["count"] = int(props.get("count") or 0)
|
|
3185
|
+
data_changed = True
|
|
3186
|
+
if "row_height" in props and props["row_height"] is not None:
|
|
3187
|
+
info["row_height"] = float(props["row_height"])
|
|
3188
|
+
data_changed = True
|
|
3189
|
+
if "row_heights" in props:
|
|
3190
|
+
info["row_heights"] = props.get("row_heights")
|
|
3191
|
+
data_changed = True
|
|
3192
|
+
if "render_row" in props:
|
|
3193
|
+
info["render_row"] = props.get("render_row")
|
|
3194
|
+
data_changed = True
|
|
3195
|
+
if "shows_scroll_indicator" in props:
|
|
3196
|
+
show = props["shows_scroll_indicator"] is not False
|
|
3197
|
+
try:
|
|
3198
|
+
view.setVerticalScrollBarEnabled(show)
|
|
3199
|
+
except Exception:
|
|
3200
|
+
pass
|
|
3201
|
+
if data_changed and not initial:
|
|
3202
|
+
try:
|
|
3203
|
+
view.notifyDataChanged()
|
|
3204
|
+
except Exception:
|
|
3205
|
+
pass
|
|
3206
|
+
|
|
3207
|
+
def _teardown(self, native_view: Any) -> None:
|
|
3208
|
+
info = _pn_vlist_info.pop(_java_id(native_view), None)
|
|
3209
|
+
if info is not None:
|
|
3210
|
+
info.get("pool").release_all()
|
|
3211
|
+
info["view"] = None
|
|
3212
|
+
|
|
3213
|
+
def measure_intrinsic(
|
|
3214
|
+
self,
|
|
3215
|
+
native_view: Any,
|
|
3216
|
+
max_width: float,
|
|
3217
|
+
max_height: float,
|
|
3218
|
+
) -> Tuple[float, float]:
|
|
3219
|
+
# Fill the available space, like a ScrollView clamped to its
|
|
3220
|
+
# parent. Inside an unbounded axis (nested in another scroll
|
|
3221
|
+
# view) collapse to 0, matching React Native's behavior for
|
|
3222
|
+
# nested virtualized lists.
|
|
3223
|
+
w = max_width if math.isfinite(max_width) else 0.0
|
|
3224
|
+
h = max_height if math.isfinite(max_height) else 0.0
|
|
3225
|
+
return (max(0.0, w), max(0.0, h))
|
|
3226
|
+
|
|
3227
|
+
def command(self, native_view: Any, name: str, args: Dict[str, Any]) -> Any:
|
|
3228
|
+
density = _density() or 1.0
|
|
3229
|
+
if name == "scroll_to_offset":
|
|
3230
|
+
try:
|
|
3231
|
+
native_view.scrollToOffsetDp(
|
|
3232
|
+
float(args.get("y", 0.0) or 0.0),
|
|
3233
|
+
args.get("animated", True) is not False,
|
|
3234
|
+
)
|
|
3235
|
+
except Exception:
|
|
3236
|
+
pass
|
|
3237
|
+
return None
|
|
3238
|
+
if name == "scroll_to_index":
|
|
3239
|
+
try:
|
|
3240
|
+
native_view.scrollToIndex(
|
|
3241
|
+
int(args.get("index", 0) or 0),
|
|
3242
|
+
args.get("animated", True) is not False,
|
|
3243
|
+
)
|
|
3244
|
+
except Exception:
|
|
3245
|
+
pass
|
|
3246
|
+
return None
|
|
3247
|
+
if name == "scroll_to_end":
|
|
3248
|
+
info = _pn_vlist_info.get(_java_id(native_view))
|
|
3249
|
+
count = int(info.get("count", 0)) if info else 0
|
|
3250
|
+
try:
|
|
3251
|
+
native_view.scrollToIndex(max(0, count - 1), args.get("animated", True) is not False)
|
|
3252
|
+
except Exception:
|
|
3253
|
+
pass
|
|
3254
|
+
return None
|
|
3255
|
+
if name == "get_scroll_offset":
|
|
3256
|
+
try:
|
|
3257
|
+
return {
|
|
3258
|
+
"x": 0.0,
|
|
3259
|
+
"y": float(native_view.computeVerticalScrollOffset()) / density,
|
|
3260
|
+
}
|
|
3261
|
+
except Exception:
|
|
3262
|
+
return {"x": 0.0, "y": 0.0}
|
|
3263
|
+
return None
|
|
3264
|
+
|
|
3265
|
+
|
|
2665
3266
|
# ======================================================================
|
|
2666
3267
|
# Registration
|
|
2667
3268
|
# ======================================================================
|
|
@@ -2685,6 +3286,7 @@ def register_handlers(registry: Any) -> None:
|
|
|
2685
3286
|
registry.register("Spacer", SpacerHandler())
|
|
2686
3287
|
registry.register("SafeAreaView", SafeAreaViewHandler())
|
|
2687
3288
|
registry.register("Modal", ModalHandler())
|
|
3289
|
+
registry.register("Portal", PortalHandler())
|
|
2688
3290
|
registry.register("Slider", SliderHandler())
|
|
2689
3291
|
registry.register("TabBar", TabBarHandler())
|
|
2690
3292
|
registry.register("Pressable", PressableHandler())
|
|
@@ -2694,6 +3296,7 @@ def register_handlers(registry: Any) -> None:
|
|
|
2694
3296
|
registry.register("Checkbox", CheckboxHandler())
|
|
2695
3297
|
registry.register("SegmentedControl", SegmentedControlHandler())
|
|
2696
3298
|
registry.register("DatePicker", DatePickerHandler())
|
|
3299
|
+
registry.register("VirtualList", VirtualListHandler())
|
|
2697
3300
|
|
|
2698
3301
|
|
|
2699
3302
|
__all__ = [
|
|
@@ -2711,6 +3314,7 @@ __all__ = [
|
|
|
2711
3314
|
"SpacerHandler",
|
|
2712
3315
|
"SafeAreaViewHandler",
|
|
2713
3316
|
"ModalHandler",
|
|
3317
|
+
"PortalHandler",
|
|
2714
3318
|
"SliderHandler",
|
|
2715
3319
|
"TabBarHandler",
|
|
2716
3320
|
"PressableHandler",
|
|
@@ -2720,5 +3324,6 @@ __all__ = [
|
|
|
2720
3324
|
"CheckboxHandler",
|
|
2721
3325
|
"SegmentedControlHandler",
|
|
2722
3326
|
"DatePickerHandler",
|
|
3327
|
+
"VirtualListHandler",
|
|
2723
3328
|
"register_handlers",
|
|
2724
3329
|
]
|