pythonnative 0.21.0__py3-none-any.whl → 0.22.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.
@@ -6,14 +6,27 @@ and frame application. Handlers are registered with the
6
6
  [`NativeViewRegistry`][pythonnative.native_views.NativeViewRegistry] by
7
7
  [`register_handlers`][pythonnative.native_views.android.register_handlers].
8
8
 
9
- Layout is owned by the pure-Python flex engine in
10
- [`pythonnative.layout`][pythonnative.layout]: container handlers create
11
- plain `FrameLayout`s, the engine computes per-child frames, and
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
12
19
  [`set_frame`][pythonnative.native_views.android.AndroidViewHandler.set_frame]
13
- applies those frames via per-child `MarginLayoutParams`. Handlers
14
- therefore only deal with *visual* props — text, colors, callbacks — and
15
- ignore everything in
16
- [`pythonnative.layout.LAYOUT_STYLE_KEYS`][pythonnative.layout.LAYOUT_STYLE_KEYS].
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.
25
+
26
+ **Animations**: ``timing`` animations on transform/opacity/color props
27
+ are driven natively by ``ObjectAnimator`` (Choreographer-paced, no
28
+ Python per-frame work); springs and decay fall back to the Python
29
+ ticker.
17
30
 
18
31
  This module is only imported on Android at runtime. Desktop tests
19
32
  inject a mock registry via
@@ -22,20 +35,16 @@ trigger this import path.
22
35
  """
23
36
 
24
37
  import math
38
+ import time
25
39
  from typing import Any, Callable, Dict, Optional, Tuple
26
40
 
27
41
  from java import dynamic_proxy, jclass
28
42
 
43
+ from ..events import dispatch_event, event_names
44
+ from ..gestures import make_arbiter
29
45
  from ..utils import get_android_context
30
46
  from .base import ViewHandler, _safe_max, parse_color_int
31
47
 
32
- _pn_text_input_watchers: dict = {}
33
- _pn_text_input_callbacks: dict = {}
34
- _pn_text_input_suppress_callbacks: dict = {}
35
- _pn_text_input_focus_listeners: dict = {}
36
- _pn_text_input_focus_callbacks: dict = {}
37
- _pn_text_input_clear_touch: dict = {}
38
- _pn_view_visual_props: dict = {}
39
48
  _DRAWABLE_STYLE_KEYS = ("background_color", "border_radius", "border_width", "border_color")
40
49
 
41
50
 
@@ -48,32 +57,65 @@ def _ctx() -> Any:
48
57
  return get_android_context()
49
58
 
50
59
 
51
- def _pn_runtime_class(class_name: str) -> Any:
52
- """Resolve a PythonNative Android helper class for the running app.
60
+ def _density() -> float:
61
+ return float(_ctx().getResources().getDisplayMetrics().density)
62
+
63
+
64
+ def _dp(value: float) -> int:
65
+ return int(round(value * _density()))
53
66
 
54
- The Android template's helper classes (e.g. ``PNVirtualListView``)
55
- live in the app's own package, which the ``pn`` CLI relocates to the
56
- configured ``application_id`` at build time. Deriving the package from
57
- the runtime ``Context`` (rather than hardcoding the template package)
58
- keeps these lookups correct for any app id.
59
67
 
60
- Args:
61
- class_name: The class name within the app package, e.g.
62
- ``"PNVirtualListView"`` or ``"PNVirtualListView$Delegate"``.
68
+ def _java_id(jobj: Any) -> int:
69
+ """Return ``System.identityHashCode(jobj)`` as a stable lookup key.
63
70
 
64
- Returns:
65
- The resolved Java class.
71
+ Chaquopy's ``JavaObject.__setattr__`` rejects unknown Python
72
+ attributes, so per-view bookkeeping can't live on the wrapper.
73
+ The JVM identity hash is stable for the lifetime of the Java
74
+ object and identical across every Python wrapper that proxies it.
66
75
  """
67
- package = _ctx().getPackageName()
68
- return jclass(f"{package}.{class_name}")
76
+ System = jclass("java.lang.System")
77
+ return int(System.identityHashCode(jobj))
69
78
 
70
79
 
71
- def _density() -> float:
72
- return float(_ctx().getResources().getDisplayMetrics().density)
80
+ # Tag table: java identity -> reconciler tag, and tag -> per-view state.
81
+ _view_tags: Dict[int, int] = {}
82
+ _view_state: Dict[int, Dict[str, Any]] = {}
73
83
 
74
84
 
75
- def _dp(value: float) -> int:
76
- return int(round(value * _density()))
85
+ def _remember(view: Any, tag: int) -> None:
86
+ _view_tags[_java_id(view)] = tag
87
+ _view_state[tag] = {"props": {}}
88
+
89
+
90
+ def _tag_of(view: Any) -> Optional[int]:
91
+ return _view_tags.get(_java_id(view))
92
+
93
+
94
+ def _state_of(view: Any) -> Dict[str, Any]:
95
+ tag = _tag_of(view)
96
+ if tag is None:
97
+ return {}
98
+ return _view_state.setdefault(tag, {"props": {}})
99
+
100
+
101
+ def _forget(view: Any) -> None:
102
+ tag = _view_tags.pop(_java_id(view), None)
103
+ if tag is not None:
104
+ _view_state.pop(tag, None)
105
+
106
+
107
+ def _fire(view: Any, name: str, *args: Any) -> bool:
108
+ """Dispatch event ``name`` for ``view`` through the tag registry."""
109
+ tag = _tag_of(view)
110
+ if tag is None:
111
+ return False
112
+ return dispatch_event(tag, name, *args)
113
+
114
+
115
+ def _has_event(view: Any, name: str) -> bool:
116
+ """Whether the element wired a callback named ``name`` this render."""
117
+ merged = _state_of(view).get("props") or {}
118
+ return name in event_names(merged)
77
119
 
78
120
 
79
121
  def _apply_border(view: Any, props: Dict[str, Any]) -> None:
@@ -211,11 +253,12 @@ def _apply_common_visual(view: Any, props: Dict[str, Any]) -> None:
211
253
  """Apply visual properties shared across many handlers."""
212
254
  has_drawable_keys = any(k in props for k in _DRAWABLE_STYLE_KEYS)
213
255
  if has_drawable_keys:
214
- visual_props = dict(_pn_view_visual_props.get(id(view), {}))
256
+ state = _state_of(view)
257
+ visual_props = dict(state.get("visual") or {})
215
258
  for key in _DRAWABLE_STYLE_KEYS:
216
259
  if key in props:
217
260
  visual_props[key] = props[key]
218
- _pn_view_visual_props[id(view)] = visual_props
261
+ state["visual"] = visual_props
219
262
  _apply_border(view, visual_props)
220
263
  if "overflow" in props:
221
264
  clip = props["overflow"] == "hidden"
@@ -235,19 +278,299 @@ def _apply_common_visual(view: Any, props: Dict[str, Any]) -> None:
235
278
 
236
279
 
237
280
  # ======================================================================
238
- # Base class with shared frame/measure implementations
281
+ # Gesture wiring (MotionEvent -> GestureArbiter -> dispatch_event)
239
282
  # ======================================================================
240
283
 
241
284
 
242
- class AndroidViewHandler(ViewHandler):
243
- """Base class providing the shared `set_frame` / measure contract.
285
+ def _wire_gestures(view: Any, specs: Any) -> None:
286
+ """Feed ``MotionEvent`` streams on ``view`` into a `GestureArbiter`.
287
+
288
+ The arbiter emits ``(gesture_index, payload)`` pairs which are
289
+ forwarded as ``gesture:<i>`` events for this view's tag. Long
290
+ presses are polled with a main-looper ``Handler``. When a pan
291
+ activates, the parent is asked not to intercept so an enclosing
292
+ ScrollView can't steal the drag.
293
+ """
294
+ state = _state_of(view)
295
+ if not isinstance(specs, (list, tuple)) or not specs:
296
+ state["arbiter"] = None
297
+ return
298
+
299
+ def _emit(index: int, payload: Dict[str, Any]) -> None:
300
+ _fire(view, f"gesture:{index}", payload)
301
+
302
+ arbiter = make_arbiter([s for s in specs if isinstance(s, dict)], _emit)
303
+ state["arbiter"] = arbiter
304
+ if state.get("gestures_bound"):
305
+ return
306
+ state["gestures_bound"] = True
307
+ _bind_touch_stream(view, state)
308
+
309
+
310
+ def _schedule_arbiter_poll(view: Any, state: Dict[str, Any]) -> None:
311
+ """Schedule a main-looper poll at the arbiter's next deadline (long-press)."""
312
+ arbiter = state.get("arbiter")
313
+ if arbiter is None:
314
+ return
315
+ deadline = arbiter.next_deadline()
316
+ if deadline is None:
317
+ return
318
+ delay_ms = max(1, int((deadline - time.monotonic()) * 1000.0))
319
+ try:
320
+ Handler = jclass("android.os.Handler")
321
+ Looper = jclass("android.os.Looper")
322
+ Runnable = jclass("java.lang.Runnable")
323
+ handler = state.get("poll_handler")
324
+ if handler is None:
325
+ handler = Handler(Looper.getMainLooper())
326
+ state["poll_handler"] = handler
327
+
328
+ class _PollRunnable(dynamic_proxy(Runnable)):
329
+ def run(self) -> None:
330
+ live = state.get("arbiter")
331
+ if live is not None:
332
+ live.poll(time.monotonic())
333
+ _schedule_arbiter_poll(view, state)
334
+
335
+ handler.postDelayed(_PollRunnable(), delay_ms)
336
+ except Exception:
337
+ pass
338
+
339
+
340
+ def _bind_touch_stream(view: Any, state: Dict[str, Any]) -> None:
341
+ """Install one ``OnTouchListener`` that forwards every pointer to the arbiter."""
342
+ try:
343
+ OnTouchListener = jclass("android.view.View$OnTouchListener")
344
+
345
+ class _GestureTouchProxy(dynamic_proxy(OnTouchListener)):
346
+ def onTouch(self, v: Any, event: Any) -> bool:
347
+ return bool(_feed_motion_event(v, state, event))
348
+
349
+ view.setOnTouchListener(_GestureTouchProxy())
350
+ except Exception:
351
+ pass
352
+
353
+
354
+ def _feed_motion_event(view: Any, state: Dict[str, Any], event: Any) -> bool:
355
+ """Translate one ``MotionEvent`` into arbiter pointer calls.
356
+
357
+ Returns ``True`` to keep receiving the stream while any gesture
358
+ spec is wired.
359
+ """
360
+ arbiter = state.get("arbiter")
361
+ if arbiter is None:
362
+ return False
363
+ try:
364
+ action = int(event.getActionMasked())
365
+ t = time.monotonic()
366
+ density = _density() or 1.0
367
+ if action in (0, 5): # DOWN / POINTER_DOWN
368
+ idx = int(event.getActionIndex())
369
+ pid = int(event.getPointerId(idx))
370
+ arbiter.pointer_down(pid, float(event.getX(idx)) / density, float(event.getY(idx)) / density, t)
371
+ _schedule_arbiter_poll(view, state)
372
+ elif action == 2: # MOVE
373
+ for i in range(int(event.getPointerCount())):
374
+ pid = int(event.getPointerId(i))
375
+ arbiter.pointer_move(pid, float(event.getX(i)) / density, float(event.getY(i)) / density, t)
376
+ if arbiter.has_active_pan():
377
+ try:
378
+ parent = view.getParent()
379
+ if parent is not None:
380
+ parent.requestDisallowInterceptTouchEvent(True)
381
+ except Exception:
382
+ pass
383
+ elif action in (1, 6): # UP / POINTER_UP
384
+ idx = int(event.getActionIndex())
385
+ pid = int(event.getPointerId(idx))
386
+ arbiter.pointer_up(pid, float(event.getX(idx)) / density, float(event.getY(idx)) / density, t)
387
+ elif action == 3: # CANCEL
388
+ arbiter.cancel(t)
389
+ return True
390
+ except Exception:
391
+ return True
392
+
393
+
394
+ # ======================================================================
395
+ # Native-driven animations (ObjectAnimator)
396
+ # ======================================================================
397
+
398
+ _native_anims: Dict[int, Dict[str, Any]] = {}
399
+
400
+
401
+ def _interpolator_for(easing: str) -> Any:
402
+ mapping = {
403
+ "linear": "android.view.animation.LinearInterpolator",
404
+ "ease_in": "android.view.animation.AccelerateInterpolator",
405
+ "ease_out": "android.view.animation.DecelerateInterpolator",
406
+ "ease_in_out": "android.view.animation.AccelerateDecelerateInterpolator",
407
+ }
408
+ cls = mapping.get(easing, "android.view.animation.AccelerateDecelerateInterpolator")
409
+ return jclass(cls)()
410
+
244
411
 
245
- All Android handlers go through `set_frame` to apply the layout
246
- engine's computed frames as `MarginLayoutParams` mutations.
247
- Container handlers inherit the default `add_child` /
248
- `remove_child` implementations; leaves leave them as no-ops.
412
+ _ANIMATABLE_FLOAT_PROPS = {
413
+ "opacity": ("alpha", 1.0),
414
+ "translate_x": ("translationX", None), # dp -> px scaling
415
+ "translate_y": ("translationY", None),
416
+ "scale_x": ("scaleX", 1.0),
417
+ "scale_y": ("scaleY", 1.0),
418
+ "rotate": ("rotation", 1.0),
419
+ }
420
+
421
+
422
+ def _read_animated_value(view: Any, prop_name: str) -> Any:
423
+ """Read the current (presentation) value of an animatable property."""
424
+ try:
425
+ density = _density() or 1.0
426
+ if prop_name == "opacity":
427
+ return float(view.getAlpha())
428
+ if prop_name == "translate_x":
429
+ return float(view.getTranslationX()) / density
430
+ if prop_name == "translate_y":
431
+ return float(view.getTranslationY()) / density
432
+ if prop_name in ("scale", "scale_x"):
433
+ return float(view.getScaleX())
434
+ if prop_name == "scale_y":
435
+ return float(view.getScaleY())
436
+ if prop_name == "rotate":
437
+ return float(view.getRotation())
438
+ except Exception:
439
+ pass
440
+ return None
441
+
442
+
443
+ def _make_end_listener(anim_id: int) -> Any:
444
+ """Build an ``Animator.AnimatorListener`` that reports completion to Python."""
445
+ AnimatorListener = jclass("android.animation.Animator$AnimatorListener")
446
+
447
+ class _EndProxy(dynamic_proxy(AnimatorListener)):
448
+ def __init__(self) -> None:
449
+ super().__init__()
450
+ self._cancelled = False
451
+
452
+ def onAnimationStart(self, animation: Any) -> None:
453
+ pass
454
+
455
+ def onAnimationRepeat(self, animation: Any) -> None:
456
+ pass
457
+
458
+ def onAnimationCancel(self, animation: Any) -> None:
459
+ self._cancelled = True
460
+
461
+ def onAnimationEnd(self, animation: Any) -> None:
462
+ entry = _native_anims.pop(anim_id, None)
463
+ if entry is None:
464
+ return
465
+ try:
466
+ from ..animated import native_animation_completed
467
+
468
+ native_animation_completed(anim_id, not self._cancelled)
469
+ except Exception:
470
+ pass
471
+
472
+ return _EndProxy()
473
+
474
+
475
+ def _start_native_timing(view: Any, anim_id: int, prop_name: str, spec: Dict[str, Any]) -> bool:
476
+ """Drive a ``timing`` spec with ``ObjectAnimator``. Returns success."""
477
+ try:
478
+ ObjectAnimator = jclass("android.animation.ObjectAnimator")
479
+ from_val = float(spec.get("from", 0.0))
480
+ to_val = float(spec.get("to", 0.0))
481
+ duration = max(0, int(float(spec.get("duration_ms", 300.0))))
482
+ density = _density() or 1.0
483
+
484
+ if prop_name == "background_color":
485
+ animator = ObjectAnimator.ofArgb(
486
+ view,
487
+ "backgroundColor",
488
+ parse_color_int(int(from_val)),
489
+ parse_color_int(int(to_val)),
490
+ )
491
+ elif prop_name == "scale":
492
+ AnimatorSet = jclass("android.animation.AnimatorSet")
493
+ sx = ObjectAnimator.ofFloat(view, "scaleX", from_val, to_val)
494
+ sy = ObjectAnimator.ofFloat(view, "scaleY", from_val, to_val)
495
+ group = AnimatorSet()
496
+ group.playTogether([sx, sy])
497
+ group.setDuration(duration)
498
+ group.setInterpolator(_interpolator_for(str(spec.get("easing", "ease_in_out"))))
499
+ group.addListener(_make_end_listener(anim_id))
500
+ _native_anims[anim_id] = {"animator": group, "view": view, "prop": prop_name}
501
+ group.start()
502
+ return True
503
+ else:
504
+ java_prop, scale = _ANIMATABLE_FLOAT_PROPS.get(prop_name, (None, None))
505
+ if java_prop is None:
506
+ return False
507
+ factor = density if scale is None else scale
508
+ animator = ObjectAnimator.ofFloat(view, java_prop, from_val * factor, to_val * factor)
509
+
510
+ animator.setDuration(duration)
511
+ animator.setInterpolator(_interpolator_for(str(spec.get("easing", "ease_in_out"))))
512
+ animator.addListener(_make_end_listener(anim_id))
513
+ _native_anims[anim_id] = {"animator": animator, "view": view, "prop": prop_name}
514
+ animator.start()
515
+ return True
516
+ except Exception:
517
+ _native_anims.pop(anim_id, None)
518
+ return False
519
+
520
+
521
+ # ======================================================================
522
+ # Base class with shared frame/measure/animation implementations
523
+ # ======================================================================
524
+
525
+
526
+ class AndroidViewHandler(ViewHandler):
527
+ """Base class providing the shared protocol implementation.
528
+
529
+ Subclasses implement
530
+ [`_build`][pythonnative.native_views.android.AndroidViewHandler._build]
531
+ (construct the widget) and
532
+ [`_apply`][pythonnative.native_views.android.AndroidViewHandler._apply]
533
+ (apply visual props); the base class owns tag registration,
534
+ gesture wiring, frame application, intrinsic measurement, and the
535
+ animation hooks.
249
536
  """
250
537
 
538
+ def create(self, tag: int, props: Dict[str, Any]) -> Any:
539
+ view = self._build(props)
540
+ _remember(view, tag)
541
+ _state_of(view)["props"] = dict(props)
542
+ self._apply(view, props, initial=True)
543
+ if props.get("gestures"):
544
+ _wire_gestures(view, props.get("gestures"))
545
+ return view
546
+
547
+ def update(self, native_view: Any, changed_props: Dict[str, Any]) -> None:
548
+ state = _state_of(native_view)
549
+ merged = state.setdefault("props", {})
550
+ merged.update(changed_props)
551
+ self._apply(native_view, changed_props, initial=False)
552
+ if "gestures" in changed_props:
553
+ _wire_gestures(native_view, changed_props.get("gestures"))
554
+
555
+ def destroy(self, native_view: Any) -> None:
556
+ self._teardown(native_view)
557
+ try:
558
+ parent = native_view.getParent()
559
+ if parent is not None:
560
+ parent.removeView(native_view)
561
+ except Exception:
562
+ pass
563
+ _forget(native_view)
564
+
565
+ def _build(self, props: Dict[str, Any]) -> Any:
566
+ raise NotImplementedError
567
+
568
+ def _apply(self, view: Any, props: Dict[str, Any], initial: bool) -> None:
569
+ _apply_common_visual(view, props)
570
+
571
+ def _teardown(self, native_view: Any) -> None:
572
+ """Subclass hook for extra cleanup before the view is released."""
573
+
251
574
  def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
252
575
  if native_view is None:
253
576
  return
@@ -305,46 +628,11 @@ class AndroidViewHandler(ViewHandler):
305
628
  except Exception:
306
629
  return (0.0, 0.0)
307
630
 
308
- def set_animated_property(
309
- self,
310
- native_view: Any,
311
- prop_name: str,
312
- value: Any,
313
- duration_ms: float = 0.0,
314
- easing: str = "linear",
315
- ) -> None:
316
- """Apply ``prop_name`` to ``native_view`` immediately or animated.
317
-
318
- When ``duration_ms > 0``, the change is wrapped in a
319
- ``ViewPropertyAnimator`` so Choreographer drives the
320
- per-frame interpolation.
321
- """
631
+ def set_animated_property(self, native_view: Any, prop_name: str, value: Any) -> None:
632
+ """Apply one Python-driven animation frame immediately."""
322
633
  if native_view is None:
323
634
  return
324
635
  try:
325
- if duration_ms > 0:
326
- animator = native_view.animate()
327
- animator.setDuration(int(duration_ms))
328
- if prop_name == "opacity":
329
- animator.alpha(float(value))
330
- elif prop_name == "translate_x":
331
- animator.translationX(float(_dp(float(value))))
332
- elif prop_name == "translate_y":
333
- animator.translationY(float(_dp(float(value))))
334
- elif prop_name == "scale":
335
- animator.scaleX(float(value))
336
- animator.scaleY(float(value))
337
- elif prop_name == "scale_x":
338
- animator.scaleX(float(value))
339
- elif prop_name == "scale_y":
340
- animator.scaleY(float(value))
341
- elif prop_name == "rotate":
342
- animator.rotation(float(value))
343
- else:
344
- return
345
- animator.start()
346
- return
347
- # Immediate path.
348
636
  if prop_name == "opacity":
349
637
  native_view.setAlpha(float(value))
350
638
  elif prop_name == "translate_x":
@@ -365,6 +653,35 @@ class AndroidViewHandler(ViewHandler):
365
653
  except Exception:
366
654
  pass
367
655
 
656
+ def start_animation(
657
+ self,
658
+ native_view: Any,
659
+ anim_id: int,
660
+ prop_name: str,
661
+ spec: Dict[str, Any],
662
+ ) -> bool:
663
+ """Run ``timing`` specs on ``ObjectAnimator``; reject the rest.
664
+
665
+ Springs and decay need the exact physics integration the
666
+ Python ticker implements, so they return ``False`` and fall
667
+ back rather than approximating with an interpolator.
668
+ """
669
+ if native_view is None or not isinstance(spec, dict):
670
+ return False
671
+ if str(spec.get("kind", "")) != "timing":
672
+ return False
673
+ return _start_native_timing(native_view, anim_id, prop_name, spec)
674
+
675
+ def cancel_animation(self, native_view: Any, anim_id: int) -> Any:
676
+ entry = _native_anims.pop(anim_id, None)
677
+ if entry is None:
678
+ return None
679
+ try:
680
+ entry["animator"].cancel()
681
+ except Exception:
682
+ pass
683
+ return _read_animated_value(entry.get("view"), str(entry.get("prop", "")))
684
+
368
685
 
369
686
  # ======================================================================
370
687
  # Flex container handler (shared by Column, Row, View)
@@ -380,32 +697,43 @@ class FlexContainerHandler(AndroidViewHandler):
380
697
  The container itself is just a positioning surface.
381
698
  """
382
699
 
383
- def create(self, props: Dict[str, Any]) -> Any:
384
- fl = jclass("android.widget.FrameLayout")(_ctx())
385
- _apply_common_visual(fl, props)
386
- return fl
387
-
388
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
389
- _apply_common_visual(native_view, changed)
700
+ def _build(self, props: Dict[str, Any]) -> Any:
701
+ return jclass("android.widget.FrameLayout")(_ctx())
390
702
 
391
- def add_child(self, parent: Any, child: Any) -> None:
392
- FrameLP = jclass("android.widget.FrameLayout$LayoutParams")
393
- lp = child.getLayoutParams()
394
- if lp is None:
395
- lp = FrameLP(0, 0)
396
- child.setLayoutParams(lp)
397
- parent.addView(child)
703
+ def insert_child(self, parent: Any, child: Any, index: int) -> None:
704
+ _insert_view(parent, child, index)
398
705
 
399
706
  def remove_child(self, parent: Any, child: Any) -> None:
400
- parent.removeView(child)
707
+ try:
708
+ parent.removeView(child)
709
+ except Exception:
710
+ pass
401
711
 
402
- def insert_child(self, parent: Any, child: Any, index: int) -> None:
712
+
713
+ def _insert_view(parent: Any, child: Any, index: int) -> None:
714
+ """Move-aware indexed insert into any ``ViewGroup``."""
715
+ try:
716
+ current_parent = child.getParent()
717
+ count = int(parent.getChildCount())
718
+ if current_parent is not None and _java_id(current_parent) == _java_id(parent):
719
+ current_index = int(parent.indexOfChild(child))
720
+ target = max(0, min(index, count - 1))
721
+ if current_index == target:
722
+ return
723
+ parent.removeView(child)
724
+ parent.addView(child, max(0, min(target, int(parent.getChildCount()))))
725
+ return
726
+ if current_parent is not None:
727
+ current_parent.removeView(child)
403
728
  FrameLP = jclass("android.widget.FrameLayout$LayoutParams")
404
- lp = child.getLayoutParams()
405
- if lp is None:
406
- lp = FrameLP(0, 0)
407
- child.setLayoutParams(lp)
408
- parent.addView(child, index)
729
+ if child.getLayoutParams() is None:
730
+ child.setLayoutParams(FrameLP(0, 0))
731
+ parent.addView(child, max(0, min(index, int(parent.getChildCount()))))
732
+ except Exception:
733
+ try:
734
+ parent.addView(child)
735
+ except Exception:
736
+ pass
409
737
 
410
738
 
411
739
  # ======================================================================
@@ -431,15 +759,10 @@ def _typeface_for(weight: Any, italic: bool) -> Any:
431
759
 
432
760
 
433
761
  class TextHandler(AndroidViewHandler):
434
- def create(self, props: Dict[str, Any]) -> Any:
435
- tv = jclass("android.widget.TextView")(_ctx())
436
- self._apply(tv, props)
437
- return tv
762
+ def _build(self, props: Dict[str, Any]) -> Any:
763
+ return jclass("android.widget.TextView")(_ctx())
438
764
 
439
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
440
- self._apply(native_view, changed)
441
-
442
- def _apply(self, tv: Any, props: Dict[str, Any]) -> None:
765
+ def _apply(self, tv: Any, props: Dict[str, Any], initial: bool) -> None:
443
766
  if "text" in props:
444
767
  tv.setText(str(props["text"]) if props["text"] is not None else "")
445
768
  if "font_size" in props and props["font_size"] is not None:
@@ -449,9 +772,10 @@ class TextHandler(AndroidViewHandler):
449
772
  if any(k in props for k in ("font_family", "font_weight", "italic", "bold")):
450
773
  try:
451
774
  Typeface = jclass("android.graphics.Typeface")
452
- family = props.get("font_family")
453
- weight = props.get("font_weight") or ("bold" if props.get("bold") else None)
454
- italic = bool(props.get("italic"))
775
+ merged = _state_of(tv).get("props") or props
776
+ family = merged.get("font_family")
777
+ weight = merged.get("font_weight") or ("bold" if merged.get("bold") else None)
778
+ italic = bool(merged.get("italic"))
455
779
  style = _typeface_for(weight, italic)
456
780
  if family:
457
781
  base = Typeface.create(str(family), style)
@@ -470,13 +794,15 @@ class TextHandler(AndroidViewHandler):
470
794
  try:
471
795
  # Android expects letter_spacing as ems (a unitless ratio of font size).
472
796
  # Convert from points by dividing by ~font_size; if no font size, use 16.
473
- size = float(props.get("font_size") or 16.0)
797
+ merged = _state_of(tv).get("props") or props
798
+ size = float(merged.get("font_size") or 16.0)
474
799
  tv.setLetterSpacing(float(props["letter_spacing"]) / max(size, 1.0))
475
800
  except Exception:
476
801
  pass
477
802
  if "line_height" in props and props["line_height"] is not None:
478
803
  try:
479
- size = float(props.get("font_size") or 16.0)
804
+ merged = _state_of(tv).get("props") or props
805
+ size = float(merged.get("font_size") or 16.0)
480
806
  tv.setLineSpacing(0.0, float(props["line_height"]) / max(size, 1.0))
481
807
  except Exception:
482
808
  pass
@@ -495,15 +821,17 @@ class TextHandler(AndroidViewHandler):
495
821
 
496
822
 
497
823
  class ButtonHandler(AndroidViewHandler):
498
- def create(self, props: Dict[str, Any]) -> Any:
824
+ def _build(self, props: Dict[str, Any]) -> Any:
499
825
  btn = jclass("android.widget.Button")(_ctx())
500
- self._apply(btn, props)
501
- return btn
502
826
 
503
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
504
- self._apply(native_view, changed)
827
+ class ClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
828
+ def onClick(self, view: Any) -> None:
829
+ _fire(view, "on_click")
830
+
831
+ btn.setOnClickListener(ClickProxy())
832
+ return btn
505
833
 
506
- def _apply(self, btn: Any, props: Dict[str, Any]) -> None:
834
+ def _apply(self, btn: Any, props: Dict[str, Any], initial: bool) -> None:
507
835
  if "title" in props:
508
836
  btn.setText(str(props["title"]) if props["title"] is not None else "")
509
837
  if "font_size" in props and props["font_size"] is not None:
@@ -512,88 +840,72 @@ class ButtonHandler(AndroidViewHandler):
512
840
  btn.setTextColor(parse_color_int(props["color"]))
513
841
  if "enabled" in props:
514
842
  btn.setEnabled(bool(props["enabled"]))
515
- if "on_click" in props:
516
- cb = props["on_click"]
517
- if cb is not None:
518
-
519
- class ClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
520
- def __init__(self, callback: Callable[[], None]) -> None:
521
- super().__init__()
522
- self.callback = callback
523
-
524
- def onClick(self, view: Any) -> None:
525
- self.callback()
526
-
527
- btn.setOnClickListener(ClickProxy(cb))
528
- else:
529
- btn.setOnClickListener(None)
530
843
  _apply_common_visual(btn, props)
531
844
 
532
845
 
533
- _pn_scrollview_state: Dict[int, Dict[str, Any]] = {}
534
-
535
-
536
846
  class ScrollViewHandler(AndroidViewHandler):
537
847
  """Scroll container — wraps a single child whose height is unbounded.
538
848
 
539
- Uses ``androidx.core.widget.NestedScrollView`` rather than the
540
- framework ``android.widget.ScrollView`` because the framework
541
- ScrollView always intercepts vertical gestures, even when it has
542
- no overflow. That breaks the common case of nesting a small
543
- fixed-height scroll view inside a screen-level scroll view (the
544
- outer steals every gesture and the inner never scrolls).
545
- ``NestedScrollView`` implements the standard
546
- ``NestedScrollingParent2`` / ``NestedScrollingChild2`` protocol so
547
- the outer cooperates with any nested scroll, only consuming
548
- leftover scroll when its child reaches its limit.
849
+ Uses ``androidx.core.widget.NestedScrollView`` (vertical) or
850
+ ``android.widget.HorizontalScrollView`` so nested scroll views
851
+ cooperate instead of fighting over gestures.
549
852
 
550
853
  When a ``refresh_control`` prop is provided at creation, the scroll
551
- view is wrapped in an ``androidx.swiperefreshlayout.widget.SwipeRefreshLayout``
552
- (the returned view is the wrapper, and child management forwards
553
- into the inner scroll view) so pull-to-refresh matches the iOS
554
- ``UIRefreshControl`` path. Without ``refresh_control`` the bare
555
- scroll view is returned unchanged.
854
+ view is wrapped in a ``SwipeRefreshLayout`` (the returned view is
855
+ the wrapper, and child management forwards into the inner scroll
856
+ view) so pull-to-refresh matches the iOS ``UIRefreshControl`` path.
857
+
858
+ Scroll offsets are reported as ``on_scroll`` events with a
859
+ ``{"x": pts, "y": pts}`` payload. Imperative commands:
860
+ ``scroll_to_offset`` / ``scroll_to_end`` / ``get_scroll_offset``.
556
861
  """
557
862
 
558
- def create(self, props: Dict[str, Any]) -> Any:
559
- try:
560
- sv = jclass("androidx.core.widget.NestedScrollView")(_ctx())
561
- except Exception:
562
- sv = jclass("android.widget.ScrollView")(_ctx())
563
- _apply_common_visual(sv, props)
564
- self._apply_scroll_props(sv, props)
565
- if props.get("refresh_control"):
863
+ def create(self, tag: int, props: Dict[str, Any]) -> Any:
864
+ horizontal = props.get("scroll_axis") == "horizontal"
865
+ if horizontal:
866
+ sv = jclass("android.widget.HorizontalScrollView")(_ctx())
867
+ else:
868
+ try:
869
+ sv = jclass("androidx.core.widget.NestedScrollView")(_ctx())
870
+ except Exception:
871
+ sv = jclass("android.widget.ScrollView")(_ctx())
872
+
873
+ # Vertical scroll views are *always* wrapped in a (disabled)
874
+ # SwipeRefreshLayout. Wrapping later is impossible — the
875
+ # reconciler may reuse this view for a screen that adds a
876
+ # ``refresh_control`` prop afterwards (e.g. navigation swapping
877
+ # screens of the same shape), and re-parenting a mounted view
878
+ # mid-update is not safe. ``_apply_refresh`` simply toggles the
879
+ # wrapper's enabled state as the prop comes and goes.
880
+ outer = sv
881
+ if not horizontal:
566
882
  wrapper = self._wrap_in_refresh(sv)
567
883
  if wrapper is not None:
568
- _pn_scrollview_state[id(wrapper)] = {
569
- "scroll": sv,
570
- "refresh": wrapper,
571
- "on_refresh": None,
572
- "listener_bound": False,
573
- }
574
- self._apply_refresh(wrapper, props)
575
- return wrapper
576
- return sv
577
-
578
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
579
- state = _pn_scrollview_state.get(id(native_view))
580
- scroll = state["scroll"] if state else native_view
581
- _apply_common_visual(scroll, changed)
582
- self._apply_scroll_props(scroll, changed)
583
- if state is not None and "refresh_control" in changed:
584
- self._apply_refresh(native_view, changed)
585
-
586
- def add_child(self, parent: Any, child: Any) -> None:
587
- state = _pn_scrollview_state.get(id(parent))
588
- target = state["scroll"] if state else parent
589
- target.addView(child)
590
-
591
- def remove_child(self, parent: Any, child: Any) -> None:
592
- state = _pn_scrollview_state.get(id(parent))
593
- target = state["scroll"] if state else parent
594
- target.removeView(child)
595
-
596
- def _apply_scroll_props(self, sv: Any, props: Dict[str, Any]) -> None:
884
+ outer = wrapper
885
+
886
+ _remember(outer, tag)
887
+ state = _state_of(outer)
888
+ state["props"] = dict(props)
889
+ state["scroll"] = sv
890
+ state["horizontal"] = horizontal
891
+ if outer is not sv:
892
+ state["refresh"] = outer
893
+ self._bind_refresh_listener(outer)
894
+ if not props.get("refresh_control"):
895
+ try:
896
+ outer.setEnabled(False)
897
+ except Exception:
898
+ pass
899
+ self._bind_scroll_listener(outer, sv)
900
+ self._apply(outer, props, initial=True)
901
+ if props.get("gestures"):
902
+ _wire_gestures(outer, props.get("gestures"))
903
+ return outer
904
+
905
+ def _apply(self, outer: Any, props: Dict[str, Any], initial: bool) -> None:
906
+ state = _state_of(outer)
907
+ sv = state.get("scroll", outer)
908
+ _apply_common_visual(sv, props)
597
909
  if "shows_scroll_indicator" in props:
598
910
  # Only present when ``False`` (hide); a removal restores bars.
599
911
  show = props["shows_scroll_indicator"] is not False
@@ -611,26 +923,69 @@ class ScrollViewHandler(AndroidViewHandler):
611
923
  sv.setOverScrollMode(mode)
612
924
  except Exception:
613
925
  pass
614
- if "on_scroll" in props:
615
- self._apply_on_scroll(sv, props.get("on_scroll"))
926
+ if "refresh_control" in props and state.get("refresh") is not None:
927
+ self._apply_refresh(outer, props)
616
928
  # ``paging_enabled`` and ``keyboard_dismiss_mode`` have no clean
617
929
  # NestedScrollView analogue, so they are intentionally skipped
618
930
  # rather than approximated poorly.
619
931
 
620
- def _apply_on_scroll(self, sv: Any, cb: Optional[Callable[[float, float], None]]) -> None:
621
- if cb is None:
622
- return
932
+ def insert_child(self, parent: Any, child: Any, index: int) -> None:
933
+ sv = _state_of(parent).get("scroll", parent)
934
+ _insert_view(sv, child, index)
935
+
936
+ def remove_child(self, parent: Any, child: Any) -> None:
937
+ sv = _state_of(parent).get("scroll", parent)
938
+ try:
939
+ sv.removeView(child)
940
+ except Exception:
941
+ pass
942
+
943
+ def command(self, native_view: Any, name: str, args: Dict[str, Any]) -> Any:
944
+ state = _state_of(native_view)
945
+ sv = state.get("scroll", native_view)
946
+ density = _density() or 1.0
947
+ if name == "scroll_to_offset":
948
+ x_px = int(float(args.get("x", 0.0) or 0.0) * density)
949
+ y_px = int(float(args.get("y", 0.0) or 0.0) * density)
950
+ animated = args.get("animated", True) is not False
951
+ try:
952
+ if animated and hasattr(sv, "smoothScrollTo"):
953
+ sv.smoothScrollTo(x_px, y_px)
954
+ else:
955
+ sv.scrollTo(x_px, y_px)
956
+ except Exception:
957
+ pass
958
+ return None
959
+ if name == "scroll_to_end":
960
+ try:
961
+ child = sv.getChildAt(0) if int(sv.getChildCount()) > 0 else None
962
+ if child is None:
963
+ return None
964
+ if state.get("horizontal"):
965
+ target_x = max(0, int(child.getWidth()) - int(sv.getWidth()))
966
+ sv.smoothScrollTo(target_x, 0)
967
+ else:
968
+ target_y = max(0, int(child.getHeight()) - int(sv.getHeight()))
969
+ sv.smoothScrollTo(0, target_y)
970
+ except Exception:
971
+ pass
972
+ return None
973
+ if name == "get_scroll_offset":
974
+ try:
975
+ return {
976
+ "x": float(sv.getScrollX()) / density,
977
+ "y": float(sv.getScrollY()) / density,
978
+ }
979
+ except Exception:
980
+ return {"x": 0.0, "y": 0.0}
981
+ return None
982
+
983
+ def _bind_scroll_listener(self, outer: Any, sv: Any) -> None:
623
984
  try:
624
985
  if jclass("android.os.Build$VERSION").SDK_INT < 23:
625
986
  return
626
- density = _density()
627
987
 
628
988
  class _ScrollChangeProxy(dynamic_proxy(jclass("android.view.View").OnScrollChangeListener)):
629
- def __init__(self, callback: Callable[[float, float], None], dens: float) -> None:
630
- super().__init__()
631
- self.callback = callback
632
- self.dens = dens if dens else 1.0
633
-
634
989
  def onScrollChange(
635
990
  self,
636
991
  v: Any,
@@ -640,11 +995,12 @@ class ScrollViewHandler(AndroidViewHandler):
640
995
  old_y: int,
641
996
  ) -> None:
642
997
  try:
643
- self.callback(scroll_x / self.dens, scroll_y / self.dens)
998
+ density = _density() or 1.0
999
+ _fire(outer, "on_scroll", {"x": scroll_x / density, "y": scroll_y / density})
644
1000
  except Exception:
645
1001
  pass
646
1002
 
647
- sv.setOnScrollChangeListener(_ScrollChangeProxy(cb, density))
1003
+ sv.setOnScrollChangeListener(_ScrollChangeProxy())
648
1004
  except Exception:
649
1005
  pass
650
1006
 
@@ -658,11 +1014,22 @@ class ScrollViewHandler(AndroidViewHandler):
658
1014
  except Exception:
659
1015
  return None
660
1016
 
661
- def _apply_refresh(self, wrapper: Any, props: Dict[str, Any]) -> None:
662
- state = _pn_scrollview_state.get(id(wrapper))
663
- if state is None:
664
- return
665
- srl = state.get("refresh")
1017
+ def _bind_refresh_listener(self, outer: Any) -> None:
1018
+ try:
1019
+ srl = _state_of(outer).get("refresh")
1020
+
1021
+ class _RefreshProxy(
1022
+ dynamic_proxy(jclass("androidx.swiperefreshlayout.widget.SwipeRefreshLayout").OnRefreshListener)
1023
+ ):
1024
+ def onRefresh(self) -> None:
1025
+ _fire(outer, "on_refresh")
1026
+
1027
+ srl.setOnRefreshListener(_RefreshProxy())
1028
+ except Exception:
1029
+ pass
1030
+
1031
+ def _apply_refresh(self, outer: Any, props: Dict[str, Any]) -> None:
1032
+ srl = _state_of(outer).get("refresh")
666
1033
  if srl is None:
667
1034
  return
668
1035
  spec = props.get("refresh_control")
@@ -676,26 +1043,6 @@ class ScrollViewHandler(AndroidViewHandler):
676
1043
  srl.setEnabled(True)
677
1044
  except Exception:
678
1045
  pass
679
- state["on_refresh"] = spec.get("on_refresh")
680
- if not state.get("listener_bound"):
681
- owner = state
682
-
683
- class _RefreshProxy(
684
- dynamic_proxy(jclass("androidx.swiperefreshlayout.widget.SwipeRefreshLayout").OnRefreshListener)
685
- ):
686
- def onRefresh(self) -> None:
687
- callback = owner.get("on_refresh")
688
- if callback is not None:
689
- try:
690
- callback()
691
- except Exception:
692
- pass
693
-
694
- try:
695
- srl.setOnRefreshListener(_RefreshProxy())
696
- state["listener_bound"] = True
697
- except Exception:
698
- pass
699
1046
  if spec.get("tint_color"):
700
1047
  try:
701
1048
  srl.setColorSchemeColors([parse_color_int(spec["tint_color"])])
@@ -708,28 +1055,55 @@ class ScrollViewHandler(AndroidViewHandler):
708
1055
 
709
1056
 
710
1057
  class TextInputHandler(AndroidViewHandler):
711
- def create(self, props: Dict[str, Any]) -> Any:
1058
+ def _build(self, props: Dict[str, Any]) -> Any:
712
1059
  et = jclass("android.widget.EditText")(_ctx())
713
1060
  # Default to single-line so pressing Enter triggers IME_ACTION_DONE
714
- # (submit / dismiss) instead of inserting a newline. The
715
- # ``_apply`` path will override this if ``multiline=True`` is
716
- # set in props. Without this, every TextInput without an
717
- # explicit ``multiline`` value falls back to Android's
718
- # multi-line default and Enter inserts ``\n``.
1061
+ # (submit / dismiss) instead of inserting a newline. ``_apply``
1062
+ # overrides this when ``multiline=True``.
719
1063
  try:
720
1064
  if not props.get("multiline"):
721
1065
  et.setSingleLine(True)
722
1066
  except Exception:
723
1067
  pass
724
- self._apply(et, props)
1068
+ self._bind_listeners(et, props)
725
1069
  return et
726
1070
 
727
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
728
- self._apply(native_view, changed)
1071
+ def _bind_listeners(self, et: Any, props: Dict[str, Any]) -> None:
1072
+ # Text watcher: dispatches on_change unless a programmatic
1073
+ # setText is in flight. State lookups happen at dispatch time,
1074
+ # after the registry has assigned the tag.
1075
+ try:
1076
+ TextWatcher = jclass("android.text.TextWatcher")
1077
+
1078
+ class ChangeProxy(dynamic_proxy(TextWatcher)):
1079
+ def afterTextChanged(self, s: Any) -> None:
1080
+ st = _state_of(et)
1081
+ if st.get("suppress"):
1082
+ return
1083
+ _fire(et, "on_change", str(s))
1084
+
1085
+ def beforeTextChanged(self, s: Any, start: int, count: int, after: int) -> None:
1086
+ pass
1087
+
1088
+ def onTextChanged(self, s: Any, start: int, before: int, count: int) -> None:
1089
+ pass
1090
+
1091
+ et.addTextChangedListener(ChangeProxy())
1092
+ except Exception:
1093
+ pass
1094
+ try:
1095
+
1096
+ class _FocusProxy(dynamic_proxy(jclass("android.view.View").OnFocusChangeListener)):
1097
+ def onFocusChange(self, view: Any, has_focus: bool) -> None:
1098
+ _fire(view, "on_focus" if has_focus else "on_blur")
1099
+
1100
+ et.setOnFocusChangeListener(_FocusProxy())
1101
+ except Exception:
1102
+ pass
729
1103
 
730
- def _apply(self, et: Any, props: Dict[str, Any]) -> None:
1104
+ def _apply(self, et: Any, props: Dict[str, Any], initial: bool) -> None:
1105
+ state = _state_of(et)
731
1106
  if "value" in props:
732
- key = id(et)
733
1107
  incoming = str(props["value"]) if props["value"] is not None else ""
734
1108
  try:
735
1109
  before = str(et.getText())
@@ -743,7 +1117,7 @@ class TextInputHandler(AndroidViewHandler):
743
1117
  selection_end = et.getSelectionEnd()
744
1118
  except Exception:
745
1119
  pass
746
- _pn_text_input_suppress_callbacks[key] = True
1120
+ state["suppress"] = True
747
1121
  try:
748
1122
  et.setText(incoming)
749
1123
  try:
@@ -757,7 +1131,7 @@ class TextInputHandler(AndroidViewHandler):
757
1131
  except Exception:
758
1132
  pass
759
1133
  finally:
760
- _pn_text_input_suppress_callbacks[key] = False
1134
+ state["suppress"] = False
761
1135
  if "placeholder" in props:
762
1136
  et.setHint(str(props["placeholder"]) if props["placeholder"] is not None else "")
763
1137
  if "placeholder_color" in props and props["placeholder_color"] is not None:
@@ -770,13 +1144,14 @@ class TextInputHandler(AndroidViewHandler):
770
1144
  if "color" in props and props["color"] is not None:
771
1145
  et.setTextColor(parse_color_int(props["color"]))
772
1146
  if any(k in props for k in ("multiline", "secure", "keyboard_type", "auto_capitalize")):
1147
+ merged = state.get("props") or props
773
1148
  try:
774
1149
  InputType = jclass("android.text.InputType")
775
1150
  base = InputType.TYPE_CLASS_TEXT
776
- if props.get("secure"):
1151
+ if merged.get("secure"):
777
1152
  base = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD
778
1153
  else:
779
- kt = props.get("keyboard_type")
1154
+ kt = merged.get("keyboard_type")
780
1155
  if kt == "email_address":
781
1156
  base = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS
782
1157
  elif kt == "number_pad" or kt == "decimal_pad":
@@ -787,14 +1162,14 @@ class TextInputHandler(AndroidViewHandler):
787
1162
  base = InputType.TYPE_CLASS_PHONE
788
1163
  elif kt == "url":
789
1164
  base = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI
790
- auto_cap = props.get("auto_capitalize")
1165
+ auto_cap = merged.get("auto_capitalize")
791
1166
  if auto_cap == "sentences":
792
1167
  base |= InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
793
1168
  elif auto_cap == "words":
794
1169
  base |= InputType.TYPE_TEXT_FLAG_CAP_WORDS
795
1170
  elif auto_cap == "characters":
796
1171
  base |= InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS
797
- if props.get("multiline"):
1172
+ if merged.get("multiline"):
798
1173
  base |= InputType.TYPE_TEXT_FLAG_MULTI_LINE
799
1174
  et.setSingleLine(False)
800
1175
  else:
@@ -835,49 +1210,12 @@ class TextInputHandler(AndroidViewHandler):
835
1210
  self._apply_autofill(et, str(props["text_content_type"]))
836
1211
  if "clear_button" in props:
837
1212
  self._apply_clear_button(et, bool(props.get("clear_button")))
838
- if "on_focus" in props or "on_blur" in props:
839
- self._apply_focus_listener(et, props)
840
- if "on_change" in props:
841
- key = id(et)
842
- cb = props["on_change"]
843
- if cb is not None:
844
- _pn_text_input_callbacks[key] = cb
845
- if key not in _pn_text_input_watchers:
846
- TextWatcher = jclass("android.text.TextWatcher")
847
-
848
- class ChangeProxy(dynamic_proxy(TextWatcher)):
849
- def __init__(self, view_key: int) -> None:
850
- super().__init__()
851
- self.view_key = view_key
852
-
853
- def afterTextChanged(self, s: Any) -> None:
854
- text = str(s)
855
- if _pn_text_input_suppress_callbacks.get(self.view_key):
856
- return
857
- callback = _pn_text_input_callbacks.get(self.view_key)
858
- if callback is None:
859
- return
860
- callback(text)
861
-
862
- def beforeTextChanged(self, s: Any, start: int, count: int, after: int) -> None:
863
- pass
864
-
865
- def onTextChanged(self, s: Any, start: int, before: int, count: int) -> None:
866
- pass
867
-
868
- watcher = ChangeProxy(key)
869
- _pn_text_input_watchers[key] = watcher
870
- et.addTextChangedListener(watcher)
871
- else:
872
- _pn_text_input_callbacks[key] = None
873
1213
  if "return_key_type" in props and props["return_key_type"] is not None:
874
1214
  # Map the cross-platform ``return_key_type`` to Android's
875
1215
  # ``EditorInfo.IME_ACTION_*`` so the soft keyboard renders the
876
- # right action key (Done / Go / Search / Send / Next), which
877
- # is what triggers the ``OnEditorActionListener`` below. iOS
878
- # has a richer set (Google / Yahoo / Join / Route) with no
879
- # direct AOSP equivalents — fall back to ``IME_ACTION_DONE``
880
- # for those so the keyboard at least dismisses cleanly.
1216
+ # right action key. iOS has a richer set (Google / Yahoo /
1217
+ # Join / Route) with no direct AOSP equivalents — fall back
1218
+ # to ``IME_ACTION_DONE`` for those.
881
1219
  try:
882
1220
  EditorInfo = jclass("android.view.inputmethod.EditorInfo")
883
1221
  rkt_mapping = {
@@ -896,31 +1234,36 @@ class TextInputHandler(AndroidViewHandler):
896
1234
  et.setImeOptions(action)
897
1235
  except Exception:
898
1236
  pass
899
- if not props.get("multiline"):
900
- # Always install an editor-action listener on single-line
901
- # inputs so pressing the IME action key (Done / Go / etc.)
902
- # *or* the Enter key on a single-line ``EditText`` dismisses
903
- # the soft keyboard. Without this the keyboard stays up after
904
- # ``inputText`` + ``pressKey: Enter`` in Maestro and on smaller
905
- # screens hides the rest of the layout — and matches React
906
- # Native's default Android behavior. ``on_submit`` (if any) is
907
- # fired before dismissal so the callback sees the final text.
908
- try:
909
- on_submit_cb = props.get("on_submit")
910
- EditorListener = jclass("android.widget.TextView$OnEditorActionListener")
911
- Context = jclass("android.content.Context")
1237
+ if initial or "multiline" in props:
1238
+ self._apply_editor_action(et)
1239
+ _apply_common_visual(et, props)
912
1240
 
913
- class SubmitProxy(dynamic_proxy(EditorListener)):
914
- def __init__(self, callback: Optional[Callable[[str], None]]) -> None:
915
- super().__init__()
916
- self.callback = callback
1241
+ def _apply_editor_action(self, et: Any) -> None:
1242
+ """Install the IME action listener for submit + keyboard dismissal.
917
1243
 
918
- def onEditorAction(self, view: Any, action_id: int, event: Any) -> bool:
919
- if self.callback is not None:
920
- try:
921
- self.callback(str(view.getText()))
922
- except Exception:
923
- pass
1244
+ Single-line inputs always dismiss the keyboard on the action
1245
+ key (matching React Native's Android default) and fire
1246
+ ``on_submit`` first. Multi-line inputs only consume the action
1247
+ when an ``on_submit`` handler exists — otherwise Enter inserts
1248
+ a newline.
1249
+ """
1250
+ try:
1251
+ EditorListener = jclass("android.widget.TextView$OnEditorActionListener")
1252
+ Context = jclass("android.content.Context")
1253
+
1254
+ class SubmitProxy(dynamic_proxy(EditorListener)):
1255
+ def onEditorAction(self, view: Any, action_id: int, event: Any) -> bool:
1256
+ merged = _state_of(view).get("props") or {}
1257
+ multiline = bool(merged.get("multiline"))
1258
+ has_submit = _has_event(view, "on_submit")
1259
+ if multiline and not has_submit:
1260
+ return False
1261
+ if has_submit:
1262
+ try:
1263
+ _fire(view, "on_submit", str(view.getText()))
1264
+ except Exception:
1265
+ pass
1266
+ if not multiline:
924
1267
  try:
925
1268
  view.clearFocus()
926
1269
  ctx = view.getContext()
@@ -928,35 +1271,11 @@ class TextInputHandler(AndroidViewHandler):
928
1271
  imm.hideSoftInputFromWindow(view.getWindowToken(), 0)
929
1272
  except Exception:
930
1273
  pass
931
- return True
1274
+ return True
932
1275
 
933
- et.setOnEditorActionListener(SubmitProxy(on_submit_cb))
934
- except Exception:
935
- pass
936
- elif "on_submit" in props and props["on_submit"] is not None:
937
- # Multi-line inputs: only install the listener when an explicit
938
- # ``on_submit`` is provided. Enter inserts a newline by default
939
- # on multi-line ``EditText`` and we don't want to override that.
940
- try:
941
- cb = props["on_submit"]
942
- EditorListener = jclass("android.widget.TextView$OnEditorActionListener")
943
-
944
- class SubmitProxy(dynamic_proxy(EditorListener)):
945
- def __init__(self, callback: Callable[[str], None]) -> None:
946
- super().__init__()
947
- self.callback = callback
948
-
949
- def onEditorAction(self, view: Any, action_id: int, event: Any) -> bool:
950
- try:
951
- self.callback(str(view.getText()))
952
- except Exception:
953
- pass
954
- return True
955
-
956
- et.setOnEditorActionListener(SubmitProxy(cb))
957
- except Exception:
958
- pass
959
- _apply_common_visual(et, props)
1276
+ et.setOnEditorActionListener(SubmitProxy())
1277
+ except Exception:
1278
+ pass
960
1279
 
961
1280
  @staticmethod
962
1281
  def _autofill_hint(content_type: str) -> Optional[str]:
@@ -994,15 +1313,16 @@ class TextInputHandler(AndroidViewHandler):
994
1313
  # Best-effort drawableEnd "X": shows a system clear icon and wires
995
1314
  # a touch listener that clears the field when the icon is tapped.
996
1315
  try:
1316
+ state = _state_of(et)
997
1317
  if not enabled:
998
1318
  et.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0)
999
1319
  return
1000
1320
  icon_id = int(getattr(jclass("android.R$drawable"), "ic_menu_close_clear_cancel", 0))
1001
1321
  if icon_id:
1002
1322
  et.setCompoundDrawablesWithIntrinsicBounds(0, 0, icon_id, 0)
1003
- key = id(et)
1004
- if key in _pn_text_input_clear_touch:
1323
+ if state.get("clear_bound"):
1005
1324
  return
1325
+ state["clear_bound"] = True
1006
1326
 
1007
1327
  class _ClearTouchProxy(dynamic_proxy(jclass("android.view.View").OnTouchListener)):
1008
1328
  def onTouch(self, view: Any, event: Any) -> bool:
@@ -1019,51 +1339,16 @@ class TextInputHandler(AndroidViewHandler):
1019
1339
  pass
1020
1340
  return False
1021
1341
 
1022
- listener = _ClearTouchProxy()
1023
- _pn_text_input_clear_touch[key] = listener
1024
- et.setOnTouchListener(listener)
1342
+ et.setOnTouchListener(_ClearTouchProxy())
1025
1343
  except Exception:
1026
1344
  pass
1027
1345
 
1028
- def _apply_focus_listener(self, et: Any, props: Dict[str, Any]) -> None:
1029
- key = id(et)
1030
- entry = _pn_text_input_focus_callbacks.setdefault(key, {"on_focus": None, "on_blur": None})
1031
- if "on_focus" in props:
1032
- entry["on_focus"] = props.get("on_focus")
1033
- if "on_blur" in props:
1034
- entry["on_blur"] = props.get("on_blur")
1035
- if key in _pn_text_input_focus_listeners:
1036
- return
1037
-
1038
- class _FocusProxy(dynamic_proxy(jclass("android.view.View").OnFocusChangeListener)):
1039
- def __init__(self, view_key: int) -> None:
1040
- super().__init__()
1041
- self.view_key = view_key
1042
-
1043
- def onFocusChange(self, view: Any, has_focus: bool) -> None:
1044
- callbacks = _pn_text_input_focus_callbacks.get(self.view_key) or {}
1045
- cb = callbacks.get("on_focus") if has_focus else callbacks.get("on_blur")
1046
- if cb is not None:
1047
- try:
1048
- cb()
1049
- except Exception:
1050
- pass
1051
-
1052
- listener = _FocusProxy(key)
1053
- _pn_text_input_focus_listeners[key] = listener
1054
- et.setOnFocusChangeListener(listener)
1055
-
1056
1346
 
1057
1347
  class ImageHandler(AndroidViewHandler):
1058
- def create(self, props: Dict[str, Any]) -> Any:
1059
- iv = jclass("android.widget.ImageView")(_ctx())
1060
- self._apply(iv, props)
1061
- return iv
1348
+ def _build(self, props: Dict[str, Any]) -> Any:
1349
+ return jclass("android.widget.ImageView")(_ctx())
1062
1350
 
1063
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1064
- self._apply(native_view, changed)
1065
-
1066
- def _apply(self, iv: Any, props: Dict[str, Any]) -> None:
1351
+ def _apply(self, iv: Any, props: Dict[str, Any], initial: bool) -> None:
1067
1352
  if "tint_color" in props and props["tint_color"] is not None:
1068
1353
  try:
1069
1354
  ColorStateList = jclass("android.content.res.ColorStateList")
@@ -1137,44 +1422,37 @@ class ImageHandler(AndroidViewHandler):
1137
1422
 
1138
1423
 
1139
1424
  class SwitchHandler(AndroidViewHandler):
1140
- def create(self, props: Dict[str, Any]) -> Any:
1425
+ def _build(self, props: Dict[str, Any]) -> Any:
1141
1426
  sw = jclass("android.widget.Switch")(_ctx())
1142
- self._apply(sw, props)
1143
- return sw
1144
-
1145
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1146
- self._apply(native_view, changed)
1147
1427
 
1148
- def _apply(self, sw: Any, props: Dict[str, Any]) -> None:
1149
- if "value" in props:
1150
- sw.setChecked(bool(props["value"]))
1151
- if "on_change" in props and props["on_change"] is not None:
1152
- cb = props["on_change"]
1153
-
1154
- class CheckedProxy(dynamic_proxy(jclass("android.widget.CompoundButton").OnCheckedChangeListener)):
1155
- def __init__(self, callback: Callable[[bool], None]) -> None:
1156
- super().__init__()
1157
- self.callback = callback
1428
+ class CheckedProxy(dynamic_proxy(jclass("android.widget.CompoundButton").OnCheckedChangeListener)):
1429
+ def onCheckedChanged(self, button: Any, checked: bool) -> None:
1430
+ if _state_of(button).get("suppress"):
1431
+ return
1432
+ _fire(button, "on_change", bool(checked))
1158
1433
 
1159
- def onCheckedChanged(self, button: Any, checked: bool) -> None:
1160
- self.callback(checked)
1434
+ sw.setOnCheckedChangeListener(CheckedProxy())
1435
+ return sw
1161
1436
 
1162
- sw.setOnCheckedChangeListener(CheckedProxy(cb))
1437
+ def _apply(self, sw: Any, props: Dict[str, Any], initial: bool) -> None:
1438
+ if "value" in props:
1439
+ state = _state_of(sw)
1440
+ state["suppress"] = True
1441
+ try:
1442
+ sw.setChecked(bool(props["value"]))
1443
+ finally:
1444
+ state["suppress"] = False
1163
1445
  _apply_accessibility(sw, props)
1164
1446
 
1165
1447
 
1166
1448
  class ProgressBarHandler(AndroidViewHandler):
1167
- def create(self, props: Dict[str, Any]) -> Any:
1449
+ def _build(self, props: Dict[str, Any]) -> Any:
1168
1450
  style = jclass("android.R$attr").progressBarStyleHorizontal
1169
1451
  pb = jclass("android.widget.ProgressBar")(_ctx(), None, 0, style)
1170
1452
  pb.setMax(1000)
1171
- self._apply(pb, props)
1172
1453
  return pb
1173
1454
 
1174
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1175
- self._apply(native_view, changed)
1176
-
1177
- def _apply(self, pb: Any, props: Dict[str, Any]) -> None:
1455
+ def _apply(self, pb: Any, props: Dict[str, Any], initial: bool) -> None:
1178
1456
  if "value" in props and props["value"] is not None:
1179
1457
  pb.setProgress(int(float(props["value"]) * 1000))
1180
1458
  if "color" in props and props["color"] is not None:
@@ -1199,15 +1477,10 @@ class ProgressBarHandler(AndroidViewHandler):
1199
1477
 
1200
1478
 
1201
1479
  class ActivityIndicatorHandler(AndroidViewHandler):
1202
- def create(self, props: Dict[str, Any]) -> Any:
1203
- pb = jclass("android.widget.ProgressBar")(_ctx())
1204
- self._apply(pb, props)
1205
- return pb
1206
-
1207
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1208
- self._apply(native_view, changed)
1480
+ def _build(self, props: Dict[str, Any]) -> Any:
1481
+ return jclass("android.widget.ProgressBar")(_ctx())
1209
1482
 
1210
- def _apply(self, pb: Any, props: Dict[str, Any]) -> None:
1483
+ def _apply(self, pb: Any, props: Dict[str, Any], initial: bool) -> None:
1211
1484
  if "animating" in props:
1212
1485
  View = jclass("android.view.View")
1213
1486
  pb.setVisibility(View.VISIBLE if props["animating"] else View.GONE)
@@ -1228,11 +1501,8 @@ class ActivityIndicatorHandler(AndroidViewHandler):
1228
1501
  pass
1229
1502
 
1230
1503
 
1231
- _pn_webview_props: Dict[int, Dict[str, Any]] = {}
1232
-
1233
-
1234
- def _make_web_client(store: Dict[str, Any]) -> Any:
1235
- """Best-effort ``WebViewClient`` proxy driving the WebView callbacks.
1504
+ def _make_web_client(wv: Any) -> Any:
1505
+ """Best-effort ``WebViewClient`` proxy driving the WebView events.
1236
1506
 
1237
1507
  ``android.webkit.WebViewClient`` is an abstract *class*, not an
1238
1508
  interface, so Chaquopy's ``dynamic_proxy`` may be unable to subclass
@@ -1240,35 +1510,22 @@ def _make_web_client(store: Dict[str, Any]) -> Any:
1240
1510
  which case the caller falls back to the default client and page
1241
1511
  loading still works.
1242
1512
 
1243
- When the proxy succeeds it drives ``on_navigation_state_change``
1513
+ When the proxy succeeds it fires ``on_navigation_state_change``
1244
1514
  (``onPageStarted``), ``on_load`` (``onPageFinished``), evaluates
1245
1515
  ``inject_javascript`` after each load, and bridges ``on_message``
1246
1516
  via a ``pythonnative://`` URL scheme plus a small JS shim installed
1247
- as ``window.pythonnative.postMessage`` — so no ``@JavascriptInterface``
1248
- Java helper is required.
1517
+ as ``window.pythonnative.postMessage``.
1249
1518
  """
1250
- on_load = store.get("on_load")
1251
- on_nav = store.get("on_navigation_state_change")
1252
- inject_js = store.get("inject_javascript")
1253
- on_message = store.get("on_message")
1254
1519
  scheme = "pythonnative://message/"
1255
1520
  try:
1256
1521
 
1257
1522
  class _WebClientProxy(dynamic_proxy(jclass("android.webkit.WebViewClient"))):
1258
1523
  def onPageStarted(self, view: Any, url: Any, favicon: Any) -> None:
1259
- if on_nav is not None:
1260
- try:
1261
- on_nav(str(url))
1262
- except Exception:
1263
- pass
1524
+ _fire(wv, "on_navigation_state_change", str(url))
1264
1525
 
1265
1526
  def onPageFinished(self, view: Any, url: Any) -> None:
1266
- if on_load is not None:
1267
- try:
1268
- on_load(str(url))
1269
- except Exception:
1270
- pass
1271
- if on_message is not None:
1527
+ _fire(wv, "on_load", str(url))
1528
+ if _has_event(wv, "on_message"):
1272
1529
  try:
1273
1530
  shim = (
1274
1531
  "(function(){window.pythonnative=window.pythonnative||{};"
@@ -1278,6 +1535,7 @@ def _make_web_client(store: Dict[str, Any]) -> Any:
1278
1535
  view.evaluateJavascript(shim, None)
1279
1536
  except Exception:
1280
1537
  pass
1538
+ inject_js = (_state_of(wv).get("props") or {}).get("inject_javascript")
1281
1539
  if inject_js:
1282
1540
  try:
1283
1541
  view.evaluateJavascript(str(inject_js), None)
@@ -1289,11 +1547,11 @@ def _make_web_client(store: Dict[str, Any]) -> Any:
1289
1547
  url = request if isinstance(request, str) else str(request.getUrl())
1290
1548
  except Exception:
1291
1549
  url = ""
1292
- if on_message is not None and url.startswith(scheme):
1550
+ if url.startswith(scheme):
1293
1551
  try:
1294
1552
  from urllib.parse import unquote
1295
1553
 
1296
- on_message(unquote(url[len(scheme) :]))
1554
+ _fire(wv, "on_message", unquote(url[len(scheme) :]))
1297
1555
  except Exception:
1298
1556
  pass
1299
1557
  return True
@@ -1305,32 +1563,20 @@ def _make_web_client(store: Dict[str, Any]) -> Any:
1305
1563
 
1306
1564
 
1307
1565
  class WebViewHandler(AndroidViewHandler):
1308
- _CLIENT_KEYS = ("on_load", "on_navigation_state_change", "inject_javascript", "on_message")
1309
-
1310
- def create(self, props: Dict[str, Any]) -> Any:
1311
- wv = jclass("android.webkit.WebView")(_ctx())
1312
- _pn_webview_props[id(wv)] = {}
1313
- self._apply(wv, props, initial=True)
1314
- return wv
1315
-
1316
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1317
- self._apply(native_view, changed, initial=False)
1566
+ def _build(self, props: Dict[str, Any]) -> Any:
1567
+ return jclass("android.webkit.WebView")(_ctx())
1318
1568
 
1319
1569
  def _apply(self, wv: Any, props: Dict[str, Any], initial: bool) -> None:
1320
- store = _pn_webview_props.setdefault(id(wv), {})
1321
- for key in self._CLIENT_KEYS:
1322
- if key in props:
1323
- store[key] = props[key]
1324
-
1325
- # Enable JS whenever a callback / injection needs it.
1326
- if any(store.get(k) for k in self._CLIENT_KEYS):
1570
+ merged = _state_of(wv).get("props") or props
1571
+ needs_js = bool(merged.get("inject_javascript")) or bool(event_names(merged))
1572
+ if needs_js:
1327
1573
  try:
1328
1574
  wv.getSettings().setJavaScriptEnabled(True)
1329
1575
  except Exception:
1330
1576
  pass
1331
1577
 
1332
- if initial or any(k in props for k in self._CLIENT_KEYS):
1333
- client = _make_web_client(store)
1578
+ if initial:
1579
+ client = _make_web_client(wv)
1334
1580
  if client is not None:
1335
1581
  try:
1336
1582
  wv.setWebViewClient(client)
@@ -1370,128 +1616,99 @@ class WebViewHandler(AndroidViewHandler):
1370
1616
  class SpacerHandler(AndroidViewHandler):
1371
1617
  """Empty layout placeholder used as a flexible gap.
1372
1618
 
1373
- All sizing semantics now live in the layout engine — ``Spacer``
1619
+ All sizing semantics live in the layout engine — ``Spacer``
1374
1620
  behaves identically to a `View` with the same style props (e.g.,
1375
1621
  ``flex: 1`` for an expanding spacer, ``size`` for a fixed gap).
1376
1622
  """
1377
1623
 
1378
- def create(self, props: Dict[str, Any]) -> Any:
1624
+ def _build(self, props: Dict[str, Any]) -> Any:
1379
1625
  return jclass("android.view.View")(_ctx())
1380
1626
 
1381
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1627
+ def _apply(self, view: Any, props: Dict[str, Any], initial: bool) -> None:
1382
1628
  pass
1383
1629
 
1384
1630
 
1385
- class SafeAreaViewHandler(AndroidViewHandler):
1631
+ class SafeAreaViewHandler(FlexContainerHandler):
1386
1632
  """Safe-area container using FrameLayout with ``fitsSystemWindows``."""
1387
1633
 
1388
- def create(self, props: Dict[str, Any]) -> Any:
1634
+ def _build(self, props: Dict[str, Any]) -> Any:
1389
1635
  fl = jclass("android.widget.FrameLayout")(_ctx())
1390
1636
  fl.setFitsSystemWindows(True)
1391
- _apply_common_visual(fl, props)
1392
1637
  return fl
1393
1638
 
1394
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1395
- _apply_common_visual(native_view, changed)
1396
-
1397
- def add_child(self, parent: Any, child: Any) -> None:
1398
- parent.addView(child)
1399
-
1400
- def remove_child(self, parent: Any, child: Any) -> None:
1401
- parent.removeView(child)
1402
-
1403
1639
 
1404
1640
  # ======================================================================
1405
1641
  # Modal — actually presents a Dialog with the children inside
1406
1642
  # ======================================================================
1407
1643
 
1408
1644
 
1409
- _pn_modal_states: Dict[int, dict] = {}
1410
- _pn_modal_pending: Dict[int, list] = {}
1411
-
1412
-
1413
1645
  class ModalHandler(AndroidViewHandler):
1414
1646
  """Real modal presentation backed by an Android `Dialog`.
1415
1647
 
1416
1648
  The on-tree placeholder is a hidden ``View`` (so the layout
1417
1649
  engine can ignore it). When ``visible`` flips to ``True``, a
1418
1650
  ``Dialog`` is created with a ``FrameLayout`` as its content view;
1419
- the reconciler's ``add_child`` calls are forwarded into that
1651
+ the reconciler's ``insert_child`` calls are forwarded into that
1420
1652
  content view.
1421
1653
  """
1422
1654
 
1423
- def create(self, props: Dict[str, Any]) -> Any:
1655
+ def _build(self, props: Dict[str, Any]) -> Any:
1424
1656
  placeholder = jclass("android.view.View")(_ctx())
1425
1657
  placeholder.setVisibility(jclass("android.view.View").GONE)
1426
- self._apply(placeholder, props, mounting=True)
1427
1658
  return placeholder
1428
1659
 
1429
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1430
- self._apply(native_view, changed, mounting=False)
1431
-
1432
- def add_child(self, parent: Any, child: Any) -> None:
1433
- state = _pn_modal_states.get(id(parent))
1434
- if state and state.get("content_view") is not None:
1660
+ def _apply(self, placeholder: Any, props: Dict[str, Any], initial: bool) -> None:
1661
+ state = _state_of(placeholder)
1662
+ # ``update`` only delivers the *changed* props. When ``visible`` is
1663
+ # not among them the presentation state must be left untouched: a
1664
+ # re-render that happens while the modal is open (e.g. an
1665
+ # ``on_show`` callback bumping some state) must NOT be read as
1666
+ # ``visible=False`` and tear the dialog down.
1667
+ if "visible" in props:
1668
+ visible = bool(props["visible"])
1669
+ if visible and state.get("dialog") is None:
1670
+ self._present(placeholder, state)
1671
+ elif not visible and state.get("dialog") is not None:
1672
+ self._dismiss(placeholder, state)
1673
+ dialog = state.get("dialog")
1674
+ if dialog is not None and "dismiss_on_backdrop" in props:
1435
1675
  try:
1436
- state["content_view"].addView(child)
1676
+ dialog.setCanceledOnTouchOutside(props["dismiss_on_backdrop"] is not False)
1437
1677
  except Exception:
1438
1678
  pass
1679
+
1680
+ def insert_child(self, parent: Any, child: Any, index: int) -> None:
1681
+ state = _state_of(parent)
1682
+ content = state.get("content_view")
1683
+ if content is not None:
1684
+ _insert_view(content, child, index)
1439
1685
  else:
1440
- _pn_modal_pending.setdefault(id(parent), []).append(child)
1686
+ state.setdefault("pending", []).insert(index, child)
1441
1687
 
1442
1688
  def remove_child(self, parent: Any, child: Any) -> None:
1443
- state = _pn_modal_states.get(id(parent))
1444
- if state and state.get("content_view") is not None:
1689
+ state = _state_of(parent)
1690
+ content = state.get("content_view")
1691
+ if content is not None:
1445
1692
  try:
1446
- state["content_view"].removeView(child)
1693
+ content.removeView(child)
1447
1694
  except Exception:
1448
1695
  pass
1449
1696
  else:
1450
- buf = _pn_modal_pending.get(id(parent))
1697
+ buf = state.get("pending")
1451
1698
  if buf and child in buf:
1452
1699
  buf.remove(child)
1453
1700
 
1454
- def insert_child(self, parent: Any, child: Any, index: int) -> None:
1455
- state = _pn_modal_states.get(id(parent))
1456
- if state and state.get("content_view") is not None:
1457
- try:
1458
- state["content_view"].addView(child, index)
1459
- except Exception:
1460
- pass
1461
- else:
1462
- _pn_modal_pending.setdefault(id(parent), []).insert(index, child)
1463
-
1464
1701
  def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
1465
1702
  return
1466
1703
 
1467
- def _apply(self, placeholder: Any, props: Dict[str, Any], *, mounting: bool) -> None:
1468
- state = _pn_modal_states.get(id(placeholder))
1469
- # ``update`` only delivers the *changed* props. When ``visible`` is
1470
- # not among them the presentation state must be left untouched: a
1471
- # re-render that happens while the modal is open (e.g. an
1472
- # ``on_show`` callback bumping some state) must NOT be read as
1473
- # ``visible=False`` and tear the dialog down. So only react to an
1474
- # explicitly supplied ``visible`` value.
1475
- if "visible" in props:
1476
- visible = bool(props["visible"])
1477
- if visible and state is None:
1478
- self._present(placeholder, props)
1479
- elif not visible and state is not None:
1480
- self._dismiss(placeholder)
1481
- # Forward live prop updates to an already-presented dialog.
1482
- state = _pn_modal_states.get(id(placeholder))
1483
- if state is not None:
1484
- if "on_dismiss" in props:
1485
- state["on_dismiss"] = props.get("on_dismiss")
1486
- dialog = state.get("dialog")
1487
- if dialog is not None and "dismiss_on_backdrop" in props:
1488
- try:
1489
- dialog.setCanceledOnTouchOutside(props["dismiss_on_backdrop"] is not False)
1490
- except Exception:
1491
- pass
1704
+ def _teardown(self, placeholder: Any) -> None:
1705
+ state = _state_of(placeholder)
1706
+ if state.get("dialog") is not None:
1707
+ self._dismiss(placeholder, state)
1492
1708
 
1493
- def _present(self, placeholder: Any, props: Dict[str, Any]) -> None:
1709
+ def _present(self, placeholder: Any, state: Dict[str, Any]) -> None:
1494
1710
  try:
1711
+ props = state.get("props") or {}
1495
1712
  Dialog = jclass("android.app.Dialog")
1496
1713
  FrameLayout = jclass("android.widget.FrameLayout")
1497
1714
  LayoutParams = jclass("android.view.ViewGroup$LayoutParams")
@@ -1525,57 +1742,36 @@ class ModalHandler(AndroidViewHandler):
1525
1742
  dialog.setCanceledOnTouchOutside(props.get("dismiss_on_backdrop") is not False)
1526
1743
  except Exception:
1527
1744
  pass
1528
- on_dismiss = props.get("on_dismiss")
1529
- _pn_modal_states[id(placeholder)] = {
1530
- "dialog": dialog,
1531
- "content_view": content,
1532
- "on_dismiss": on_dismiss,
1533
- }
1534
- for child in _pn_modal_pending.pop(id(placeholder), []):
1745
+ state["dialog"] = dialog
1746
+ state["content_view"] = content
1747
+ for child in state.pop("pending", []):
1535
1748
  try:
1536
1749
  content.addView(child)
1537
1750
  except Exception:
1538
1751
  pass
1539
- on_show = props.get("on_show")
1540
- if on_show is not None:
1541
- OnShowListener = jclass("android.content.DialogInterface$OnShowListener")
1542
1752
 
1543
- class _ShowProxy(dynamic_proxy(OnShowListener)):
1544
- def __init__(self, callback: Callable[[], None]) -> None:
1545
- super().__init__()
1546
- self.callback = callback
1753
+ OnShowListener = jclass("android.content.DialogInterface$OnShowListener")
1547
1754
 
1548
- def onShow(self, di: Any) -> None:
1549
- try:
1550
- self.callback()
1551
- except Exception:
1552
- pass
1755
+ class _ShowProxy(dynamic_proxy(OnShowListener)):
1756
+ def onShow(self, di: Any) -> None:
1757
+ _fire(placeholder, "on_show")
1553
1758
 
1554
- dialog.setOnShowListener(_ShowProxy(on_show))
1555
- if on_dismiss is not None:
1556
- OnDismissListener = jclass("android.content.DialogInterface$OnDismissListener")
1759
+ dialog.setOnShowListener(_ShowProxy())
1557
1760
 
1558
- class _DismissProxy(dynamic_proxy(OnDismissListener)):
1559
- def __init__(self, callback: Callable[[], None]) -> None:
1560
- super().__init__()
1561
- self.callback = callback
1761
+ OnDismissListener = jclass("android.content.DialogInterface$OnDismissListener")
1562
1762
 
1563
- def onDismiss(self, di: Any) -> None:
1564
- try:
1565
- self.callback()
1566
- except Exception:
1567
- pass
1763
+ class _DismissProxy(dynamic_proxy(OnDismissListener)):
1764
+ def onDismiss(self, di: Any) -> None:
1765
+ _fire(placeholder, "on_dismiss")
1568
1766
 
1569
- dialog.setOnDismissListener(_DismissProxy(on_dismiss))
1767
+ dialog.setOnDismissListener(_DismissProxy())
1570
1768
  dialog.show()
1571
1769
  except Exception:
1572
1770
  pass
1573
1771
 
1574
- def _dismiss(self, placeholder: Any) -> None:
1575
- state = _pn_modal_states.pop(id(placeholder), None)
1576
- if state is None:
1577
- return
1578
- dialog = state.get("dialog")
1772
+ def _dismiss(self, placeholder: Any, state: Dict[str, Any]) -> None:
1773
+ dialog = state.pop("dialog", None)
1774
+ state.pop("content_view", None)
1579
1775
  if dialog is not None:
1580
1776
  try:
1581
1777
  dialog.dismiss()
@@ -1584,46 +1780,38 @@ class ModalHandler(AndroidViewHandler):
1584
1780
 
1585
1781
 
1586
1782
  class SliderHandler(AndroidViewHandler):
1587
- def create(self, props: Dict[str, Any]) -> Any:
1783
+ def _build(self, props: Dict[str, Any]) -> Any:
1588
1784
  sb = jclass("android.widget.SeekBar")(_ctx())
1589
1785
  sb.setMax(1000)
1590
- self._apply(sb, props)
1591
- return sb
1592
-
1593
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1594
- self._apply(native_view, changed)
1595
-
1596
- def _apply(self, sb: Any, props: Dict[str, Any]) -> None:
1597
- min_val = float(props.get("min_value", 0))
1598
- max_val = float(props.get("max_value", 1))
1599
- rng = max_val - min_val if max_val != min_val else 1
1600
- if "value" in props:
1601
- normalized = (float(props["value"]) - min_val) / rng
1602
- sb.setProgress(int(normalized * 1000))
1603
- if "on_change" in props and props["on_change"] is not None:
1604
- cb = props["on_change"]
1605
-
1606
- class SeekProxy(dynamic_proxy(jclass("android.widget.SeekBar").OnSeekBarChangeListener)):
1607
- def __init__(self, callback: Callable[[float], None], mn: float, rn: float) -> None:
1608
- super().__init__()
1609
- self.callback = callback
1610
- self.mn = mn
1611
- self.rn = rn
1612
-
1613
- def onProgressChanged(self, seekBar: Any, progress: int, fromUser: bool) -> None:
1614
- if fromUser:
1615
- self.callback(self.mn + (progress / 1000.0) * self.rn)
1616
1786
 
1617
- def onStartTrackingTouch(self, seekBar: Any) -> None:
1618
- pass
1787
+ class SeekProxy(dynamic_proxy(jclass("android.widget.SeekBar").OnSeekBarChangeListener)):
1788
+ def onProgressChanged(self, seekBar: Any, progress: int, fromUser: bool) -> None:
1789
+ if not fromUser:
1790
+ return
1791
+ merged = _state_of(seekBar).get("props") or {}
1792
+ mn = float(merged.get("min_value", 0))
1793
+ mx = float(merged.get("max_value", 1))
1794
+ rng = mx - mn if mx != mn else 1
1795
+ _fire(seekBar, "on_change", mn + (progress / 1000.0) * rng)
1619
1796
 
1620
- def onStopTrackingTouch(self, seekBar: Any) -> None:
1621
- pass
1797
+ def onStartTrackingTouch(self, seekBar: Any) -> None:
1798
+ pass
1622
1799
 
1623
- sb.setOnSeekBarChangeListener(SeekProxy(cb, min_val, rng))
1800
+ def onStopTrackingTouch(self, seekBar: Any) -> None:
1801
+ pass
1624
1802
 
1803
+ sb.setOnSeekBarChangeListener(SeekProxy())
1804
+ return sb
1625
1805
 
1626
- _android_tabbar_state: dict = {"callback": None, "items": []}
1806
+ def _apply(self, sb: Any, props: Dict[str, Any], initial: bool) -> None:
1807
+ merged = _state_of(sb).get("props") or props
1808
+ min_val = float(merged.get("min_value", 0))
1809
+ max_val = float(merged.get("max_value", 1))
1810
+ rng = max_val - min_val if max_val != min_val else 1
1811
+ if "value" in props and props["value"] is not None:
1812
+ normalized = (float(props["value"]) - min_val) / rng
1813
+ sb.setProgress(int(normalized * 1000))
1814
+ _apply_accessibility(sb, props)
1627
1815
 
1628
1816
 
1629
1817
  class TabBarHandler(AndroidViewHandler):
@@ -1634,71 +1822,67 @@ class TabBarHandler(AndroidViewHandler):
1634
1822
  """
1635
1823
 
1636
1824
  _LABEL_VISIBILITY_LABELED = 1
1637
- _is_material: bool = True
1638
1825
 
1639
- def create(self, props: Dict[str, Any]) -> Any:
1826
+ def _build(self, props: Dict[str, Any]) -> Any:
1640
1827
  try:
1641
1828
  bnv = jclass("com.google.android.material.bottomnavigation.BottomNavigationView")(_ctx())
1642
1829
  bnv.setBackgroundColor(parse_color_int("#FFFFFF"))
1643
- self._configure_material_bar(bnv)
1644
- self._is_material = True
1645
- self._apply_full(bnv, props)
1830
+ try:
1831
+ bnv.setLabelVisibilityMode(self._LABEL_VISIBILITY_LABELED)
1832
+ except Exception:
1833
+ pass
1646
1834
  return bnv
1647
1835
  except Exception:
1648
- self._is_material = False
1649
- return self._create_fallback(props)
1650
-
1651
- def _create_fallback(self, props: Dict[str, Any]) -> Any:
1652
- """Horizontal LinearLayout with Button children as a tab-bar fallback."""
1653
- LinearLayout = jclass("android.widget.LinearLayout")
1654
- ll = LinearLayout(_ctx())
1655
- ll.setOrientation(LinearLayout.HORIZONTAL)
1656
- ll.setBackgroundColor(parse_color_int("#F8F8F8"))
1657
- self._apply_fallback(ll, props)
1658
- return ll
1659
-
1660
- def _configure_material_bar(self, bnv: Any) -> None:
1661
- """Keep text visible for every tab, including 4+ item bars."""
1836
+ LinearLayout = jclass("android.widget.LinearLayout")
1837
+ ll = LinearLayout(_ctx())
1838
+ ll.setOrientation(LinearLayout.HORIZONTAL)
1839
+ ll.setBackgroundColor(parse_color_int("#F8F8F8"))
1840
+ return ll
1841
+
1842
+ def create(self, tag: int, props: Dict[str, Any]) -> Any:
1843
+ view = super().create(tag, props)
1844
+ state = _state_of(view)
1845
+ state["is_material"] = "LinearLayout" not in str(type(view))
1662
1846
  try:
1663
- bnv.setLabelVisibilityMode(self._LABEL_VISIBILITY_LABELED)
1847
+ state["is_material"] = bool(view.getMenu() is not None)
1664
1848
  except Exception:
1665
- pass
1666
-
1667
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1668
- if self._is_material:
1669
- self._apply_partial(native_view, changed)
1849
+ state["is_material"] = False
1850
+ if state["is_material"]:
1851
+ self._bind_material_listener(view)
1852
+ # Re-run the items now that we know which flavor we hold.
1853
+ self._apply(view, props, initial=True)
1854
+ return view
1855
+
1856
+ def _apply(self, view: Any, props: Dict[str, Any], initial: bool) -> None:
1857
+ state = _state_of(view)
1858
+ if "is_material" not in state:
1859
+ return # create() re-invokes once flavor detection is done.
1860
+ if state.get("is_material"):
1861
+ if "items" in props:
1862
+ self._set_menu(view, props["items"] or [])
1863
+ if "active_tab" in props:
1864
+ items = (state.get("props") or {}).get("items") or []
1865
+ self._set_active(view, props["active_tab"], items)
1670
1866
  else:
1671
- self._apply_fallback(native_view, changed)
1672
-
1673
- def _apply_full(self, bnv: Any, props: Dict[str, Any]) -> None:
1674
- """Initial creation — all props are present."""
1675
- items = props.get("items", [])
1676
- self._set_menu(bnv, items)
1677
- self._set_active(bnv, props.get("active_tab"), items)
1678
- cb = props.get("on_tab_select")
1679
- if cb is not None:
1680
- self._set_listener(bnv, cb, items)
1681
-
1682
- def _apply_partial(self, bnv: Any, changed: Dict[str, Any]) -> None:
1683
- """Reconciler update — only changed props are present."""
1684
- prev_items = _android_tabbar_state["items"]
1685
-
1686
- if "items" in changed:
1687
- items = changed["items"]
1688
- self._set_menu(bnv, items)
1689
- else:
1690
- items = prev_items
1867
+ self._apply_fallback(view, props)
1691
1868
 
1692
- if "active_tab" in changed:
1693
- self._set_active(bnv, changed["active_tab"], items)
1869
+ def _bind_material_listener(self, bnv: Any) -> None:
1870
+ try:
1871
+ listener_cls = jclass("com.google.android.material.navigation.NavigationBarView$OnItemSelectedListener")
1694
1872
 
1695
- if "on_tab_select" in changed:
1696
- cb = changed["on_tab_select"]
1697
- if cb is not None:
1698
- self._set_listener(bnv, cb, items)
1873
+ class _TabSelectProxy(dynamic_proxy(listener_cls)):
1874
+ def onNavigationItemSelected(self, menu_item: Any) -> bool:
1875
+ idx = menu_item.getItemId()
1876
+ items = (_state_of(bnv).get("props") or {}).get("items") or []
1877
+ if 0 <= idx < len(items):
1878
+ _fire(bnv, "on_tab_select", items[idx].get("name", ""))
1879
+ return True
1880
+
1881
+ bnv.setOnItemSelectedListener(_TabSelectProxy())
1882
+ except Exception:
1883
+ pass
1699
1884
 
1700
1885
  def _set_menu(self, bnv: Any, items: list) -> None:
1701
- _android_tabbar_state["items"] = items
1702
1886
  try:
1703
1887
  menu = bnv.getMenu()
1704
1888
  menu.clear()
@@ -1749,33 +1933,11 @@ class TabBarHandler(AndroidViewHandler):
1749
1933
  pass
1750
1934
  break
1751
1935
 
1752
- def _set_listener(self, bnv: Any, cb: Callable, items: list) -> None:
1753
- _android_tabbar_state["callback"] = cb
1754
- _android_tabbar_state["items"] = items
1755
- try:
1756
- listener_cls = jclass("com.google.android.material.navigation.NavigationBarView$OnItemSelectedListener")
1757
-
1758
- class _TabSelectProxy(dynamic_proxy(listener_cls)):
1759
- def __init__(self, callback: Callable, tab_items: list) -> None:
1760
- super().__init__()
1761
- self.callback = callback
1762
- self.tab_items = tab_items
1763
-
1764
- def onNavigationItemSelected(self, menu_item: Any) -> bool:
1765
- idx = menu_item.getItemId()
1766
- if 0 <= idx < len(self.tab_items):
1767
- self.callback(self.tab_items[idx].get("name", ""))
1768
- return True
1769
-
1770
- bnv.setOnItemSelectedListener(_TabSelectProxy(cb, items))
1771
- except Exception:
1772
- pass
1773
-
1774
1936
  def _apply_fallback(self, ll: Any, props: Dict[str, Any]) -> None:
1775
- items = props.get("items", [])
1776
- active = props.get("active_tab")
1777
- cb = props.get("on_tab_select")
1778
- if "items" in props:
1937
+ merged = _state_of(ll).get("props") or props
1938
+ items = merged.get("items", []) or []
1939
+ active = merged.get("active_tab")
1940
+ if "items" in props or "active_tab" in props:
1779
1941
  ll.removeAllViews()
1780
1942
  for item in items:
1781
1943
  name = item.get("name", "")
@@ -1783,96 +1945,131 @@ class TabBarHandler(AndroidViewHandler):
1783
1945
  btn = jclass("android.widget.Button")(_ctx())
1784
1946
  btn.setText(str(title))
1785
1947
  btn.setEnabled(name != active)
1786
- if cb is not None:
1787
- tab_name = name
1788
-
1789
- def _make_click(n: str) -> Callable[[], None]:
1790
- return lambda: cb(n)
1791
1948
 
1792
- class _ClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
1793
- def __init__(self, callback: Callable[[], None]) -> None:
1794
- super().__init__()
1795
- self.callback = callback
1949
+ class _ClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
1950
+ def __init__(self, tab_name: str) -> None:
1951
+ super().__init__()
1952
+ self.tab_name = tab_name
1796
1953
 
1797
- def onClick(self, view: Any) -> None:
1798
- self.callback()
1954
+ def onClick(self, view: Any) -> None:
1955
+ _fire(ll, "on_tab_select", self.tab_name)
1799
1956
 
1800
- btn.setOnClickListener(_ClickProxy(_make_click(tab_name)))
1957
+ btn.setOnClickListener(_ClickProxy(name))
1801
1958
  ll.addView(btn)
1802
1959
 
1803
1960
 
1804
1961
  # ======================================================================
1805
- # Pressable — visual feedback + tap callbacks
1962
+ # Pressable — visual feedback + tap callbacks + gestures
1806
1963
  # ======================================================================
1807
1964
 
1808
1965
 
1809
- class PressableHandler(AndroidViewHandler):
1810
- def create(self, props: Dict[str, Any]) -> Any:
1966
+ class PressableHandler(FlexContainerHandler):
1967
+ """Container that dispatches press events through one touch stream.
1968
+
1969
+ A single ``OnTouchListener`` drives the entire interaction:
1970
+ ``on_press_in`` at finger-down (plus the opacity dip),
1971
+ ``on_long_press`` on a 500 ms hold, ``on_press`` on a clean
1972
+ release, and ``on_press_out`` when the finger lifts or the touch
1973
+ cancels. The same stream feeds the gesture arbiter when
1974
+ ``gestures`` are attached, so press feedback and pan/pinch
1975
+ recognition coexist on one view.
1976
+ """
1977
+
1978
+ _LONG_PRESS_MS = 500
1979
+ _TAP_SLOP_DP = 12.0
1980
+
1981
+ def _build(self, props: Dict[str, Any]) -> Any:
1811
1982
  fl = jclass("android.widget.FrameLayout")(_ctx())
1812
1983
  fl.setClickable(True)
1813
1984
  fl.setFocusable(True)
1814
- self._apply(fl, props)
1985
+ self._bind_press_stream(fl)
1815
1986
  return fl
1816
1987
 
1817
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1818
- self._apply(native_view, changed)
1819
-
1820
- def _apply(self, fl: Any, props: Dict[str, Any]) -> None:
1821
- if "on_press" in props and props["on_press"] is not None:
1822
- cb = props["on_press"]
1988
+ def create(self, tag: int, props: Dict[str, Any]) -> Any:
1989
+ view = super().create(tag, props)
1990
+ # Press handling owns the touch listener; gesture specs are fed
1991
+ # from inside the press stream rather than a second listener.
1992
+ _state_of(view)["gestures_bound"] = True
1993
+ return view
1823
1994
 
1824
- class PressProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
1825
- def __init__(self, callback: Callable[[], None]) -> None:
1826
- super().__init__()
1827
- self.callback = callback
1828
-
1829
- def onClick(self, view: Any) -> None:
1830
- self.callback()
1831
-
1832
- fl.setOnClickListener(PressProxy(cb))
1833
- if "on_long_press" in props and props["on_long_press"] is not None:
1834
- cb = props["on_long_press"]
1835
-
1836
- class LongPressProxy(dynamic_proxy(jclass("android.view.View").OnLongClickListener)):
1837
- def __init__(self, callback: Callable[[], None]) -> None:
1838
- super().__init__()
1839
- self.callback = callback
1995
+ def update(self, native_view: Any, changed_props: Dict[str, Any]) -> None:
1996
+ _state_of(native_view)["gestures_bound"] = True
1997
+ super().update(native_view, changed_props)
1840
1998
 
1841
- def onLongClick(self, view: Any) -> bool:
1842
- self.callback()
1843
- return True
1844
-
1845
- fl.setOnLongClickListener(LongPressProxy(cb))
1846
- # Press feedback via OnTouchListener that fades the alpha.
1847
- if "pressed_opacity" in props or "on_press" in props:
1848
- try:
1849
- pressed_opacity = float(props.get("pressed_opacity", 0.6))
1850
- OnTouchListener = jclass("android.view.View$OnTouchListener")
1851
- MotionEvent = jclass("android.view.MotionEvent") # noqa: F841
1852
-
1853
- class _TouchProxy(dynamic_proxy(OnTouchListener)):
1854
- def __init__(self, opacity: float) -> None:
1855
- super().__init__()
1856
- self.opacity = opacity
1999
+ def _bind_press_stream(self, fl: Any) -> None:
2000
+ try:
2001
+ OnTouchListener = jclass("android.view.View$OnTouchListener")
2002
+ Handler = jclass("android.os.Handler")
2003
+ Looper = jclass("android.os.Looper")
2004
+ Runnable = jclass("java.lang.Runnable")
2005
+ handler = Handler(Looper.getMainLooper())
2006
+ slop = self._TAP_SLOP_DP
2007
+ long_ms = self._LONG_PRESS_MS
2008
+
2009
+ class _PressTouchProxy(dynamic_proxy(OnTouchListener)):
2010
+ def onTouch(self, view: Any, event: Any) -> bool:
2011
+ state = _state_of(view)
2012
+ _feed_motion_event(view, state, event)
2013
+ action = int(event.getActionMasked())
2014
+ density = _density() or 1.0
2015
+ x_dp = float(event.getX()) / density
2016
+ y_dp = float(event.getY()) / density
2017
+ if action == 0: # DOWN
2018
+ press = {
2019
+ "down": (x_dp, y_dp),
2020
+ "moved": False,
2021
+ "long_fired": False,
2022
+ "seq": state.get("press_seq", 0) + 1,
2023
+ }
2024
+ state["press"] = press
2025
+ state["press_seq"] = press["seq"]
2026
+ _fire(view, "on_press_in")
2027
+ merged = state.get("props") or {}
2028
+ opacity = float(merged.get("pressed_opacity", 0.6))
2029
+ if opacity < 1.0:
2030
+ try:
2031
+ view.animate().alpha(opacity).setDuration(50).start()
2032
+ except Exception:
2033
+ pass
2034
+ if _has_event(view, "on_long_press"):
2035
+ seq = press["seq"]
1857
2036
 
1858
- def onTouch(self, view: Any, event: Any) -> bool:
1859
- action = event.getAction()
1860
- if action == 0: # ACTION_DOWN
1861
- view.animate().alpha(self.opacity).setDuration(50).start()
1862
- elif action in (1, 3): # ACTION_UP / ACTION_CANCEL
2037
+ class _LongRunnable(dynamic_proxy(Runnable)):
2038
+ def run(self) -> None:
2039
+ live = _state_of(view).get("press")
2040
+ if live is None or live.get("seq") != seq:
2041
+ return
2042
+ if live.get("moved") or live.get("long_fired"):
2043
+ return
2044
+ live["long_fired"] = True
2045
+ _fire(view, "on_long_press")
2046
+
2047
+ handler.postDelayed(_LongRunnable(), long_ms)
2048
+ return True
2049
+ press = state.get("press")
2050
+ if press is None:
2051
+ return True
2052
+ if action == 2: # MOVE
2053
+ dx = x_dp - press["down"][0]
2054
+ dy = y_dp - press["down"][1]
2055
+ if math.hypot(dx, dy) > slop:
2056
+ press["moved"] = True
2057
+ return True
2058
+ if action in (1, 3): # UP / CANCEL
2059
+ state["press"] = None
2060
+ _fire(view, "on_press_out")
2061
+ try:
1863
2062
  view.animate().alpha(1.0).setDuration(100).start()
1864
- return False
1865
-
1866
- fl.setOnTouchListener(_TouchProxy(pressed_opacity))
1867
- except Exception:
1868
- pass
1869
- _apply_common_visual(fl, props)
1870
-
1871
- def add_child(self, parent: Any, child: Any) -> None:
1872
- parent.addView(child)
2063
+ except Exception:
2064
+ pass
2065
+ if action == 1 and not press["moved"] and not press["long_fired"]:
2066
+ _fire(view, "on_press")
2067
+ return True
2068
+ return True
1873
2069
 
1874
- def remove_child(self, parent: Any, child: Any) -> None:
1875
- parent.removeView(child)
2070
+ fl.setOnTouchListener(_PressTouchProxy())
2071
+ except Exception:
2072
+ pass
1876
2073
 
1877
2074
 
1878
2075
  # ======================================================================
@@ -1883,19 +2080,15 @@ class PressableHandler(AndroidViewHandler):
1883
2080
  class StatusBarHandler(AndroidViewHandler):
1884
2081
  """Apply status-bar background color / style on the host activity."""
1885
2082
 
1886
- def create(self, props: Dict[str, Any]) -> Any:
2083
+ def _build(self, props: Dict[str, Any]) -> Any:
1887
2084
  v = jclass("android.view.View")(_ctx())
1888
2085
  v.setVisibility(jclass("android.view.View").GONE)
1889
- self._apply(props)
1890
2086
  return v
1891
2087
 
1892
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1893
- self._apply(changed)
1894
-
1895
2088
  def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
1896
2089
  return
1897
2090
 
1898
- def _apply(self, props: Dict[str, Any]) -> None:
2091
+ def _apply(self, view: Any, props: Dict[str, Any], initial: bool) -> None:
1899
2092
  try:
1900
2093
  ctx = _ctx()
1901
2094
  window = ctx.getWindow()
@@ -1919,174 +2112,8 @@ class StatusBarHandler(AndroidViewHandler):
1919
2112
  pass
1920
2113
 
1921
2114
 
1922
- # ======================================================================
1923
- # KeyboardAvoidingView — vanilla container; the user-land component
1924
- # computes the offset from manifest-driven insets.
1925
- # ======================================================================
1926
-
1927
-
1928
- class KeyboardAvoidingViewHandler(AndroidViewHandler):
1929
- def create(self, props: Dict[str, Any]) -> Any:
1930
- fl = jclass("android.widget.FrameLayout")(_ctx())
1931
- _apply_common_visual(fl, props)
1932
- return fl
1933
-
1934
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1935
- _apply_common_visual(native_view, changed)
1936
-
1937
- def add_child(self, parent: Any, child: Any) -> None:
1938
- parent.addView(child)
1939
-
1940
- def remove_child(self, parent: Any, child: Any) -> None:
1941
- parent.removeView(child)
1942
-
1943
-
1944
- # ======================================================================
1945
- # VirtualList — RecyclerView-backed virtualized list
1946
- # ======================================================================
1947
-
1948
-
1949
- _pn_recyclerview_state: Dict[int, Any] = {}
1950
-
1951
-
1952
- def _java_id(jobj: Any) -> int:
1953
- """Return ``System.identityHashCode(jobj)`` as a stable lookup key.
1954
-
1955
- Chaquopy's ``JavaObject.__setattr__`` rejects unknown Python attributes,
1956
- so we cannot stash custom IDs on the Java view wrapper. Instead, we use
1957
- the JVM's identity hash code, which is stable for the lifetime of the
1958
- Java object and the same across all Python wrappers that may proxy it.
1959
- """
1960
- System = jclass("java.lang.System")
1961
- return int(System.identityHashCode(jobj))
1962
-
1963
-
1964
- def _make_recyclerview_delegate(props: Dict[str, Any]) -> Any:
1965
- Delegate = _pn_runtime_class("PNVirtualListView$Delegate")
1966
-
1967
- class _Delegate(dynamic_proxy(Delegate)):
1968
- def __init__(self, initial: Dict[str, Any]) -> None:
1969
- super().__init__()
1970
- self.count = int(initial.get("count", 0))
1971
- self.row_height = float(initial.get("row_height", 44.0))
1972
- self.mount_row = initial.get("mount_row")
1973
- self.on_row_press = initial.get("on_row_press")
1974
-
1975
- def update(self, changed: Dict[str, Any]) -> None:
1976
- if "count" in changed:
1977
- self.count = int(changed["count"])
1978
- if "row_height" in changed and changed["row_height"] is not None:
1979
- self.row_height = float(changed["row_height"])
1980
- if "mount_row" in changed:
1981
- self.mount_row = changed["mount_row"]
1982
- if "on_row_press" in changed:
1983
- self.on_row_press = changed["on_row_press"]
1984
-
1985
- def getCount(self) -> int:
1986
- return self.count
1987
-
1988
- def getRowHeightDp(self) -> float:
1989
- return self.row_height
1990
-
1991
- def mountRow(self, position: int, container: Any, width_dp: float, height_dp: float) -> None:
1992
- if self.mount_row is None:
1993
- return
1994
- try:
1995
- self.mount_row(int(position), container, float(width_dp), float(height_dp))
1996
- except Exception:
1997
- import traceback as _tb
1998
-
1999
- _tb.print_exc()
2000
-
2001
- def onRowPress(self, position: int) -> None:
2002
- idx = int(position)
2003
- if idx < 0 or self.on_row_press is None:
2004
- return
2005
- try:
2006
- self.on_row_press(idx)
2007
- except Exception:
2008
- import traceback as _tb
2009
-
2010
- _tb.print_exc()
2011
-
2012
- return _Delegate(props)
2013
-
2014
-
2015
- class VirtualListHandler(AndroidViewHandler):
2016
- """Backed by ``RecyclerView`` through a tiny Android template helper.
2017
-
2018
- Chaquopy cannot proxy ``RecyclerView.Adapter`` directly because it is an
2019
- abstract Java class, so the Android template provides
2020
- ``PNVirtualListView``. Python implements that helper's small ``Delegate``
2021
- interface, while Java owns the adapter/view-holder lifecycle.
2022
- """
2023
-
2024
- def create(self, props: Dict[str, Any]) -> Any:
2025
- try:
2026
- PNVirtualListView = _pn_runtime_class("PNVirtualListView")
2027
- delegate = _make_recyclerview_delegate(props)
2028
- rv = PNVirtualListView(_ctx(), delegate)
2029
- if "background_color" in props and props["background_color"] is not None:
2030
- rv.setBackgroundColor(parse_color_int(props["background_color"]))
2031
- key = _java_id(rv)
2032
- _pn_recyclerview_state[key] = delegate
2033
- return rv
2034
- except Exception:
2035
- return self._fallback(props)
2036
-
2037
- def _fallback(self, props: Dict[str, Any]) -> Any:
2038
- """Eagerly mount all rows in a ScrollView (controller init failed).
2039
-
2040
- Sets each row's LinearLayout.LayoutParams to MATCH_PARENT × row_h_px
2041
- so cells have a real visual size, and forwards the screen width (in
2042
- dp) to ``mount_row`` so the layout engine can position child
2043
- elements.
2044
- """
2045
- n = int(props.get("count", 0))
2046
- row_h_dp = float(props.get("row_height", 44.0))
2047
- density = _density()
2048
- row_h_px = max(1, int(round(row_h_dp * density)))
2049
-
2050
- try:
2051
- screen_w_px = float(_ctx().getResources().getDisplayMetrics().widthPixels)
2052
- screen_w_dp = screen_w_px / density if density else screen_w_px
2053
- except Exception:
2054
- screen_w_dp = 0.0
2055
-
2056
- sv = jclass("android.widget.ScrollView")(_ctx())
2057
- LinearLayout = jclass("android.widget.LinearLayout")
2058
- LL_LP = jclass("android.widget.LinearLayout$LayoutParams")
2059
- ll = LinearLayout(_ctx())
2060
- ll.setOrientation(LinearLayout.VERTICAL)
2061
- sv.addView(ll)
2062
-
2063
- mount = props.get("mount_row")
2064
-
2065
- for i in range(n):
2066
- try:
2067
- cell = jclass("android.widget.FrameLayout")(_ctx())
2068
- cell.setLayoutParams(LL_LP(LL_LP.MATCH_PARENT, row_h_px))
2069
- if mount is not None:
2070
- mount(i, cell, screen_w_dp, row_h_dp)
2071
- ll.addView(cell)
2072
- except Exception:
2073
- continue
2074
- return sv
2075
-
2076
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
2077
- delegate = _pn_recyclerview_state.get(_java_id(native_view))
2078
- if delegate is None:
2079
- return
2080
- delegate.update(changed)
2081
- if "background_color" in changed and changed["background_color"] is not None:
2082
- try:
2083
- native_view.setBackgroundColor(parse_color_int(changed["background_color"]))
2084
- except Exception:
2085
- pass
2086
- try:
2087
- native_view.notifyDataChanged()
2088
- except Exception:
2089
- pass
2115
+ class KeyboardAvoidingViewHandler(FlexContainerHandler):
2116
+ """Vanilla container; the user-land component computes the offset."""
2090
2117
 
2091
2118
 
2092
2119
  # ======================================================================
@@ -2202,31 +2229,43 @@ def _present_alert(
2202
2229
  # ======================================================================
2203
2230
  # Picker — native dropdown / select widget
2204
2231
  # ======================================================================
2205
- #
2206
- # Renders the PythonNative `Picker` element as an Android ``Spinner``,
2207
- # which is the platform's standard dropdown widget. The selected item is
2208
- # pushed to the user's callback via ``OnItemSelectedListener``.
2209
2232
 
2210
2233
 
2211
2234
  class PickerHandler(AndroidViewHandler):
2212
2235
  """``Picker`` element handler — native ``Spinner`` dropdown."""
2213
2236
 
2214
- def create(self, props: Dict[str, Any]) -> Any:
2215
- Spinner = jclass("android.widget.Spinner")
2216
- sp = Spinner(_ctx())
2217
- self._state: Dict[int, Dict[str, Any]] = getattr(self, "_state", {})
2218
- self._state[id(sp)] = {"items": [], "on_change": None, "suppress": False}
2219
- self._apply(sp, props, initial=True)
2220
- return sp
2237
+ def _build(self, props: Dict[str, Any]) -> Any:
2238
+ sp = jclass("android.widget.Spinner")(_ctx())
2239
+
2240
+ class _PickerListener(dynamic_proxy(jclass("android.widget.AdapterView").OnItemSelectedListener)):
2241
+ def onItemSelected(
2242
+ self,
2243
+ parent: Any,
2244
+ view: Any, # noqa: ARG002
2245
+ position: int,
2246
+ id_: int, # noqa: ARG002
2247
+ ) -> None:
2248
+ state = _state_of(parent)
2249
+ if state.get("suppress"):
2250
+ return
2251
+ items = (state.get("props") or {}).get("items") or []
2252
+ if 0 <= position < len(items):
2253
+ item = items[position]
2254
+ v = item.get("value") if isinstance(item, dict) else item
2255
+ _fire(parent, "on_change", v)
2221
2256
 
2222
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
2223
- self._apply(native_view, changed, initial=False)
2257
+ def onNothingSelected(self, parent: Any) -> None: # noqa: ARG002
2258
+ pass
2259
+
2260
+ sp.setOnItemSelectedListener(_PickerListener())
2261
+ return sp
2224
2262
 
2225
2263
  def _apply(self, sp: Any, props: Dict[str, Any], initial: bool) -> None:
2226
- state = self._state.setdefault(id(sp), {"items": [], "on_change": None, "suppress": False})
2264
+ state = _state_of(sp)
2265
+ merged = state.get("props") or props
2227
2266
 
2228
2267
  if "items" in props or initial:
2229
- items = list(props.get("items") or state.get("items") or [])
2268
+ items = list(merged.get("items") or [])
2230
2269
  labels = []
2231
2270
  for item in items:
2232
2271
  if isinstance(item, dict):
@@ -2238,13 +2277,14 @@ class PickerHandler(AndroidViewHandler):
2238
2277
  adapter = ArrayAdapter(_ctx(), R.layout.simple_spinner_item, labels)
2239
2278
  adapter.setDropDownViewResource(R.layout.simple_spinner_dropdown_item)
2240
2279
  state["suppress"] = True
2241
- sp.setAdapter(adapter)
2242
- state["suppress"] = False
2243
- state["items"] = items
2280
+ try:
2281
+ sp.setAdapter(adapter)
2282
+ finally:
2283
+ state["suppress"] = False
2244
2284
 
2245
2285
  if "value" in props or initial:
2246
- items = state["items"]
2247
- value = props.get("value") if "value" in props else None
2286
+ items = list(merged.get("items") or [])
2287
+ value = merged.get("value")
2248
2288
  target_index = -1
2249
2289
  for i, item in enumerate(items):
2250
2290
  v = item.get("value") if isinstance(item, dict) else item
@@ -2253,41 +2293,10 @@ class PickerHandler(AndroidViewHandler):
2253
2293
  break
2254
2294
  if target_index >= 0 and sp.getSelectedItemPosition() != target_index:
2255
2295
  state["suppress"] = True
2256
- sp.setSelection(target_index, False)
2257
- state["suppress"] = False
2258
-
2259
- if "on_change" in props or initial:
2260
- state["on_change"] = props.get("on_change") if "on_change" in props else state.get("on_change")
2261
-
2262
- class _PickerListener(dynamic_proxy(jclass("android.widget.AdapterView").OnItemSelectedListener)):
2263
- def __init__(self, owner_state: Dict[str, Any]) -> None:
2264
- super().__init__()
2265
- self._owner_state = owner_state
2266
-
2267
- def onItemSelected(
2268
- self,
2269
- parent: Any,
2270
- view: Any, # noqa: ARG002
2271
- position: int,
2272
- id_: int, # noqa: ARG002
2273
- ) -> None:
2274
- if self._owner_state.get("suppress"):
2275
- return
2276
- items = self._owner_state.get("items") or []
2277
- if 0 <= position < len(items):
2278
- item = items[position]
2279
- v = item.get("value") if isinstance(item, dict) else item
2280
- cb = self._owner_state.get("on_change")
2281
- if cb is not None:
2282
- try:
2283
- cb(v)
2284
- except Exception:
2285
- pass
2286
-
2287
- def onNothingSelected(self, parent: Any) -> None: # noqa: ARG002
2288
- pass
2289
-
2290
- sp.setOnItemSelectedListener(_PickerListener(state))
2296
+ try:
2297
+ sp.setSelection(target_index, False)
2298
+ finally:
2299
+ state["suppress"] = False
2291
2300
 
2292
2301
 
2293
2302
  # ======================================================================
@@ -2299,65 +2308,42 @@ class CheckboxHandler(AndroidViewHandler):
2299
2308
  """``Checkbox`` element handler — native ``CheckBox`` widget.
2300
2309
 
2301
2310
  Programmatic ``value`` updates are wrapped in a per-view
2302
- "suppress" guard (mirroring ``PickerHandler``) so pushing a new
2303
- state via ``setChecked`` never re-fires the user's ``on_change``.
2311
+ "suppress" guard so pushing a new state via ``setChecked`` never
2312
+ re-fires the user's ``on_change``.
2304
2313
  """
2305
2314
 
2306
- def create(self, props: Dict[str, Any]) -> Any:
2315
+ def _build(self, props: Dict[str, Any]) -> Any:
2307
2316
  cb = jclass("android.widget.CheckBox")(_ctx())
2308
- self._state: Dict[int, Dict[str, Any]] = getattr(self, "_state", {})
2309
- self._state[id(cb)] = {"on_change": None, "suppress": False}
2310
- self._apply(cb, props, initial=True)
2311
- return cb
2312
2317
 
2313
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
2314
- self._apply(native_view, changed, initial=False)
2318
+ class _CheckedProxy(dynamic_proxy(jclass("android.widget.CompoundButton").OnCheckedChangeListener)):
2319
+ def onCheckedChanged(self, button: Any, is_checked: bool) -> None:
2320
+ if _state_of(button).get("suppress"):
2321
+ return
2322
+ _fire(button, "on_change", bool(is_checked))
2315
2323
 
2316
- def _apply(self, cb: Any, props: Dict[str, Any], initial: bool) -> None:
2317
- state = self._state.setdefault(id(cb), {"on_change": None, "suppress": False})
2324
+ cb.setOnCheckedChangeListener(_CheckedProxy())
2325
+ return cb
2318
2326
 
2327
+ def _apply(self, cb: Any, props: Dict[str, Any], initial: bool) -> None:
2328
+ state = _state_of(cb)
2319
2329
  if "label" in props:
2320
2330
  cb.setText(str(props["label"]) if props["label"] is not None else "")
2321
-
2322
2331
  if "value" in props:
2323
2332
  state["suppress"] = True
2324
2333
  try:
2325
2334
  cb.setChecked(bool(props["value"]))
2326
2335
  finally:
2327
2336
  state["suppress"] = False
2328
-
2329
2337
  if "disabled" in props:
2330
2338
  # ``disabled`` is only present when truthy; a removal (``None``)
2331
2339
  # re-enables the control.
2332
2340
  cb.setEnabled(not bool(props["disabled"]))
2333
-
2334
2341
  if "color" in props and props["color"] is not None:
2335
2342
  try:
2336
2343
  ColorStateList = jclass("android.content.res.ColorStateList")
2337
2344
  cb.setButtonTintList(ColorStateList.valueOf(parse_color_int(props["color"])))
2338
2345
  except Exception:
2339
2346
  pass
2340
-
2341
- if "on_change" in props or initial:
2342
- state["on_change"] = props.get("on_change") if "on_change" in props else state.get("on_change")
2343
-
2344
- class _CheckboxCheckedProxy(dynamic_proxy(jclass("android.widget.CompoundButton").OnCheckedChangeListener)):
2345
- def __init__(self, owner_state: Dict[str, Any]) -> None:
2346
- super().__init__()
2347
- self._owner_state = owner_state
2348
-
2349
- def onCheckedChanged(self, button: Any, is_checked: bool) -> None:
2350
- if self._owner_state.get("suppress"):
2351
- return
2352
- callback = self._owner_state.get("on_change")
2353
- if callback is not None:
2354
- try:
2355
- callback(bool(is_checked))
2356
- except Exception:
2357
- pass
2358
-
2359
- cb.setOnCheckedChangeListener(_CheckboxCheckedProxy(state))
2360
-
2361
2347
  _apply_accessibility(cb, props)
2362
2348
 
2363
2349
 
@@ -2372,70 +2358,34 @@ class SegmentedControlHandler(AndroidViewHandler):
2372
2358
  Android has no ``UISegmentedControl`` equivalent, so the control is
2373
2359
  built from a horizontal ``LinearLayout`` holding one ``Button`` per
2374
2360
  segment. The selected segment is filled with the ``tint_color`` (or
2375
- a default accent); the rest are drawn outlined. Selection state and
2376
- the change callback live in a per-view dict, and a "suppress" guard
2377
- keeps programmatic ``selected_index`` updates from re-firing
2378
- ``on_change``. The control owns its own subviews, so
2379
- ``add_child`` / ``remove_child`` are intentional no-ops.
2361
+ a default accent); the rest are drawn outlined. The control owns
2362
+ its own subviews, so ``insert_child`` / ``remove_child`` are
2363
+ intentional no-ops.
2380
2364
  """
2381
2365
 
2382
2366
  _DEFAULT_ACCENT = "#007AFF"
2383
2367
 
2384
- def create(self, props: Dict[str, Any]) -> Any:
2368
+ def _build(self, props: Dict[str, Any]) -> Any:
2385
2369
  LinearLayout = jclass("android.widget.LinearLayout")
2386
2370
  ll = LinearLayout(_ctx())
2387
2371
  ll.setOrientation(LinearLayout.HORIZONTAL)
2388
- self._state: Dict[int, Dict[str, Any]] = getattr(self, "_state", {})
2389
- self._state[id(ll)] = {
2390
- "segments": [],
2391
- "selected_index": 0,
2392
- "on_change": None,
2393
- "tint_color": None,
2394
- "enabled": True,
2395
- "buttons": [],
2396
- "suppress": False,
2397
- }
2398
- self._apply(ll, props, initial=True)
2399
2372
  return ll
2400
2373
 
2401
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
2402
- self._apply(native_view, changed, initial=False)
2403
-
2404
- def add_child(self, parent: Any, child: Any) -> None:
2405
- # SegmentedControl renders its own segment buttons.
2374
+ def insert_child(self, parent: Any, child: Any, index: int) -> None:
2406
2375
  return
2407
2376
 
2408
2377
  def remove_child(self, parent: Any, child: Any) -> None:
2409
2378
  return
2410
2379
 
2411
- def _default_state(self) -> Dict[str, Any]:
2412
- return {
2413
- "segments": [],
2414
- "selected_index": 0,
2415
- "on_change": None,
2416
- "tint_color": None,
2417
- "enabled": True,
2418
- "buttons": [],
2419
- "suppress": False,
2420
- }
2421
-
2422
2380
  def _apply(self, ll: Any, props: Dict[str, Any], initial: bool) -> None:
2423
- state = self._state.setdefault(id(ll), self._default_state())
2424
-
2425
- if "on_change" in props:
2426
- state["on_change"] = props.get("on_change")
2427
- if "tint_color" in props:
2428
- state["tint_color"] = props.get("tint_color")
2429
- if "enabled" in props:
2430
- # ``enabled`` is only present when ``False``; a removal (``None``)
2431
- # re-enables the control.
2432
- state["enabled"] = props["enabled"] is not False
2381
+ state = _state_of(ll)
2382
+ merged = state.get("props") or props
2433
2383
 
2434
2384
  segments_changed = False
2435
2385
  if "segments" in props or initial:
2436
- raw = props.get("segments")
2386
+ raw = merged.get("segments")
2437
2387
  new_segments = [str(s) for s in raw] if raw else []
2438
- if initial or new_segments != state["segments"]:
2388
+ if initial or new_segments != state.get("segments"):
2439
2389
  state["segments"] = new_segments
2440
2390
  segments_changed = True
2441
2391
 
@@ -2445,7 +2395,7 @@ class SegmentedControlHandler(AndroidViewHandler):
2445
2395
  if segments_changed:
2446
2396
  self._rebuild(ll, state)
2447
2397
  else:
2448
- self._restyle(state)
2398
+ self._restyle(ll, state)
2449
2399
 
2450
2400
  _apply_accessibility(ll, props)
2451
2401
 
@@ -2457,7 +2407,7 @@ class SegmentedControlHandler(AndroidViewHandler):
2457
2407
  state["buttons"] = []
2458
2408
  LL_LP = jclass("android.widget.LinearLayout$LayoutParams")
2459
2409
  restyle = self._restyle
2460
- for index, label in enumerate(state["segments"]):
2410
+ for index, label in enumerate(state.get("segments") or []):
2461
2411
  btn = jclass("android.widget.Button")(_ctx())
2462
2412
  btn.setText(str(label))
2463
2413
  try:
@@ -2466,37 +2416,33 @@ class SegmentedControlHandler(AndroidViewHandler):
2466
2416
  pass
2467
2417
  # Equal-width segments: zero base width + weight 1, full height.
2468
2418
  btn.setLayoutParams(LL_LP(0, LL_LP.MATCH_PARENT, 1.0))
2469
- btn.setEnabled(bool(state["enabled"]))
2419
+ enabled = (state.get("props") or {}).get("enabled") is not False
2420
+ btn.setEnabled(enabled)
2470
2421
 
2471
2422
  class _SegmentClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
2472
- def __init__(self, owner_state: Dict[str, Any], seg_index: int, container: Any) -> None:
2423
+ def __init__(self, seg_index: int) -> None:
2473
2424
  super().__init__()
2474
- self._owner_state = owner_state
2475
2425
  self._seg_index = seg_index
2476
- self._container = container
2477
2426
 
2478
2427
  def onClick(self, view: Any) -> None:
2479
- if self._owner_state.get("suppress") or not self._owner_state.get("enabled", True):
2428
+ st = _state_of(ll)
2429
+ if (st.get("props") or {}).get("enabled") is False:
2480
2430
  return
2481
- self._owner_state["selected_index"] = self._seg_index
2482
- restyle(self._owner_state)
2483
- cb = self._owner_state.get("on_change")
2484
- if cb is not None:
2485
- try:
2486
- cb(self._seg_index)
2487
- except Exception:
2488
- pass
2431
+ st["selected_index"] = self._seg_index
2432
+ restyle(ll, st)
2433
+ _fire(ll, "on_change", self._seg_index)
2489
2434
 
2490
- btn.setOnClickListener(_SegmentClickProxy(state, index, ll))
2435
+ btn.setOnClickListener(_SegmentClickProxy(index))
2491
2436
  ll.addView(btn)
2492
2437
  state["buttons"].append(btn)
2493
- self._restyle(state)
2494
-
2495
- def _restyle(self, state: Dict[str, Any]) -> None:
2496
- accent = state.get("tint_color") or self._DEFAULT_ACCENT
2497
- selected = state.get("selected_index", 0)
2498
- enabled = bool(state.get("enabled", True))
2499
- for i, btn in enumerate(state.get("buttons", [])):
2438
+ self._restyle(ll, state)
2439
+
2440
+ def _restyle(self, ll: Any, state: Dict[str, Any]) -> None:
2441
+ merged = state.get("props") or {}
2442
+ accent = merged.get("tint_color") or self._DEFAULT_ACCENT
2443
+ selected = state.get("selected_index", int(merged.get("selected_index", 0) or 0))
2444
+ enabled = merged.get("enabled") is not False
2445
+ for i, btn in enumerate(state.get("buttons") or []):
2500
2446
  self._style_segment(btn, i == selected, accent, enabled)
2501
2447
 
2502
2448
  def _style_segment(self, btn: Any, selected: bool, accent: Any, enabled: bool) -> None:
@@ -2559,85 +2505,50 @@ class DatePickerHandler(AndroidViewHandler):
2559
2505
  date→time flow (``"datetime"``). Values are parsed / formatted with
2560
2506
  ``java.util.Calendar`` + ``java.text.SimpleDateFormat`` using
2561
2507
  per-mode ISO patterns, and the confirmed value is reported through
2562
- ``on_change``.
2508
+ the ``on_change`` event.
2563
2509
  """
2564
2510
 
2565
2511
  _PATTERNS = {"date": "yyyy-MM-dd", "time": "HH:mm", "datetime": "yyyy-MM-dd'T'HH:mm"}
2566
2512
  _PLACEHOLDERS = {"date": "Select date", "time": "Select time", "datetime": "Select date & time"}
2567
2513
 
2568
- def create(self, props: Dict[str, Any]) -> Any:
2514
+ def _build(self, props: Dict[str, Any]) -> Any:
2569
2515
  btn = jclass("android.widget.Button")(_ctx())
2570
2516
  try:
2571
2517
  btn.setAllCaps(False)
2572
2518
  except Exception:
2573
2519
  pass
2574
- self._state: Dict[int, Dict[str, Any]] = getattr(self, "_state", {})
2575
- self._state[id(btn)] = {
2576
- "value": None,
2577
- "mode": "date",
2578
- "on_change": None,
2579
- "minimum": None,
2580
- "maximum": None,
2581
- "enabled": True,
2582
- }
2583
- self._apply(btn, props, initial=True)
2584
- return btn
2520
+ open_dialog = self._open_dialog
2585
2521
 
2586
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
2587
- self._apply(native_view, changed, initial=False)
2522
+ class _DateTriggerProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
2523
+ def onClick(self, view: Any) -> None:
2524
+ st = _state_of(view)
2525
+ if (st.get("props") or {}).get("enabled") is False:
2526
+ return
2527
+ open_dialog(view, st)
2588
2528
 
2589
- def _apply(self, btn: Any, props: Dict[str, Any], initial: bool) -> None:
2590
- state = self._state.setdefault(
2591
- id(btn),
2592
- {"value": None, "mode": "date", "on_change": None, "minimum": None, "maximum": None, "enabled": True},
2593
- )
2529
+ btn.setOnClickListener(_DateTriggerProxy())
2530
+ return btn
2594
2531
 
2595
- if "mode" in props and props["mode"]:
2596
- state["mode"] = str(props["mode"])
2597
- if "on_change" in props:
2598
- state["on_change"] = props.get("on_change")
2599
- if "minimum" in props:
2600
- state["minimum"] = props.get("minimum")
2601
- if "maximum" in props:
2602
- state["maximum"] = props.get("maximum")
2532
+ def _apply(self, btn: Any, props: Dict[str, Any], initial: bool) -> None:
2533
+ state = _state_of(btn)
2603
2534
  if "enabled" in props:
2604
- state["enabled"] = props["enabled"] is not False
2605
- btn.setEnabled(bool(state["enabled"]))
2606
- if "value" in props or initial:
2607
- state["value"] = props.get("value") if "value" in props else state.get("value")
2608
-
2609
- self._refresh_label(btn, state)
2610
- if initial:
2611
- self._attach_trigger(btn, state)
2612
-
2535
+ btn.setEnabled(props["enabled"] is not False)
2536
+ if "value" in props or "mode" in props or initial:
2537
+ self._refresh_label(btn, state)
2613
2538
  _apply_accessibility(btn, props)
2614
2539
 
2615
2540
  def _refresh_label(self, btn: Any, state: Dict[str, Any]) -> None:
2616
- value = state.get("value")
2541
+ merged = state.get("props") or {}
2542
+ value = merged.get("value")
2617
2543
  if value:
2618
2544
  btn.setText(str(value))
2619
2545
  else:
2620
- btn.setText(self._PLACEHOLDERS.get(state.get("mode", "date"), "Select"))
2621
-
2622
- def _attach_trigger(self, btn: Any, state: Dict[str, Any]) -> None:
2623
- open_dialog = self._open_dialog
2624
-
2625
- class _DateTriggerProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
2626
- def __init__(self, owner_state: Dict[str, Any], trigger: Any) -> None:
2627
- super().__init__()
2628
- self._owner_state = owner_state
2629
- self._trigger = trigger
2630
-
2631
- def onClick(self, view: Any) -> None:
2632
- if not self._owner_state.get("enabled", True):
2633
- return
2634
- open_dialog(self._trigger, self._owner_state)
2635
-
2636
- btn.setOnClickListener(_DateTriggerProxy(state, btn))
2546
+ btn.setText(self._PLACEHOLDERS.get(str(merged.get("mode", "date")), "Select"))
2637
2547
 
2638
2548
  def _open_dialog(self, btn: Any, state: Dict[str, Any]) -> None:
2639
- mode = state.get("mode", "date")
2640
- cal = self._parse_to_calendar(state.get("value"), mode)
2549
+ merged = state.get("props") or {}
2550
+ mode = str(merged.get("mode", "date"))
2551
+ cal = self._parse_to_calendar(merged.get("value"), mode)
2641
2552
  if mode == "time":
2642
2553
  self._open_time(btn, state, cal)
2643
2554
  elif mode == "datetime":
@@ -2705,10 +2616,11 @@ class DatePickerHandler(AndroidViewHandler):
2705
2616
 
2706
2617
  def _apply_min_max(self, dialog: Any, state: Dict[str, Any]) -> None:
2707
2618
  try:
2708
- mode = state.get("mode", "date")
2619
+ merged = state.get("props") or {}
2620
+ mode = str(merged.get("mode", "date"))
2709
2621
  picker = dialog.getDatePicker()
2710
- minimum = state.get("minimum")
2711
- maximum = state.get("maximum")
2622
+ minimum = merged.get("minimum")
2623
+ maximum = merged.get("maximum")
2712
2624
  if minimum:
2713
2625
  picker.setMinDate(self._parse_to_calendar(minimum, mode).getTimeInMillis())
2714
2626
  if maximum:
@@ -2736,22 +2648,18 @@ class DatePickerHandler(AndroidViewHandler):
2736
2648
  return str(fmt.format(cal.getTime()))
2737
2649
 
2738
2650
  def _commit(self, btn: Any, state: Dict[str, Any], cal: Any) -> None:
2739
- mode = state.get("mode", "date")
2651
+ merged = state.get("props") or {}
2652
+ mode = str(merged.get("mode", "date"))
2740
2653
  try:
2741
2654
  iso = self._format_calendar(cal, mode)
2742
2655
  except Exception:
2743
2656
  return
2744
- state["value"] = iso
2657
+ merged["value"] = iso
2745
2658
  try:
2746
2659
  btn.setText(iso)
2747
2660
  except Exception:
2748
2661
  pass
2749
- cb = state.get("on_change")
2750
- if cb is not None:
2751
- try:
2752
- cb(iso)
2753
- except Exception:
2754
- pass
2662
+ _fire(btn, "on_change", iso)
2755
2663
 
2756
2664
 
2757
2665
  # ======================================================================
@@ -2782,7 +2690,6 @@ def register_handlers(registry: Any) -> None:
2782
2690
  registry.register("Pressable", PressableHandler())
2783
2691
  registry.register("StatusBar", StatusBarHandler())
2784
2692
  registry.register("KeyboardAvoidingView", KeyboardAvoidingViewHandler())
2785
- registry.register("VirtualList", VirtualListHandler())
2786
2693
  registry.register("Picker", PickerHandler())
2787
2694
  registry.register("Checkbox", CheckboxHandler())
2788
2695
  registry.register("SegmentedControl", SegmentedControlHandler())
@@ -2809,7 +2716,6 @@ __all__ = [
2809
2716
  "PressableHandler",
2810
2717
  "StatusBarHandler",
2811
2718
  "KeyboardAvoidingViewHandler",
2812
- "VirtualListHandler",
2813
2719
  "PickerHandler",
2814
2720
  "CheckboxHandler",
2815
2721
  "SegmentedControlHandler",