pythonnative 0.7.0__py3-none-any.whl → 0.8.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 +22 -1
- pythonnative/components.py +78 -21
- pythonnative/hooks.py +135 -29
- pythonnative/hot_reload.py +2 -2
- pythonnative/native_views/__init__.py +87 -0
- pythonnative/native_views/android.py +832 -0
- pythonnative/native_views/base.py +150 -0
- pythonnative/native_views/ios.py +777 -0
- pythonnative/navigation.py +571 -0
- pythonnative/page.py +61 -16
- pythonnative/reconciler.py +89 -1
- {pythonnative-0.7.0.dist-info → pythonnative-0.8.0.dist-info}/METADATA +1 -1
- {pythonnative-0.7.0.dist-info → pythonnative-0.8.0.dist-info}/RECORD +17 -13
- pythonnative/native_views.py +0 -1404
- {pythonnative-0.7.0.dist-info → pythonnative-0.8.0.dist-info}/WHEEL +0 -0
- {pythonnative-0.7.0.dist-info → pythonnative-0.8.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.7.0.dist-info → pythonnative-0.8.0.dist-info}/licenses/LICENSE +0 -0
- {pythonnative-0.7.0.dist-info → pythonnative-0.8.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,832 @@
|
|
|
1
|
+
"""Android native view handlers (Chaquopy / Java bridge).
|
|
2
|
+
|
|
3
|
+
Each handler class maps a PythonNative element type to an Android widget,
|
|
4
|
+
implementing view creation, property updates, and child management.
|
|
5
|
+
|
|
6
|
+
This module is only imported on Android at runtime; desktop tests inject
|
|
7
|
+
a mock registry via :func:`~.set_registry` and never trigger this import.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Any, Callable, Dict
|
|
11
|
+
|
|
12
|
+
from java import dynamic_proxy, jclass
|
|
13
|
+
|
|
14
|
+
from ..utils import get_android_context
|
|
15
|
+
from .base import CONTAINER_KEYS, LAYOUT_KEYS, ViewHandler, is_vertical, parse_color_int, resolve_padding
|
|
16
|
+
|
|
17
|
+
# ======================================================================
|
|
18
|
+
# Shared helpers
|
|
19
|
+
# ======================================================================
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _ctx() -> Any:
|
|
23
|
+
return get_android_context()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _density() -> float:
|
|
27
|
+
return float(_ctx().getResources().getDisplayMetrics().density)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _dp(value: float) -> int:
|
|
31
|
+
return int(value * _density())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _apply_layout(view: Any, props: Dict[str, Any]) -> None:
|
|
35
|
+
"""Apply common layout properties (child-level flex props) to an Android view."""
|
|
36
|
+
lp = view.getLayoutParams()
|
|
37
|
+
LayoutParams = jclass("android.widget.LinearLayout$LayoutParams")
|
|
38
|
+
ViewGroupLP = jclass("android.view.ViewGroup$LayoutParams")
|
|
39
|
+
Gravity = jclass("android.view.Gravity")
|
|
40
|
+
needs_set = False
|
|
41
|
+
|
|
42
|
+
if lp is None:
|
|
43
|
+
lp = LayoutParams(ViewGroupLP.WRAP_CONTENT, ViewGroupLP.WRAP_CONTENT)
|
|
44
|
+
needs_set = True
|
|
45
|
+
|
|
46
|
+
if "width" in props and props["width"] is not None:
|
|
47
|
+
lp.width = _dp(float(props["width"]))
|
|
48
|
+
needs_set = True
|
|
49
|
+
if "height" in props and props["height"] is not None:
|
|
50
|
+
lp.height = _dp(float(props["height"]))
|
|
51
|
+
needs_set = True
|
|
52
|
+
|
|
53
|
+
flex = props.get("flex")
|
|
54
|
+
flex_grow = props.get("flex_grow")
|
|
55
|
+
weight = None
|
|
56
|
+
if flex is not None:
|
|
57
|
+
weight = float(flex)
|
|
58
|
+
elif flex_grow is not None:
|
|
59
|
+
weight = float(flex_grow)
|
|
60
|
+
if weight is not None:
|
|
61
|
+
try:
|
|
62
|
+
lp.weight = weight
|
|
63
|
+
needs_set = True
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
if "margin" in props and props["margin"] is not None:
|
|
68
|
+
left, top, right, bottom = resolve_padding(props["margin"])
|
|
69
|
+
try:
|
|
70
|
+
lp.setMargins(_dp(left), _dp(top), _dp(right), _dp(bottom))
|
|
71
|
+
needs_set = True
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
if "align_self" in props and props["align_self"] is not None:
|
|
76
|
+
align_map = {
|
|
77
|
+
"flex_start": Gravity.START | Gravity.TOP,
|
|
78
|
+
"leading": Gravity.START | Gravity.TOP,
|
|
79
|
+
"center": Gravity.CENTER,
|
|
80
|
+
"flex_end": Gravity.END | Gravity.BOTTOM,
|
|
81
|
+
"trailing": Gravity.END | Gravity.BOTTOM,
|
|
82
|
+
"stretch": Gravity.FILL,
|
|
83
|
+
}
|
|
84
|
+
g = align_map.get(props["align_self"])
|
|
85
|
+
if g is not None:
|
|
86
|
+
lp.gravity = g
|
|
87
|
+
needs_set = True
|
|
88
|
+
|
|
89
|
+
if needs_set:
|
|
90
|
+
view.setLayoutParams(lp)
|
|
91
|
+
|
|
92
|
+
if "min_width" in props and props["min_width"] is not None:
|
|
93
|
+
view.setMinimumWidth(_dp(float(props["min_width"])))
|
|
94
|
+
if "min_height" in props and props["min_height"] is not None:
|
|
95
|
+
view.setMinimumHeight(_dp(float(props["min_height"])))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _apply_common_visual(view: Any, props: Dict[str, Any]) -> None:
|
|
99
|
+
"""Apply visual properties shared across many handlers."""
|
|
100
|
+
if "background_color" in props and props["background_color"] is not None:
|
|
101
|
+
view.setBackgroundColor(parse_color_int(props["background_color"]))
|
|
102
|
+
if "overflow" in props:
|
|
103
|
+
clip = props["overflow"] == "hidden"
|
|
104
|
+
try:
|
|
105
|
+
view.setClipChildren(clip)
|
|
106
|
+
view.setClipToPadding(clip)
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _apply_flex_container(container: Any, props: Dict[str, Any]) -> None:
|
|
112
|
+
"""Apply flex container properties to a LinearLayout.
|
|
113
|
+
|
|
114
|
+
Handles spacing, padding, alignment, justification, background, and overflow.
|
|
115
|
+
"""
|
|
116
|
+
LinearLayout = jclass("android.widget.LinearLayout")
|
|
117
|
+
Gravity = jclass("android.view.Gravity")
|
|
118
|
+
|
|
119
|
+
if "flex_direction" in props:
|
|
120
|
+
vertical = is_vertical(props["flex_direction"])
|
|
121
|
+
container.setOrientation(LinearLayout.VERTICAL if vertical else LinearLayout.HORIZONTAL)
|
|
122
|
+
|
|
123
|
+
direction = props.get("flex_direction", "column")
|
|
124
|
+
vertical = is_vertical(direction)
|
|
125
|
+
|
|
126
|
+
if "spacing" in props and props["spacing"]:
|
|
127
|
+
px = _dp(float(props["spacing"]))
|
|
128
|
+
GradientDrawable = jclass("android.graphics.drawable.GradientDrawable")
|
|
129
|
+
d = GradientDrawable()
|
|
130
|
+
d.setColor(0x00000000)
|
|
131
|
+
d.setSize(1 if vertical else px, px if vertical else 1)
|
|
132
|
+
container.setShowDividers(LinearLayout.SHOW_DIVIDER_MIDDLE)
|
|
133
|
+
container.setDividerDrawable(d)
|
|
134
|
+
|
|
135
|
+
if "padding" in props:
|
|
136
|
+
left, top, right, bottom = resolve_padding(props["padding"])
|
|
137
|
+
container.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom))
|
|
138
|
+
|
|
139
|
+
gravity = 0
|
|
140
|
+
ai = props.get("align_items") or props.get("alignment")
|
|
141
|
+
if ai:
|
|
142
|
+
if vertical:
|
|
143
|
+
cross_map = {
|
|
144
|
+
"stretch": Gravity.FILL_HORIZONTAL,
|
|
145
|
+
"fill": Gravity.FILL_HORIZONTAL,
|
|
146
|
+
"flex_start": Gravity.START,
|
|
147
|
+
"leading": Gravity.START,
|
|
148
|
+
"start": Gravity.START,
|
|
149
|
+
"center": Gravity.CENTER_HORIZONTAL,
|
|
150
|
+
"flex_end": Gravity.END,
|
|
151
|
+
"trailing": Gravity.END,
|
|
152
|
+
"end": Gravity.END,
|
|
153
|
+
}
|
|
154
|
+
else:
|
|
155
|
+
cross_map = {
|
|
156
|
+
"stretch": Gravity.FILL_VERTICAL,
|
|
157
|
+
"fill": Gravity.FILL_VERTICAL,
|
|
158
|
+
"flex_start": Gravity.TOP,
|
|
159
|
+
"top": Gravity.TOP,
|
|
160
|
+
"center": Gravity.CENTER_VERTICAL,
|
|
161
|
+
"flex_end": Gravity.BOTTOM,
|
|
162
|
+
"bottom": Gravity.BOTTOM,
|
|
163
|
+
}
|
|
164
|
+
gravity |= cross_map.get(ai, 0)
|
|
165
|
+
|
|
166
|
+
jc = props.get("justify_content")
|
|
167
|
+
if jc:
|
|
168
|
+
if vertical:
|
|
169
|
+
main_map = {
|
|
170
|
+
"flex_start": Gravity.TOP,
|
|
171
|
+
"center": Gravity.CENTER_VERTICAL,
|
|
172
|
+
"flex_end": Gravity.BOTTOM,
|
|
173
|
+
}
|
|
174
|
+
else:
|
|
175
|
+
main_map = {
|
|
176
|
+
"flex_start": Gravity.START,
|
|
177
|
+
"center": Gravity.CENTER_HORIZONTAL,
|
|
178
|
+
"flex_end": Gravity.END,
|
|
179
|
+
}
|
|
180
|
+
gravity |= main_map.get(jc, 0)
|
|
181
|
+
|
|
182
|
+
if gravity:
|
|
183
|
+
container.setGravity(gravity)
|
|
184
|
+
|
|
185
|
+
_apply_common_visual(container, props)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ======================================================================
|
|
189
|
+
# Flex container handler (shared by Column, Row, View)
|
|
190
|
+
# ======================================================================
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class FlexContainerHandler(ViewHandler):
|
|
194
|
+
"""Unified handler for flex layout containers (Column, Row, View).
|
|
195
|
+
|
|
196
|
+
All three element types use ``LinearLayout`` with orientation
|
|
197
|
+
determined by the ``flex_direction`` prop.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
def create(self, props: Dict[str, Any]) -> Any:
|
|
201
|
+
ll = jclass("android.widget.LinearLayout")(_ctx())
|
|
202
|
+
direction = props.get("flex_direction", "column")
|
|
203
|
+
LinearLayout = jclass("android.widget.LinearLayout")
|
|
204
|
+
ll.setOrientation(LinearLayout.VERTICAL if is_vertical(direction) else LinearLayout.HORIZONTAL)
|
|
205
|
+
_apply_flex_container(ll, props)
|
|
206
|
+
_apply_layout(ll, props)
|
|
207
|
+
return ll
|
|
208
|
+
|
|
209
|
+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
210
|
+
if changed.keys() & CONTAINER_KEYS:
|
|
211
|
+
_apply_flex_container(native_view, changed)
|
|
212
|
+
if changed.keys() & LAYOUT_KEYS:
|
|
213
|
+
_apply_layout(native_view, changed)
|
|
214
|
+
|
|
215
|
+
def add_child(self, parent: Any, child: Any) -> None:
|
|
216
|
+
parent.addView(child)
|
|
217
|
+
|
|
218
|
+
def remove_child(self, parent: Any, child: Any) -> None:
|
|
219
|
+
parent.removeView(child)
|
|
220
|
+
|
|
221
|
+
def insert_child(self, parent: Any, child: Any, index: int) -> None:
|
|
222
|
+
parent.addView(child, index)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# ======================================================================
|
|
226
|
+
# Leaf handlers
|
|
227
|
+
# ======================================================================
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class TextHandler(ViewHandler):
|
|
231
|
+
def create(self, props: Dict[str, Any]) -> Any:
|
|
232
|
+
tv = jclass("android.widget.TextView")(_ctx())
|
|
233
|
+
self._apply(tv, props)
|
|
234
|
+
_apply_layout(tv, props)
|
|
235
|
+
return tv
|
|
236
|
+
|
|
237
|
+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
238
|
+
self._apply(native_view, changed)
|
|
239
|
+
if changed.keys() & LAYOUT_KEYS:
|
|
240
|
+
_apply_layout(native_view, changed)
|
|
241
|
+
|
|
242
|
+
def _apply(self, tv: Any, props: Dict[str, Any]) -> None:
|
|
243
|
+
if "text" in props:
|
|
244
|
+
tv.setText(str(props["text"]))
|
|
245
|
+
if "font_size" in props and props["font_size"] is not None:
|
|
246
|
+
tv.setTextSize(float(props["font_size"]))
|
|
247
|
+
if "color" in props and props["color"] is not None:
|
|
248
|
+
tv.setTextColor(parse_color_int(props["color"]))
|
|
249
|
+
if "background_color" in props and props["background_color"] is not None:
|
|
250
|
+
tv.setBackgroundColor(parse_color_int(props["background_color"]))
|
|
251
|
+
if "bold" in props and props["bold"]:
|
|
252
|
+
tv.setTypeface(tv.getTypeface(), 1) # Typeface.BOLD = 1
|
|
253
|
+
if "max_lines" in props and props["max_lines"] is not None:
|
|
254
|
+
tv.setMaxLines(int(props["max_lines"]))
|
|
255
|
+
if "text_align" in props:
|
|
256
|
+
Gravity = jclass("android.view.Gravity")
|
|
257
|
+
mapping = {"left": Gravity.START, "center": Gravity.CENTER, "right": Gravity.END}
|
|
258
|
+
tv.setGravity(mapping.get(props["text_align"], Gravity.START))
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class ButtonHandler(ViewHandler):
|
|
262
|
+
def create(self, props: Dict[str, Any]) -> Any:
|
|
263
|
+
btn = jclass("android.widget.Button")(_ctx())
|
|
264
|
+
self._apply(btn, props)
|
|
265
|
+
_apply_layout(btn, props)
|
|
266
|
+
return btn
|
|
267
|
+
|
|
268
|
+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
269
|
+
self._apply(native_view, changed)
|
|
270
|
+
if changed.keys() & LAYOUT_KEYS:
|
|
271
|
+
_apply_layout(native_view, changed)
|
|
272
|
+
|
|
273
|
+
def _apply(self, btn: Any, props: Dict[str, Any]) -> None:
|
|
274
|
+
if "title" in props:
|
|
275
|
+
btn.setText(str(props["title"]))
|
|
276
|
+
if "font_size" in props and props["font_size"] is not None:
|
|
277
|
+
btn.setTextSize(float(props["font_size"]))
|
|
278
|
+
if "color" in props and props["color"] is not None:
|
|
279
|
+
btn.setTextColor(parse_color_int(props["color"]))
|
|
280
|
+
if "background_color" in props and props["background_color"] is not None:
|
|
281
|
+
btn.setBackgroundColor(parse_color_int(props["background_color"]))
|
|
282
|
+
if "enabled" in props:
|
|
283
|
+
btn.setEnabled(bool(props["enabled"]))
|
|
284
|
+
if "on_click" in props:
|
|
285
|
+
cb = props["on_click"]
|
|
286
|
+
if cb is not None:
|
|
287
|
+
|
|
288
|
+
class ClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
|
|
289
|
+
def __init__(self, callback: Callable[[], None]) -> None:
|
|
290
|
+
super().__init__()
|
|
291
|
+
self.callback = callback
|
|
292
|
+
|
|
293
|
+
def onClick(self, view: Any) -> None:
|
|
294
|
+
self.callback()
|
|
295
|
+
|
|
296
|
+
btn.setOnClickListener(ClickProxy(cb))
|
|
297
|
+
else:
|
|
298
|
+
btn.setOnClickListener(None)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class ScrollViewHandler(ViewHandler):
|
|
302
|
+
def create(self, props: Dict[str, Any]) -> Any:
|
|
303
|
+
sv = jclass("android.widget.ScrollView")(_ctx())
|
|
304
|
+
if "background_color" in props and props["background_color"] is not None:
|
|
305
|
+
sv.setBackgroundColor(parse_color_int(props["background_color"]))
|
|
306
|
+
_apply_layout(sv, props)
|
|
307
|
+
return sv
|
|
308
|
+
|
|
309
|
+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
310
|
+
if "background_color" in changed and changed["background_color"] is not None:
|
|
311
|
+
native_view.setBackgroundColor(parse_color_int(changed["background_color"]))
|
|
312
|
+
if changed.keys() & LAYOUT_KEYS:
|
|
313
|
+
_apply_layout(native_view, changed)
|
|
314
|
+
|
|
315
|
+
def add_child(self, parent: Any, child: Any) -> None:
|
|
316
|
+
parent.addView(child)
|
|
317
|
+
|
|
318
|
+
def remove_child(self, parent: Any, child: Any) -> None:
|
|
319
|
+
parent.removeView(child)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class TextInputHandler(ViewHandler):
|
|
323
|
+
def create(self, props: Dict[str, Any]) -> Any:
|
|
324
|
+
et = jclass("android.widget.EditText")(_ctx())
|
|
325
|
+
self._apply(et, props)
|
|
326
|
+
_apply_layout(et, props)
|
|
327
|
+
return et
|
|
328
|
+
|
|
329
|
+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
330
|
+
self._apply(native_view, changed)
|
|
331
|
+
if changed.keys() & LAYOUT_KEYS:
|
|
332
|
+
_apply_layout(native_view, changed)
|
|
333
|
+
|
|
334
|
+
def _apply(self, et: Any, props: Dict[str, Any]) -> None:
|
|
335
|
+
if "value" in props:
|
|
336
|
+
et.setText(str(props["value"]))
|
|
337
|
+
if "placeholder" in props:
|
|
338
|
+
et.setHint(str(props["placeholder"]))
|
|
339
|
+
if "font_size" in props and props["font_size"] is not None:
|
|
340
|
+
et.setTextSize(float(props["font_size"]))
|
|
341
|
+
if "color" in props and props["color"] is not None:
|
|
342
|
+
et.setTextColor(parse_color_int(props["color"]))
|
|
343
|
+
if "background_color" in props and props["background_color"] is not None:
|
|
344
|
+
et.setBackgroundColor(parse_color_int(props["background_color"]))
|
|
345
|
+
if "secure" in props and props["secure"]:
|
|
346
|
+
InputType = jclass("android.text.InputType")
|
|
347
|
+
et.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD)
|
|
348
|
+
if "on_change" in props:
|
|
349
|
+
cb = props["on_change"]
|
|
350
|
+
if cb is not None:
|
|
351
|
+
TextWatcher = jclass("android.text.TextWatcher")
|
|
352
|
+
|
|
353
|
+
class ChangeProxy(dynamic_proxy(TextWatcher)):
|
|
354
|
+
def __init__(self, callback: Callable[[str], None]) -> None:
|
|
355
|
+
super().__init__()
|
|
356
|
+
self.callback = callback
|
|
357
|
+
|
|
358
|
+
def afterTextChanged(self, s: Any) -> None:
|
|
359
|
+
self.callback(str(s))
|
|
360
|
+
|
|
361
|
+
def beforeTextChanged(self, s: Any, start: int, count: int, after: int) -> None:
|
|
362
|
+
pass
|
|
363
|
+
|
|
364
|
+
def onTextChanged(self, s: Any, start: int, before: int, count: int) -> None:
|
|
365
|
+
pass
|
|
366
|
+
|
|
367
|
+
et.addTextChangedListener(ChangeProxy(cb))
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class ImageHandler(ViewHandler):
|
|
371
|
+
def create(self, props: Dict[str, Any]) -> Any:
|
|
372
|
+
iv = jclass("android.widget.ImageView")(_ctx())
|
|
373
|
+
self._apply(iv, props)
|
|
374
|
+
_apply_layout(iv, props)
|
|
375
|
+
return iv
|
|
376
|
+
|
|
377
|
+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
378
|
+
self._apply(native_view, changed)
|
|
379
|
+
if changed.keys() & LAYOUT_KEYS:
|
|
380
|
+
_apply_layout(native_view, changed)
|
|
381
|
+
|
|
382
|
+
def _apply(self, iv: Any, props: Dict[str, Any]) -> None:
|
|
383
|
+
if "background_color" in props and props["background_color"] is not None:
|
|
384
|
+
iv.setBackgroundColor(parse_color_int(props["background_color"]))
|
|
385
|
+
if "source" in props and props["source"]:
|
|
386
|
+
self._load_source(iv, props["source"])
|
|
387
|
+
if "scale_type" in props and props["scale_type"]:
|
|
388
|
+
ScaleType = jclass("android.widget.ImageView$ScaleType")
|
|
389
|
+
mapping = {
|
|
390
|
+
"cover": ScaleType.CENTER_CROP,
|
|
391
|
+
"contain": ScaleType.FIT_CENTER,
|
|
392
|
+
"stretch": ScaleType.FIT_XY,
|
|
393
|
+
"center": ScaleType.CENTER,
|
|
394
|
+
}
|
|
395
|
+
st = mapping.get(props["scale_type"])
|
|
396
|
+
if st:
|
|
397
|
+
iv.setScaleType(st)
|
|
398
|
+
|
|
399
|
+
def _load_source(self, iv: Any, source: str) -> None:
|
|
400
|
+
try:
|
|
401
|
+
if source.startswith(("http://", "https://")):
|
|
402
|
+
Thread = jclass("java.lang.Thread")
|
|
403
|
+
Runnable = jclass("java.lang.Runnable")
|
|
404
|
+
URL = jclass("java.net.URL")
|
|
405
|
+
BitmapFactory = jclass("android.graphics.BitmapFactory")
|
|
406
|
+
Handler = jclass("android.os.Handler")
|
|
407
|
+
Looper = jclass("android.os.Looper")
|
|
408
|
+
handler = Handler(Looper.getMainLooper())
|
|
409
|
+
|
|
410
|
+
class LoadTask(dynamic_proxy(Runnable)):
|
|
411
|
+
def __init__(self, image_view: Any, url_str: str, main_handler: Any) -> None:
|
|
412
|
+
super().__init__()
|
|
413
|
+
self.image_view = image_view
|
|
414
|
+
self.url_str = url_str
|
|
415
|
+
self.main_handler = main_handler
|
|
416
|
+
|
|
417
|
+
def run(self) -> None:
|
|
418
|
+
try:
|
|
419
|
+
url = URL(self.url_str)
|
|
420
|
+
stream = url.openStream()
|
|
421
|
+
bitmap = BitmapFactory.decodeStream(stream)
|
|
422
|
+
stream.close()
|
|
423
|
+
|
|
424
|
+
class SetImage(dynamic_proxy(Runnable)):
|
|
425
|
+
def __init__(self, view: Any, bmp: Any) -> None:
|
|
426
|
+
super().__init__()
|
|
427
|
+
self.view = view
|
|
428
|
+
self.bmp = bmp
|
|
429
|
+
|
|
430
|
+
def run(self) -> None:
|
|
431
|
+
self.view.setImageBitmap(self.bmp)
|
|
432
|
+
|
|
433
|
+
self.main_handler.post(SetImage(self.image_view, bitmap))
|
|
434
|
+
except Exception:
|
|
435
|
+
pass
|
|
436
|
+
|
|
437
|
+
Thread(LoadTask(iv, source, handler)).start()
|
|
438
|
+
else:
|
|
439
|
+
ctx = _ctx()
|
|
440
|
+
res = ctx.getResources()
|
|
441
|
+
pkg = ctx.getPackageName()
|
|
442
|
+
res_name = source.rsplit(".", 1)[0] if "." in source else source
|
|
443
|
+
res_id = res.getIdentifier(res_name, "drawable", pkg)
|
|
444
|
+
if res_id != 0:
|
|
445
|
+
iv.setImageResource(res_id)
|
|
446
|
+
except Exception:
|
|
447
|
+
pass
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
class SwitchHandler(ViewHandler):
|
|
451
|
+
def create(self, props: Dict[str, Any]) -> Any:
|
|
452
|
+
sw = jclass("android.widget.Switch")(_ctx())
|
|
453
|
+
self._apply(sw, props)
|
|
454
|
+
_apply_layout(sw, props)
|
|
455
|
+
return sw
|
|
456
|
+
|
|
457
|
+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
458
|
+
self._apply(native_view, changed)
|
|
459
|
+
|
|
460
|
+
def _apply(self, sw: Any, props: Dict[str, Any]) -> None:
|
|
461
|
+
if "value" in props:
|
|
462
|
+
sw.setChecked(bool(props["value"]))
|
|
463
|
+
if "on_change" in props and props["on_change"] is not None:
|
|
464
|
+
cb = props["on_change"]
|
|
465
|
+
|
|
466
|
+
class CheckedProxy(dynamic_proxy(jclass("android.widget.CompoundButton").OnCheckedChangeListener)):
|
|
467
|
+
def __init__(self, callback: Callable[[bool], None]) -> None:
|
|
468
|
+
super().__init__()
|
|
469
|
+
self.callback = callback
|
|
470
|
+
|
|
471
|
+
def onCheckedChanged(self, button: Any, checked: bool) -> None:
|
|
472
|
+
self.callback(checked)
|
|
473
|
+
|
|
474
|
+
sw.setOnCheckedChangeListener(CheckedProxy(cb))
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
class ProgressBarHandler(ViewHandler):
|
|
478
|
+
def create(self, props: Dict[str, Any]) -> Any:
|
|
479
|
+
style = jclass("android.R$attr").progressBarStyleHorizontal
|
|
480
|
+
pb = jclass("android.widget.ProgressBar")(_ctx(), None, 0, style)
|
|
481
|
+
pb.setMax(1000)
|
|
482
|
+
self._apply(pb, props)
|
|
483
|
+
_apply_layout(pb, props)
|
|
484
|
+
return pb
|
|
485
|
+
|
|
486
|
+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
487
|
+
self._apply(native_view, changed)
|
|
488
|
+
|
|
489
|
+
def _apply(self, pb: Any, props: Dict[str, Any]) -> None:
|
|
490
|
+
if "value" in props:
|
|
491
|
+
pb.setProgress(int(float(props["value"]) * 1000))
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
class ActivityIndicatorHandler(ViewHandler):
|
|
495
|
+
def create(self, props: Dict[str, Any]) -> Any:
|
|
496
|
+
pb = jclass("android.widget.ProgressBar")(_ctx())
|
|
497
|
+
if not props.get("animating", True):
|
|
498
|
+
pb.setVisibility(jclass("android.view.View").GONE)
|
|
499
|
+
_apply_layout(pb, props)
|
|
500
|
+
return pb
|
|
501
|
+
|
|
502
|
+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
503
|
+
View = jclass("android.view.View")
|
|
504
|
+
if "animating" in changed:
|
|
505
|
+
native_view.setVisibility(View.VISIBLE if changed["animating"] else View.GONE)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
class WebViewHandler(ViewHandler):
|
|
509
|
+
def create(self, props: Dict[str, Any]) -> Any:
|
|
510
|
+
wv = jclass("android.webkit.WebView")(_ctx())
|
|
511
|
+
if "url" in props and props["url"]:
|
|
512
|
+
wv.loadUrl(str(props["url"]))
|
|
513
|
+
_apply_layout(wv, props)
|
|
514
|
+
return wv
|
|
515
|
+
|
|
516
|
+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
517
|
+
if "url" in changed and changed["url"]:
|
|
518
|
+
native_view.loadUrl(str(changed["url"]))
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
class SpacerHandler(ViewHandler):
|
|
522
|
+
def create(self, props: Dict[str, Any]) -> Any:
|
|
523
|
+
v = jclass("android.view.View")(_ctx())
|
|
524
|
+
if "size" in props and props["size"] is not None:
|
|
525
|
+
px = _dp(float(props["size"]))
|
|
526
|
+
lp = jclass("android.widget.LinearLayout$LayoutParams")(px, px)
|
|
527
|
+
v.setLayoutParams(lp)
|
|
528
|
+
if "flex" in props and props["flex"] is not None:
|
|
529
|
+
lp = v.getLayoutParams()
|
|
530
|
+
if lp is None:
|
|
531
|
+
lp = jclass("android.widget.LinearLayout$LayoutParams")(0, 0)
|
|
532
|
+
lp.weight = float(props["flex"])
|
|
533
|
+
v.setLayoutParams(lp)
|
|
534
|
+
return v
|
|
535
|
+
|
|
536
|
+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
537
|
+
if "size" in changed and changed["size"] is not None:
|
|
538
|
+
px = _dp(float(changed["size"]))
|
|
539
|
+
lp = jclass("android.widget.LinearLayout$LayoutParams")(px, px)
|
|
540
|
+
native_view.setLayoutParams(lp)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
class SafeAreaViewHandler(ViewHandler):
|
|
544
|
+
"""Safe-area container using FrameLayout with ``fitsSystemWindows``."""
|
|
545
|
+
|
|
546
|
+
def create(self, props: Dict[str, Any]) -> Any:
|
|
547
|
+
fl = jclass("android.widget.FrameLayout")(_ctx())
|
|
548
|
+
fl.setFitsSystemWindows(True)
|
|
549
|
+
if "background_color" in props and props["background_color"] is not None:
|
|
550
|
+
fl.setBackgroundColor(parse_color_int(props["background_color"]))
|
|
551
|
+
if "padding" in props:
|
|
552
|
+
left, top, right, bottom = resolve_padding(props["padding"])
|
|
553
|
+
fl.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom))
|
|
554
|
+
return fl
|
|
555
|
+
|
|
556
|
+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
557
|
+
if "background_color" in changed and changed["background_color"] is not None:
|
|
558
|
+
native_view.setBackgroundColor(parse_color_int(changed["background_color"]))
|
|
559
|
+
|
|
560
|
+
def add_child(self, parent: Any, child: Any) -> None:
|
|
561
|
+
parent.addView(child)
|
|
562
|
+
|
|
563
|
+
def remove_child(self, parent: Any, child: Any) -> None:
|
|
564
|
+
parent.removeView(child)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
class ModalHandler(ViewHandler):
|
|
568
|
+
def create(self, props: Dict[str, Any]) -> Any:
|
|
569
|
+
placeholder = jclass("android.view.View")(_ctx())
|
|
570
|
+
placeholder.setVisibility(jclass("android.view.View").GONE)
|
|
571
|
+
return placeholder
|
|
572
|
+
|
|
573
|
+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
574
|
+
pass
|
|
575
|
+
|
|
576
|
+
def add_child(self, parent: Any, child: Any) -> None:
|
|
577
|
+
pass
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
class SliderHandler(ViewHandler):
|
|
581
|
+
def create(self, props: Dict[str, Any]) -> Any:
|
|
582
|
+
sb = jclass("android.widget.SeekBar")(_ctx())
|
|
583
|
+
sb.setMax(1000)
|
|
584
|
+
self._apply(sb, props)
|
|
585
|
+
_apply_layout(sb, props)
|
|
586
|
+
return sb
|
|
587
|
+
|
|
588
|
+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
589
|
+
self._apply(native_view, changed)
|
|
590
|
+
|
|
591
|
+
def _apply(self, sb: Any, props: Dict[str, Any]) -> None:
|
|
592
|
+
min_val = float(props.get("min_value", 0))
|
|
593
|
+
max_val = float(props.get("max_value", 1))
|
|
594
|
+
rng = max_val - min_val if max_val != min_val else 1
|
|
595
|
+
if "value" in props:
|
|
596
|
+
normalized = (float(props["value"]) - min_val) / rng
|
|
597
|
+
sb.setProgress(int(normalized * 1000))
|
|
598
|
+
if "on_change" in props and props["on_change"] is not None:
|
|
599
|
+
cb = props["on_change"]
|
|
600
|
+
|
|
601
|
+
class SeekProxy(dynamic_proxy(jclass("android.widget.SeekBar").OnSeekBarChangeListener)):
|
|
602
|
+
def __init__(self, callback: Callable[[float], None], mn: float, rn: float) -> None:
|
|
603
|
+
super().__init__()
|
|
604
|
+
self.callback = callback
|
|
605
|
+
self.mn = mn
|
|
606
|
+
self.rn = rn
|
|
607
|
+
|
|
608
|
+
def onProgressChanged(self, seekBar: Any, progress: int, fromUser: bool) -> None:
|
|
609
|
+
if fromUser:
|
|
610
|
+
self.callback(self.mn + (progress / 1000.0) * self.rn)
|
|
611
|
+
|
|
612
|
+
def onStartTrackingTouch(self, seekBar: Any) -> None:
|
|
613
|
+
pass
|
|
614
|
+
|
|
615
|
+
def onStopTrackingTouch(self, seekBar: Any) -> None:
|
|
616
|
+
pass
|
|
617
|
+
|
|
618
|
+
sb.setOnSeekBarChangeListener(SeekProxy(cb, min_val, rng))
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
_android_tabbar_state: dict = {"callback": None, "items": []}
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
class TabBarHandler(ViewHandler):
|
|
625
|
+
"""Native tab bar using ``BottomNavigationView`` from Material Components.
|
|
626
|
+
|
|
627
|
+
Falls back to a horizontal ``LinearLayout`` with ``Button`` children
|
|
628
|
+
when Material Components is unavailable.
|
|
629
|
+
"""
|
|
630
|
+
|
|
631
|
+
_is_material: bool = True
|
|
632
|
+
|
|
633
|
+
def create(self, props: Dict[str, Any]) -> Any:
|
|
634
|
+
try:
|
|
635
|
+
bnv = jclass("com.google.android.material.bottomnavigation.BottomNavigationView")(_ctx())
|
|
636
|
+
bnv.setBackgroundColor(parse_color_int("#FFFFFF"))
|
|
637
|
+
ViewGroupLP = jclass("android.view.ViewGroup$LayoutParams")
|
|
638
|
+
LayoutParams = jclass("android.widget.LinearLayout$LayoutParams")
|
|
639
|
+
lp = LayoutParams(ViewGroupLP.MATCH_PARENT, ViewGroupLP.WRAP_CONTENT)
|
|
640
|
+
bnv.setLayoutParams(lp)
|
|
641
|
+
self._is_material = True
|
|
642
|
+
self._apply_full(bnv, props)
|
|
643
|
+
return bnv
|
|
644
|
+
except Exception:
|
|
645
|
+
self._is_material = False
|
|
646
|
+
return self._create_fallback(props)
|
|
647
|
+
|
|
648
|
+
def _create_fallback(self, props: Dict[str, Any]) -> Any:
|
|
649
|
+
"""Horizontal LinearLayout with Button children as a tab-bar fallback."""
|
|
650
|
+
LinearLayout = jclass("android.widget.LinearLayout")
|
|
651
|
+
ll = LinearLayout(_ctx())
|
|
652
|
+
ll.setOrientation(LinearLayout.HORIZONTAL)
|
|
653
|
+
ll.setBackgroundColor(parse_color_int("#F8F8F8"))
|
|
654
|
+
self._apply_fallback(ll, props)
|
|
655
|
+
return ll
|
|
656
|
+
|
|
657
|
+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
658
|
+
if self._is_material:
|
|
659
|
+
self._apply_partial(native_view, changed)
|
|
660
|
+
else:
|
|
661
|
+
self._apply_fallback(native_view, changed)
|
|
662
|
+
|
|
663
|
+
def _apply_full(self, bnv: Any, props: Dict[str, Any]) -> None:
|
|
664
|
+
"""Initial creation — all props are present."""
|
|
665
|
+
items = props.get("items", [])
|
|
666
|
+
self._set_menu(bnv, items)
|
|
667
|
+
self._set_active(bnv, props.get("active_tab"), items)
|
|
668
|
+
cb = props.get("on_tab_select")
|
|
669
|
+
if cb is not None:
|
|
670
|
+
self._set_listener(bnv, cb, items)
|
|
671
|
+
|
|
672
|
+
def _apply_partial(self, bnv: Any, changed: Dict[str, Any]) -> None:
|
|
673
|
+
"""Reconciler update — only changed props are present."""
|
|
674
|
+
prev_items = _android_tabbar_state["items"]
|
|
675
|
+
|
|
676
|
+
if "items" in changed:
|
|
677
|
+
items = changed["items"]
|
|
678
|
+
self._set_menu(bnv, items)
|
|
679
|
+
else:
|
|
680
|
+
items = prev_items
|
|
681
|
+
|
|
682
|
+
if "active_tab" in changed:
|
|
683
|
+
self._set_active(bnv, changed["active_tab"], items)
|
|
684
|
+
|
|
685
|
+
if "on_tab_select" in changed:
|
|
686
|
+
cb = changed["on_tab_select"]
|
|
687
|
+
if cb is not None:
|
|
688
|
+
self._set_listener(bnv, cb, items)
|
|
689
|
+
|
|
690
|
+
def _set_menu(self, bnv: Any, items: list) -> None:
|
|
691
|
+
_android_tabbar_state["items"] = items
|
|
692
|
+
try:
|
|
693
|
+
menu = bnv.getMenu()
|
|
694
|
+
menu.clear()
|
|
695
|
+
for i, item in enumerate(items):
|
|
696
|
+
title = item.get("title", item.get("name", ""))
|
|
697
|
+
menu.add(0, i, i, str(title))
|
|
698
|
+
except Exception:
|
|
699
|
+
pass
|
|
700
|
+
|
|
701
|
+
def _set_active(self, bnv: Any, active: Any, items: list) -> None:
|
|
702
|
+
if active and items:
|
|
703
|
+
for i, item in enumerate(items):
|
|
704
|
+
if item.get("name") == active:
|
|
705
|
+
try:
|
|
706
|
+
bnv.setSelectedItemId(i)
|
|
707
|
+
except Exception:
|
|
708
|
+
pass
|
|
709
|
+
break
|
|
710
|
+
|
|
711
|
+
def _set_listener(self, bnv: Any, cb: Callable, items: list) -> None:
|
|
712
|
+
_android_tabbar_state["callback"] = cb
|
|
713
|
+
_android_tabbar_state["items"] = items
|
|
714
|
+
try:
|
|
715
|
+
listener_cls = jclass("com.google.android.material.navigation.NavigationBarView$OnItemSelectedListener")
|
|
716
|
+
|
|
717
|
+
class _TabSelectProxy(dynamic_proxy(listener_cls)):
|
|
718
|
+
def __init__(self, callback: Callable, tab_items: list) -> None:
|
|
719
|
+
super().__init__()
|
|
720
|
+
self.callback = callback
|
|
721
|
+
self.tab_items = tab_items
|
|
722
|
+
|
|
723
|
+
def onNavigationItemSelected(self, menu_item: Any) -> bool:
|
|
724
|
+
idx = menu_item.getItemId()
|
|
725
|
+
if 0 <= idx < len(self.tab_items):
|
|
726
|
+
self.callback(self.tab_items[idx].get("name", ""))
|
|
727
|
+
return True
|
|
728
|
+
|
|
729
|
+
bnv.setOnItemSelectedListener(_TabSelectProxy(cb, items))
|
|
730
|
+
except Exception:
|
|
731
|
+
pass
|
|
732
|
+
|
|
733
|
+
def _apply_fallback(self, ll: Any, props: Dict[str, Any]) -> None:
|
|
734
|
+
items = props.get("items", [])
|
|
735
|
+
active = props.get("active_tab")
|
|
736
|
+
cb = props.get("on_tab_select")
|
|
737
|
+
if "items" in props:
|
|
738
|
+
ll.removeAllViews()
|
|
739
|
+
for item in items:
|
|
740
|
+
name = item.get("name", "")
|
|
741
|
+
title = item.get("title", name)
|
|
742
|
+
btn = jclass("android.widget.Button")(_ctx())
|
|
743
|
+
btn.setText(str(title))
|
|
744
|
+
btn.setEnabled(name != active)
|
|
745
|
+
if cb is not None:
|
|
746
|
+
tab_name = name
|
|
747
|
+
|
|
748
|
+
def _make_click(n: str) -> Callable[[], None]:
|
|
749
|
+
return lambda: cb(n)
|
|
750
|
+
|
|
751
|
+
class _ClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
|
|
752
|
+
def __init__(self, callback: Callable[[], None]) -> None:
|
|
753
|
+
super().__init__()
|
|
754
|
+
self.callback = callback
|
|
755
|
+
|
|
756
|
+
def onClick(self, view: Any) -> None:
|
|
757
|
+
self.callback()
|
|
758
|
+
|
|
759
|
+
btn.setOnClickListener(_ClickProxy(_make_click(tab_name)))
|
|
760
|
+
ll.addView(btn)
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
class PressableHandler(ViewHandler):
|
|
764
|
+
def create(self, props: Dict[str, Any]) -> Any:
|
|
765
|
+
fl = jclass("android.widget.FrameLayout")(_ctx())
|
|
766
|
+
fl.setClickable(True)
|
|
767
|
+
self._apply(fl, props)
|
|
768
|
+
return fl
|
|
769
|
+
|
|
770
|
+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
771
|
+
self._apply(native_view, changed)
|
|
772
|
+
|
|
773
|
+
def _apply(self, fl: Any, props: Dict[str, Any]) -> None:
|
|
774
|
+
if "on_press" in props and props["on_press"] is not None:
|
|
775
|
+
cb = props["on_press"]
|
|
776
|
+
|
|
777
|
+
class PressProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
|
|
778
|
+
def __init__(self, callback: Callable[[], None]) -> None:
|
|
779
|
+
super().__init__()
|
|
780
|
+
self.callback = callback
|
|
781
|
+
|
|
782
|
+
def onClick(self, view: Any) -> None:
|
|
783
|
+
self.callback()
|
|
784
|
+
|
|
785
|
+
fl.setOnClickListener(PressProxy(cb))
|
|
786
|
+
if "on_long_press" in props and props["on_long_press"] is not None:
|
|
787
|
+
cb = props["on_long_press"]
|
|
788
|
+
|
|
789
|
+
class LongPressProxy(dynamic_proxy(jclass("android.view.View").OnLongClickListener)):
|
|
790
|
+
def __init__(self, callback: Callable[[], None]) -> None:
|
|
791
|
+
super().__init__()
|
|
792
|
+
self.callback = callback
|
|
793
|
+
|
|
794
|
+
def onLongClick(self, view: Any) -> bool:
|
|
795
|
+
self.callback()
|
|
796
|
+
return True
|
|
797
|
+
|
|
798
|
+
fl.setOnLongClickListener(LongPressProxy(cb))
|
|
799
|
+
|
|
800
|
+
def add_child(self, parent: Any, child: Any) -> None:
|
|
801
|
+
parent.addView(child)
|
|
802
|
+
|
|
803
|
+
def remove_child(self, parent: Any, child: Any) -> None:
|
|
804
|
+
parent.removeView(child)
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
# ======================================================================
|
|
808
|
+
# Registration
|
|
809
|
+
# ======================================================================
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def register_handlers(registry: Any) -> None:
|
|
813
|
+
"""Register all Android view handlers with the given registry."""
|
|
814
|
+
flex = FlexContainerHandler()
|
|
815
|
+
registry.register("Text", TextHandler())
|
|
816
|
+
registry.register("Button", ButtonHandler())
|
|
817
|
+
registry.register("Column", flex)
|
|
818
|
+
registry.register("Row", flex)
|
|
819
|
+
registry.register("View", flex)
|
|
820
|
+
registry.register("ScrollView", ScrollViewHandler())
|
|
821
|
+
registry.register("TextInput", TextInputHandler())
|
|
822
|
+
registry.register("Image", ImageHandler())
|
|
823
|
+
registry.register("Switch", SwitchHandler())
|
|
824
|
+
registry.register("ProgressBar", ProgressBarHandler())
|
|
825
|
+
registry.register("ActivityIndicator", ActivityIndicatorHandler())
|
|
826
|
+
registry.register("WebView", WebViewHandler())
|
|
827
|
+
registry.register("Spacer", SpacerHandler())
|
|
828
|
+
registry.register("SafeAreaView", SafeAreaViewHandler())
|
|
829
|
+
registry.register("Modal", ModalHandler())
|
|
830
|
+
registry.register("Slider", SliderHandler())
|
|
831
|
+
registry.register("TabBar", TabBarHandler())
|
|
832
|
+
registry.register("Pressable", PressableHandler())
|