pythonnative 0.17.0__py3-none-any.whl → 0.17.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pythonnative/__init__.py CHANGED
@@ -51,7 +51,7 @@ Example:
51
51
  ```
52
52
  """
53
53
 
54
- __version__ = "0.17.0"
54
+ __version__ = "0.17.1"
55
55
 
56
56
  from . import runtime, sdk
57
57
  from .alerts import Alert
pythonnative/animated.py CHANGED
@@ -62,6 +62,12 @@ from .style import StyleProp, resolve_style
62
62
  _TARGET_FPS = 60.0
63
63
  _FRAME_DT = 1.0 / _TARGET_FPS
64
64
 
65
+ # Upper bound on how much wall-clock time the animation loop will try to
66
+ # catch up on in a single iteration after thread starvation. At 60 fps
67
+ # this is ~333 ms of simulated motion; further drift is dropped to keep
68
+ # the loop responsive.
69
+ _MAX_CATCHUP_FRAMES = 20
70
+
65
71
  _EASINGS: Dict[str, Callable[[float], float]] = {
66
72
  "linear": lambda t: t,
67
73
  "ease_in": lambda t: t * t,
@@ -199,6 +205,20 @@ class _AnimationManager:
199
205
 
200
206
  def _loop(self) -> None:
201
207
  last = time.monotonic()
208
+ # Clamping the per-tick dt is important for numerical stability:
209
+ # an underdamped spring with a 0.3 s step explodes immediately,
210
+ # and on iOS/Android the animation thread can be starved for
211
+ # several frames during render bursts. We integrate physics on a
212
+ # clamped dt (max 2 target frames) and sub-step when wall-clock
213
+ # has advanced more than that, so the perceived motion still
214
+ # tracks real time at most a couple of frames behind. After an
215
+ # extreme starvation (e.g. the app was backgrounded for seconds)
216
+ # we cap the catch-up at ``_MAX_CATCHUP_FRAMES`` worth of
217
+ # physics; any further wall-clock drift is dropped on the floor,
218
+ # which keeps the loop responsive instead of spinning forward
219
+ # through hundreds of substeps.
220
+ max_step = _FRAME_DT * 2.0
221
+ max_catchup = _FRAME_DT * _MAX_CATCHUP_FRAMES
202
222
  while not self._stopped:
203
223
  now = time.monotonic()
204
224
  dt = now - last
@@ -209,13 +229,19 @@ class _AnimationManager:
209
229
  time.sleep(0.05)
210
230
  last = time.monotonic()
211
231
  continue
212
- for anim in active:
213
- try:
214
- finished = anim.advance(dt)
215
- except Exception:
216
- finished = True
217
- if finished:
218
- self.remove(anim)
232
+ remaining = min(dt, max_catchup)
233
+ while remaining > 0.0:
234
+ step = remaining if remaining <= max_step else max_step
235
+ remaining -= step
236
+ for anim in active:
237
+ if getattr(anim, "_completed", False):
238
+ continue
239
+ try:
240
+ finished = anim.advance(step)
241
+ except Exception:
242
+ finished = True
243
+ if finished:
244
+ self.remove(anim)
219
245
  time.sleep(_FRAME_DT)
220
246
 
221
247
 
pythonnative/layout.py CHANGED
@@ -401,7 +401,17 @@ class LayoutNode:
401
401
  height: Computed height in points.
402
402
  """
403
403
 
404
- __slots__ = ("style", "children", "measure", "user_data", "x", "y", "width", "height")
404
+ __slots__ = (
405
+ "style",
406
+ "children",
407
+ "measure",
408
+ "user_data",
409
+ "x",
410
+ "y",
411
+ "width",
412
+ "height",
413
+ "_pn_scroll_axis",
414
+ )
405
415
 
406
416
  def __init__(
407
417
  self,
@@ -418,6 +428,14 @@ class LayoutNode:
418
428
  self.y: float = 0.0
419
429
  self.width: float = 0.0
420
430
  self.height: float = 0.0
431
+ # ``"x"``/``"y"`` for scroll containers; ``None`` for everything
432
+ # else. Consumed by ``_measure_container`` to clamp the node's
433
+ # own main-axis size to the parent's available space while still
434
+ # measuring children unbounded on the scroll axis (which is what
435
+ # makes the native ``UIScrollView`` / Android ``ScrollView``
436
+ # actually scroll). The reconciler stamps this when building the
437
+ # layout tree for ``ScrollView`` elements.
438
+ self._pn_scroll_axis: Optional[str] = None
421
439
 
422
440
  def __repr__(self) -> str:
423
441
  return (
@@ -576,6 +594,22 @@ def _measure_container(
576
594
 
577
595
  width = explicit_w if explicit_w is not None else (used_w + pad_x)
578
596
  height = explicit_h if explicit_h is not None else (used_h + pad_y)
597
+
598
+ # Scroll containers: clamp the container's own main-axis size to the
599
+ # parent's available space when no explicit size was provided. The
600
+ # children are still measured against an unbounded main-axis (handled
601
+ # via the wrapper inserted in ``Reconciler._build_layout_tree``) so the
602
+ # overflow becomes the scrollable region. Without this clamp, the
603
+ # container would grow to fit its content and there would be no
604
+ # overflow for the native ScrollView to scroll. Skipped when the
605
+ # parent is itself unbounded, so nested scroll views still fall back
606
+ # to natural sizing (the inner scroll is unscrollable in that case,
607
+ # which matches the behavior in React Native).
608
+ scroll_axis = getattr(node, "_pn_scroll_axis", None)
609
+ if scroll_axis == "y" and explicit_h is None and math.isfinite(avail_h):
610
+ height = avail_h
611
+ elif scroll_axis == "x" and explicit_w is None and math.isfinite(avail_w):
612
+ width = avail_w
579
613
  return width, height
580
614
 
581
615
 
@@ -35,6 +35,7 @@ _pn_text_input_suppress_callbacks: dict = {}
35
35
  _pn_view_visual_props: dict = {}
36
36
  _DRAWABLE_STYLE_KEYS = ("background_color", "border_radius", "border_width", "border_color")
37
37
 
38
+
38
39
  # ======================================================================
39
40
  # Shared helpers
40
41
  # ======================================================================
@@ -509,18 +510,27 @@ class ButtonHandler(AndroidViewHandler):
509
510
  class ScrollViewHandler(AndroidViewHandler):
510
511
  """Scroll container — wraps a single child whose height is unbounded.
511
512
 
513
+ Uses ``androidx.core.widget.NestedScrollView`` rather than the
514
+ framework ``android.widget.ScrollView`` because the framework
515
+ ScrollView always intercepts vertical gestures, even when it has
516
+ no overflow. That breaks the common case of nesting a small
517
+ fixed-height scroll view inside a screen-level scroll view (the
518
+ outer steals every gesture and the inner never scrolls).
519
+ ``NestedScrollView`` implements the standard
520
+ ``NestedScrollingParent2`` / ``NestedScrollingChild2`` protocol so
521
+ the outer cooperates with any nested scroll, only consuming
522
+ leftover scroll when its child reaches its limit.
523
+
512
524
  When a ``refresh_control`` prop is provided, wraps the scroll in
513
525
  a `SwipeRefreshLayout` and forwards the on-refresh callback.
514
526
  """
515
527
 
516
528
  def create(self, props: Dict[str, Any]) -> Any:
517
- sv = jclass("android.widget.ScrollView")(_ctx())
529
+ try:
530
+ sv = jclass("androidx.core.widget.NestedScrollView")(_ctx())
531
+ except Exception:
532
+ sv = jclass("android.widget.ScrollView")(_ctx())
518
533
  _apply_common_visual(sv, props)
519
- # Wrap the inner ScrollView in a SwipeRefreshLayout when
520
- # ``refresh_control`` is asked for. Implementing this cleanly
521
- # would require returning a different parent; for v1, we
522
- # attach the listener via a wrapper that we expose to
523
- # add_child callers below.
524
534
  return sv
525
535
 
526
536
  def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
@@ -536,6 +546,17 @@ class ScrollViewHandler(AndroidViewHandler):
536
546
  class TextInputHandler(AndroidViewHandler):
537
547
  def create(self, props: Dict[str, Any]) -> Any:
538
548
  et = jclass("android.widget.EditText")(_ctx())
549
+ # Default to single-line so pressing Enter triggers IME_ACTION_DONE
550
+ # (submit / dismiss) instead of inserting a newline. The
551
+ # ``_apply`` path will override this if ``multiline=True`` is
552
+ # set in props. Without this, every TextInput without an
553
+ # explicit ``multiline`` value falls back to Android's
554
+ # multi-line default and Enter inserts ``\n``.
555
+ try:
556
+ if not props.get("multiline"):
557
+ et.setSingleLine(True)
558
+ except Exception:
559
+ pass
539
560
  self._apply(et, props)
540
561
  return et
541
562
 
@@ -661,7 +682,73 @@ class TextInputHandler(AndroidViewHandler):
661
682
  et.addTextChangedListener(watcher)
662
683
  else:
663
684
  _pn_text_input_callbacks[key] = None
664
- if "on_submit" in props and props["on_submit"] is not None:
685
+ if "return_key_type" in props and props["return_key_type"] is not None:
686
+ # Map the cross-platform ``return_key_type`` to Android's
687
+ # ``EditorInfo.IME_ACTION_*`` so the soft keyboard renders the
688
+ # right action key (Done / Go / Search / Send / Next), which
689
+ # is what triggers the ``OnEditorActionListener`` below. iOS
690
+ # has a richer set (Google / Yahoo / Join / Route) with no
691
+ # direct AOSP equivalents — fall back to ``IME_ACTION_DONE``
692
+ # for those so the keyboard at least dismisses cleanly.
693
+ try:
694
+ EditorInfo = jclass("android.view.inputmethod.EditorInfo")
695
+ rkt_mapping = {
696
+ "default": EditorInfo.IME_ACTION_UNSPECIFIED,
697
+ "go": EditorInfo.IME_ACTION_GO,
698
+ "google": EditorInfo.IME_ACTION_DONE,
699
+ "join": EditorInfo.IME_ACTION_DONE,
700
+ "next": EditorInfo.IME_ACTION_NEXT,
701
+ "route": EditorInfo.IME_ACTION_DONE,
702
+ "search": EditorInfo.IME_ACTION_SEARCH,
703
+ "send": EditorInfo.IME_ACTION_SEND,
704
+ "yahoo": EditorInfo.IME_ACTION_DONE,
705
+ "done": EditorInfo.IME_ACTION_DONE,
706
+ }
707
+ action = rkt_mapping.get(props["return_key_type"], EditorInfo.IME_ACTION_DONE)
708
+ et.setImeOptions(action)
709
+ except Exception:
710
+ pass
711
+ if not props.get("multiline"):
712
+ # Always install an editor-action listener on single-line
713
+ # inputs so pressing the IME action key (Done / Go / etc.)
714
+ # *or* the Enter key on a single-line ``EditText`` dismisses
715
+ # the soft keyboard. Without this the keyboard stays up after
716
+ # ``inputText`` + ``pressKey: Enter`` in Maestro and on smaller
717
+ # screens hides the rest of the layout — and matches React
718
+ # Native's default Android behavior. ``on_submit`` (if any) is
719
+ # fired before dismissal so the callback sees the final text.
720
+ try:
721
+ on_submit_cb = props.get("on_submit")
722
+ EditorListener = jclass("android.widget.TextView$OnEditorActionListener")
723
+ Context = jclass("android.content.Context")
724
+
725
+ class SubmitProxy(dynamic_proxy(EditorListener)):
726
+ def __init__(self, callback: Optional[Callable[[str], None]]) -> None:
727
+ super().__init__()
728
+ self.callback = callback
729
+
730
+ def onEditorAction(self, view: Any, action_id: int, event: Any) -> bool:
731
+ if self.callback is not None:
732
+ try:
733
+ self.callback(str(view.getText()))
734
+ except Exception:
735
+ pass
736
+ try:
737
+ view.clearFocus()
738
+ ctx = view.getContext()
739
+ imm = ctx.getSystemService(Context.INPUT_METHOD_SERVICE)
740
+ imm.hideSoftInputFromWindow(view.getWindowToken(), 0)
741
+ except Exception:
742
+ pass
743
+ return True
744
+
745
+ et.setOnEditorActionListener(SubmitProxy(on_submit_cb))
746
+ except Exception:
747
+ pass
748
+ elif "on_submit" in props and props["on_submit"] is not None:
749
+ # Multi-line inputs: only install the listener when an explicit
750
+ # ``on_submit`` is provided. Enter inserts a newline by default
751
+ # on multi-line ``EditText`` and we don't want to override that.
665
752
  try:
666
753
  cb = props["on_submit"]
667
754
  EditorListener = jclass("android.widget.TextView$OnEditorActionListener")
@@ -54,6 +54,19 @@ NSObject = ObjCClass("NSObject")
54
54
  UIColor = ObjCClass("UIColor")
55
55
  UIFont = ObjCClass("UIFont")
56
56
 
57
+ # Declare ``superview`` as a property on UIView so rubicon-objc returns
58
+ # the actual UIView (or None) on attribute access, instead of an
59
+ # ObjCBoundMethod. Without this, accessing ``view.superview`` returns a
60
+ # method handle and the entire codepath that updates UIScrollView's
61
+ # ``contentSize`` would raise silently. See rubicon-objc docs on
62
+ # ``declare_property`` for why some ``@property`` declarations aren't
63
+ # auto-detected by the runtime introspection.
64
+ try:
65
+ _UIView = ObjCClass("UIView")
66
+ _UIView.declare_property("superview")
67
+ except Exception:
68
+ pass
69
+
57
70
 
58
71
  def _objc_ptr(obj: Any) -> Optional[int]:
59
72
  """Return the raw Objective-C pointer for a Rubicon object."""
@@ -131,6 +144,8 @@ _SEL_UTF8STRING = _sel_reg(b"UTF8String")
131
144
  _SEL_ADD_TARGET_ACTION_EVENTS = _sel_reg(b"addTarget:action:forControlEvents:")
132
145
  _SEL_ON_EDIT = _sel_reg(b"onEdit:")
133
146
  _SEL_ON_SUBMIT = _sel_reg(b"onSubmit:")
147
+ _SEL_RESIGN_FIRST_RESPONDER = _sel_reg(b"resignFirstResponder")
148
+ _SEL_TEXT_FIELD_SHOULD_RETURN = _sel_reg(b"textFieldShouldReturn:")
134
149
 
135
150
  _NS_OBJECT_CLS = _get_cls(b"NSObject")
136
151
 
@@ -507,12 +522,19 @@ class IOSViewHandler(ViewHandler):
507
522
  pass
508
523
  try:
509
524
  parent = native_view.superview
510
- set_content_size = getattr(parent, "setContentSize_", None)
511
- if set_content_size is not None:
525
+ parent_cls = ""
526
+ try:
527
+ parent_cls = str(parent.objc_class.name) if parent is not None else ""
528
+ except Exception:
529
+ parent_cls = ""
530
+ # Expand the parent UIScrollView's contentSize whenever a
531
+ # child's frame extends past the visible bounds, so the
532
+ # scroll view can actually scroll to reveal it.
533
+ if "UIScrollView" in parent_cls:
512
534
  bounds = parent.bounds
513
535
  content_w = max(float(bounds.size.width), frame_x + frame_w)
514
536
  content_h = max(float(bounds.size.height), frame_y + frame_h)
515
- set_content_size((content_w, content_h))
537
+ parent.setContentSize_((content_w, content_h))
516
538
  except Exception:
517
539
  pass
518
540
  except Exception:
@@ -651,6 +673,7 @@ _pn_tf_raw_target_map: dict = {}
651
673
  _PN_TEXTFIELD_TARGET_CLS: Optional[int] = None
652
674
  _textfield_edit_imp_ref: Any = None
653
675
  _textfield_submit_imp_ref: Any = None
676
+ _textfield_should_return_imp_ref: Any = None
654
677
 
655
678
 
656
679
  def _textfield_text(sender_ptr: int) -> str:
@@ -694,8 +717,27 @@ def _textfield_on_submit_imp(self_ptr: int, _cmd: int, sender_ptr: int) -> None:
694
717
  pass
695
718
 
696
719
 
720
+ def _textfield_should_return_imp(self_ptr: int, _cmd: int, tf_ptr: int) -> bool:
721
+ """``UITextFieldDelegate.textFieldShouldReturn:`` — dismiss the keyboard.
722
+
723
+ iOS doesn't dismiss the keyboard on Return by default; the standard
724
+ pattern is for the delegate to call ``resignFirstResponder`` and
725
+ return ``YES``. Matching that here brings PythonNative's
726
+ ``TextInput`` in line with React Native's default behavior and with
727
+ what users expect from a ``return_key_type="done"`` style.
728
+ """
729
+ try:
730
+ _objc_msgSend.restype = None
731
+ _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
732
+ _objc_msgSend(_ct.c_void_p(int(tf_ptr or 0)), _SEL_RESIGN_FIRST_RESPONDER)
733
+ except Exception:
734
+ pass
735
+ return True
736
+
737
+
697
738
  def _ensure_textfield_target_class() -> Optional[int]:
698
- global _PN_TEXTFIELD_TARGET_CLS, _textfield_edit_imp_ref, _textfield_submit_imp_ref
739
+ global _PN_TEXTFIELD_TARGET_CLS
740
+ global _textfield_edit_imp_ref, _textfield_submit_imp_ref, _textfield_should_return_imp_ref
699
741
  if _PN_TEXTFIELD_TARGET_CLS is not None:
700
742
  return _PN_TEXTFIELD_TARGET_CLS
701
743
  existing = _get_cls(b"PNTextFieldActionTarget")
@@ -706,10 +748,18 @@ def _ensure_textfield_target_class() -> Optional[int]:
706
748
  if not cls:
707
749
  return None
708
750
  action_type = _ct.CFUNCTYPE(None, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
751
+ bool_type = _ct.CFUNCTYPE(_ct.c_bool, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
709
752
  _textfield_edit_imp_ref = action_type(_textfield_on_edit_imp)
710
753
  _textfield_submit_imp_ref = action_type(_textfield_on_submit_imp)
754
+ _textfield_should_return_imp_ref = bool_type(_textfield_should_return_imp)
711
755
  _add_method(cls, _SEL_ON_EDIT, _ct.cast(_textfield_edit_imp_ref, _ct.c_void_p), b"v@:@")
712
756
  _add_method(cls, _SEL_ON_SUBMIT, _ct.cast(_textfield_submit_imp_ref, _ct.c_void_p), b"v@:@")
757
+ _add_method(
758
+ cls,
759
+ _SEL_TEXT_FIELD_SHOULD_RETURN,
760
+ _ct.cast(_textfield_should_return_imp_ref, _ct.c_void_p),
761
+ b"c@:@",
762
+ )
713
763
  _reg_cls(cls)
714
764
  _PN_TEXTFIELD_TARGET_CLS = int(cls)
715
765
  return _PN_TEXTFIELD_TARGET_CLS
@@ -760,6 +810,16 @@ def _attach_textfield_raw_target(tf: Any, props: Dict[str, Any]) -> None:
760
810
  _SEL_ON_SUBMIT,
761
811
  1 << 6,
762
812
  )
813
+ # Wire the same object as the UITextFieldDelegate so its
814
+ # ``textFieldShouldReturn:`` runs and resigns first responder
815
+ # — without this iOS keeps the keyboard up after Return.
816
+ _objc_msgSend.restype = None
817
+ _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_void_p]
818
+ _objc_msgSend(
819
+ _ct.c_void_p(tf_ptr),
820
+ _SEL_SET_DELEGATE,
821
+ _ct.c_void_p(target_ptr),
822
+ )
763
823
  if "on_change" in props:
764
824
  _pn_tf_change_callback_map[int(target_ptr)] = props["on_change"]
765
825
  if "on_submit" in props:
@@ -1313,8 +1373,10 @@ class TextInputHandler(IOSViewHandler):
1313
1373
  except Exception:
1314
1374
  pass
1315
1375
  self._common_apply(tf, props)
1316
- if "on_change" in props or "on_submit" in props:
1317
- _attach_textfield_raw_target(tf, props)
1376
+ # Always wire the action target even without ``on_change`` /
1377
+ # ``on_submit`` we want the textfield's delegate set so Return
1378
+ # dismisses the keyboard (textFieldShouldReturn:).
1379
+ _attach_textfield_raw_target(tf, props)
1318
1380
 
1319
1381
  def _apply_textview(self, tv: Any, props: Dict[str, Any]) -> None:
1320
1382
  if "value" in props:
@@ -64,7 +64,14 @@ from .hooks import (
64
64
  # Focus context
65
65
  # ======================================================================
66
66
 
67
- _FocusContext = create_context(False)
67
+ # Defaults to True: components rendered outside any declarative
68
+ # navigator (e.g. the root component of a screen pushed via the host's
69
+ # native nav stack) are by definition focused — the host's own
70
+ # ``on_resume`` / ``on_pause`` lifecycle drives the focus state for
71
+ # those. Declarative navigators override this provider on the active
72
+ # subtree (always True today; reserved for future inactive-screen
73
+ # rendering).
74
+ _FocusContext = create_context(True)
68
75
 
69
76
  # ======================================================================
70
77
  # Data structures
@@ -925,6 +932,15 @@ def use_focus_effect(effect: Callable, deps: Optional[list] = None) -> None:
925
932
  one. Useful for starting subscriptions, refreshing data, or
926
933
  pausing animations on the inactive screen.
927
934
 
935
+ The focus state combines two sources of truth:
936
+
937
+ - The screen host's lifecycle (``on_resume`` / ``on_pause``), so
938
+ pushing a sibling onto the navigation stack blurs this screen and
939
+ popping back to it refocuses it.
940
+ - The in-tree ``_FocusContext`` value, which lets declarative
941
+ navigators (e.g. tabs, drawers) mark only the active subtree as
942
+ focused even when both screens are part of the same host.
943
+
928
944
  Args:
929
945
  effect: A zero-arg callable invoked when focused. Optionally
930
946
  returns a cleanup callable.
@@ -941,7 +957,46 @@ def use_focus_effect(effect: Callable, deps: Optional[list] = None) -> None:
941
957
  return pn.Text("Home")
942
958
  ```
943
959
  """
944
- is_focused = use_context(_FocusContext)
960
+ context_focused = use_context(_FocusContext)
961
+
962
+ nav = use_context(_NavigationContext)
963
+ # Walk the navigator parent chain to find the screen host. Declarative
964
+ # navigators (Stack/Tab/Drawer) wrap the host's ``NavigationHandle``
965
+ # as ``_parent``; only the host-level handle has ``_host``.
966
+ host = None
967
+ cursor = nav
968
+ while cursor is not None:
969
+ candidate = getattr(cursor, "_host", None)
970
+ if candidate is not None:
971
+ host = candidate
972
+ break
973
+ cursor = getattr(cursor, "_parent", None)
974
+ initial_host_focus = bool(getattr(host, "_is_focused", True)) if host is not None else True
975
+ host_focused, set_host_focused = use_state(initial_host_focus)
976
+
977
+ def subscribe_to_host_focus() -> Any:
978
+ if host is None:
979
+ return None
980
+ subscribers = getattr(host, "_focus_subscribers", None)
981
+ if subscribers is None:
982
+ return None
983
+ subscribers.append(set_host_focused)
984
+ # The host may have changed focus state between the initial
985
+ # ``use_state`` call and this effect running (e.g. mid-render
986
+ # lifecycle event); resync once to avoid stale state.
987
+ set_host_focused(bool(host._is_focused))
988
+
989
+ def cleanup() -> None:
990
+ try:
991
+ subscribers.remove(set_host_focused)
992
+ except ValueError:
993
+ pass
994
+
995
+ return cleanup
996
+
997
+ use_effect(subscribe_to_host_focus, [])
998
+
999
+ is_focused = context_focused and host_focused
945
1000
  all_deps = [is_focused] + (list(deps) if deps is not None else [])
946
1001
 
947
1002
  def wrapped_effect() -> Any:
@@ -803,8 +803,37 @@ class Reconciler:
803
803
  # root in the screen.
804
804
  for child in layout_root.children:
805
805
  self._apply_layout(child, 0.0, 0.0)
806
+ # Lay out the children of every visible ``Modal`` as a fresh
807
+ # subtree sized to the viewport. Modals are excluded from the
808
+ # main layout tree (their content lives in a separately
809
+ # presented native container) so without this pass the
810
+ # children's frames never get computed and the modal renders
811
+ # blank.
812
+ self._layout_visible_modals(self._tree, viewport_w, viewport_h)
806
813
  self._log_viewport(f"_run_layout: pass#{layout_pass} done")
807
814
 
815
+ def _layout_visible_modals(
816
+ self,
817
+ vnode: VNode,
818
+ viewport_w: float,
819
+ viewport_h: float,
820
+ ) -> None:
821
+ element = vnode.element
822
+ if isinstance(element.type, str) and element.type == "Modal":
823
+ if element.props.get("visible") and vnode.children:
824
+ child_layout = self._build_layout_tree(vnode.children[0])
825
+ if child_layout is not None:
826
+ viewport = LayoutNode(
827
+ style={"width": viewport_w, "height": viewport_h},
828
+ children=[child_layout],
829
+ )
830
+ calculate_layout(viewport, viewport_w, viewport_h)
831
+ for c in viewport.children:
832
+ self._apply_layout(c, 0.0, 0.0)
833
+ return
834
+ for child in vnode.children:
835
+ self._layout_visible_modals(child, viewport_w, viewport_h)
836
+
808
837
  def _build_layout_tree(self, vnode: VNode) -> Optional[LayoutNode]:
809
838
  """Walk `vnode` and build a parallel `LayoutNode` tree of native nodes.
810
839
 
@@ -829,6 +858,15 @@ class Reconciler:
829
858
 
830
859
  style = extract_layout_style(element.props)
831
860
  layout = LayoutNode(style=style, user_data=vnode)
861
+ if element.type == "ScrollView":
862
+ # Mark the scroll axis so the layout engine clamps the
863
+ # container's own main-axis size to its parent's available
864
+ # space (otherwise the container grows to fit its content
865
+ # and there is no overflow for the native ScrollView to
866
+ # actually scroll). The children are still wrapped below so
867
+ # they see an unbounded main axis when measured.
868
+ scroll_axis = element.props.get("scroll_axis", "vertical")
869
+ layout._pn_scroll_axis = "x" if scroll_axis == "horizontal" else "y"
832
870
  self._log_viewport(
833
871
  f"_build_layout_tree: node type={element.type!r} view={self._obj_debug(vnode.native_view)} "
834
872
  f"style={style!r} children={len(vnode.children)}"
pythonnative/screen.py CHANGED
@@ -165,6 +165,25 @@ def _init_host_common(host: Any, component_path: str, component_func: Any) -> No
165
165
  host._hot_reload_manifest_path = None
166
166
  host._hot_reload_last_version = None
167
167
  host._layout_listener = None # retained on Android to prevent GC
168
+ # Focus state — drives ``use_focus_effect``. Starts focused because
169
+ # a host is only created when the screen is being presented; the
170
+ # platform lifecycle hooks (``on_resume`` / ``on_pause``) flip this
171
+ # when the user navigates to / from another screen.
172
+ host._is_focused = True
173
+ host._focus_subscribers = []
174
+
175
+
176
+ def _set_host_focused(host: Any, focused: bool) -> None:
177
+ """Update ``host._is_focused`` and notify ``use_focus_effect`` subscribers."""
178
+ if getattr(host, "_is_focused", True) == focused:
179
+ return
180
+ host._is_focused = focused
181
+ subscribers = list(getattr(host, "_focus_subscribers", ()) or ())
182
+ for callback in subscribers:
183
+ try:
184
+ callback(focused)
185
+ except Exception:
186
+ pass
168
187
 
169
188
 
170
189
  def _push_viewport_size(host: Any, width: float, height: float) -> None:
@@ -232,6 +251,23 @@ def _flush_scheduled_renders(hosts: Sequence[Any]) -> None:
232
251
  def _on_create(host: Any) -> None:
233
252
  from .hooks import NavigationHandle, Provider, _NavigationContext
234
253
 
254
+ # ``on_create`` is idempotent across native-view recreations. On
255
+ # Android the FragmentManager destroys and recreates a screen's
256
+ # view every time the user pops back to it, and the platform
257
+ # template calls ``screen.on_create()`` again from
258
+ # ``onViewCreated`` — but the Python screen object (and therefore
259
+ # the reconciler, hook state, focus subscribers, etc.) persists
260
+ # across that. Re-running the full mount path here would reset
261
+ # use_state, clobber use_focus_effect subscriptions, and break
262
+ # navigation handles held by existing components, which is why
263
+ # the focus counter never advanced past ``1`` before this guard.
264
+ # If we're already mounted, just re-attach the existing root view
265
+ # to the (newly created) native container — ``on_resume`` will
266
+ # fire the focus subscribers separately.
267
+ if host._reconciler is not None and host._root_native_view is not None:
268
+ host._attach_root(host._root_native_view)
269
+ return
270
+
235
271
  host._nav_handle = NavigationHandle(host)
236
272
  host._reconciler = _new_reconciler(host)
237
273
 
@@ -711,7 +747,7 @@ if IS_ANDROID:
711
747
  pass
712
748
 
713
749
  def on_resume(self) -> None:
714
- pass
750
+ _set_host_focused(self, True)
715
751
 
716
752
  def on_layout(self) -> None:
717
753
  # Android pushes viewport changes through the
@@ -721,7 +757,7 @@ if IS_ANDROID:
721
757
  pass
722
758
 
723
759
  def on_pause(self) -> None:
724
- pass
760
+ _set_host_focused(self, False)
725
761
 
726
762
  def on_stop(self) -> None:
727
763
  pass
@@ -796,6 +832,18 @@ if IS_ANDROID:
796
832
  container.removeAllViews()
797
833
  except Exception:
798
834
  pass
835
+ # When the user pops back to a previously mounted screen,
836
+ # ``native_view`` is the root from the prior mount and may
837
+ # still be parented under the old (destroyed) FrameLayout.
838
+ # ViewGroup.addView() throws if a view already has a
839
+ # parent, so detach it from the old one before re-attaching
840
+ # to the freshly created container.
841
+ try:
842
+ old_parent = native_view.getParent()
843
+ if old_parent is not None:
844
+ old_parent.removeView(native_view)
845
+ except Exception:
846
+ pass
799
847
  LayoutParams = jclass("android.view.ViewGroup$LayoutParams")
800
848
  lp = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
801
849
  container.addView(native_view, lp)
@@ -1052,7 +1100,7 @@ else:
1052
1100
  pass
1053
1101
 
1054
1102
  def on_pause(self) -> None:
1055
- pass
1103
+ _set_host_focused(self, False)
1056
1104
 
1057
1105
  def on_stop(self) -> None:
1058
1106
  pass
@@ -1267,6 +1315,7 @@ else:
1267
1315
  # ``viewDidAppear`` always follows ``viewDidLayoutSubviews``,
1268
1316
  # but trigger one extra sync here for safety in case a
1269
1317
  # template overrides the layout call without forwarding.
1318
+ _set_host_focused(self, True)
1270
1319
  if self._root_native_view is None:
1271
1320
  _log_pn("on_resume: no root_native_view yet, skipping")
1272
1321
  return
pythonnative/storage.py CHANGED
@@ -52,18 +52,32 @@ T = TypeVar("T")
52
52
  _DEFAULTS_SUITE = "pn_async_storage"
53
53
 
54
54
 
55
- def _ios_set(key: str, value: str) -> None:
56
- from rubicon.objc import ObjCClass
55
+ # Cache the NSUserDefaults class lookup. rubicon.objc's
56
+ # ``ObjCClass("NSUserDefaults")`` walks the ObjC runtime metadata which
57
+ # takes hundreds of milliseconds on first call; resolving once at module
58
+ # import keeps every later get/set/delete in the sub-millisecond range.
59
+ _ios_defaults: Any = None
60
+
61
+
62
+ def _ios_get_defaults() -> Any:
63
+ global _ios_defaults
64
+ if _ios_defaults is None:
65
+ from rubicon.objc import ObjCClass
66
+
67
+ _ios_defaults = ObjCClass("NSUserDefaults").standardUserDefaults
68
+ return _ios_defaults
69
+
57
70
 
58
- defaults = ObjCClass("NSUserDefaults").standardUserDefaults
71
+ def _ios_set(key: str, value: str) -> None:
72
+ defaults = _ios_get_defaults()
59
73
  defaults.setObject_forKey_(value, key)
60
- defaults.synchronize()
74
+ # ``synchronize()`` is documented as unnecessary on modern iOS and
75
+ # can block for seconds while it flushes to disk on a busy system;
76
+ # NSUserDefaults already coalesces writes asynchronously.
61
77
 
62
78
 
63
79
  def _ios_get(key: str) -> Optional[str]:
64
- from rubicon.objc import ObjCClass
65
-
66
- defaults = ObjCClass("NSUserDefaults").standardUserDefaults
80
+ defaults = _ios_get_defaults()
67
81
  val = defaults.stringForKey_(key)
68
82
  if val is None:
69
83
  return None
@@ -74,17 +88,12 @@ def _ios_get(key: str) -> Optional[str]:
74
88
 
75
89
 
76
90
  def _ios_delete(key: str) -> None:
77
- from rubicon.objc import ObjCClass
78
-
79
- defaults = ObjCClass("NSUserDefaults").standardUserDefaults
91
+ defaults = _ios_get_defaults()
80
92
  defaults.removeObjectForKey_(key)
81
- defaults.synchronize()
82
93
 
83
94
 
84
95
  def _ios_all_keys() -> List[str]:
85
- from rubicon.objc import ObjCClass
86
-
87
- defaults = ObjCClass("NSUserDefaults").standardUserDefaults
96
+ defaults = _ios_get_defaults()
88
97
  rep = defaults.dictionaryRepresentation()
89
98
  if rep is None:
90
99
  return []
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pythonnative
3
- Version: 0.17.0
3
+ Version: 0.17.1
4
4
  Summary: Cross-platform native UI toolkit for Android and iOS
5
5
  Author: Owen Carey
6
6
  License: MIT License
@@ -1,20 +1,20 @@
1
- pythonnative/__init__.py,sha256=pGDb4NIq65W-DxqsxhdTdjNdc3rbRhx1_-H7TJPInpE,7157
1
+ pythonnative/__init__.py,sha256=HL2betn3DUAr0_LR_WSTEObS_KigtwmuQyZXEwR5Jm4,7157
2
2
  pythonnative/_ios_log.py,sha256=Oi7V28VxcVoZyrpAirvLeEmUW18McqnU87V4d37Zzlw,2582
3
3
  pythonnative/alerts.py,sha256=mIANysFlaHwL5EqKnvNcyiJN9rGiZi9XDrD9Jpz1RFM,9340
4
- pythonnative/animated.py,sha256=-uQXZyjSC4vDk0B_fQSXoLdk7Uzqks7IP-MYleuYVd8,23008
4
+ pythonnative/animated.py,sha256=bAgG_sGODAdl5eVQjX_vryaKI1hyjI92QH1PNx7Tqyg,24491
5
5
  pythonnative/components.py,sha256=P0BJuTXDHqgNi9rWPpBe2T3F79CcnQmGTR99VeH9FU8,51127
6
6
  pythonnative/element.py,sha256=W9varJj0Cl9HpckL8BcsC1u4ryUQOPVMrvetro4ilAE,2725
7
7
  pythonnative/hooks.py,sha256=Zt1AiK5kOtHJIhk8_FetAKKt-8n21UDig1_DC-XcUow,37948
8
8
  pythonnative/hot_reload.py,sha256=j7z2c7o2Hdoyd-p4nQY15LTW7CBH_1z0TSAzLCer-aA,25036
9
- pythonnative/layout.py,sha256=-Wrvj4eHtQXqa9kn26ktKLAZVH4VMc_WuoCxV67UnQw,34994
10
- pythonnative/navigation.py,sha256=BtmdAKHocAF3ub7PewkGJYn8Rxy3GdV3serYnU0TMWk,33282
9
+ pythonnative/layout.py,sha256=siU7PeVOjL_G1f-1q31ssrKWlxz2UBmvMwNXtmqyOxw,36586
10
+ pythonnative/navigation.py,sha256=skMZFh3AXEJgTU6qQpATFN1Lp4GB94K_ACNNXZjEOEE,35579
11
11
  pythonnative/net.py,sha256=UI-39-BmGYWLE_vMAFoAbkzWZvfhIFj9yX_gexp1loc,8091
12
12
  pythonnative/platform.py,sha256=jEya1KTDc3WfwpmrQkk3DIFyt7CWO4Vc3pej_wDiSR8,4629
13
13
  pythonnative/platform_metrics.py,sha256=m2u8M8x52n5THNsYdspcaI9mlWWMbfSJWai1svjD0NM,8976
14
- pythonnative/reconciler.py,sha256=E4azBejkUe2hUYTIu7yyPJ-UscPgX0SHO9UswTbNvS0,42056
14
+ pythonnative/reconciler.py,sha256=dJUSXX65Ckdj5iSmpPtXYnNk0pfg54aesmSlAV0vLbM,43996
15
15
  pythonnative/runtime.py,sha256=wQnMMG7ibDGR4zFtwSbh7pR6o5nRe1R_vyPYI0dVcfk,17116
16
- pythonnative/screen.py,sha256=-8m_L5QQxpMrX80j4XR2IjW2UtSKf9UCeggOHOK-RjY,55123
17
- pythonnative/storage.py,sha256=eQ4jepvzXpkVVfpQOarz88kJCPVJeYL4Q-vIAwcxmgM,11604
16
+ pythonnative/screen.py,sha256=hcOjs1ZP2jeag55NkbyzOCXiae8cUs5IUhSDwHKIFLQ,57666
17
+ pythonnative/storage.py,sha256=hLgSI44ADq6wj29eeYbHaAUNpxYPzJ2ZLn1L7AHkPZY,12010
18
18
  pythonnative/style.py,sha256=yDJv-G6iZIgrscpc-IZS_cbEQvY2o7R02PTQZ4BV8RA,15162
19
19
  pythonnative/utils.py,sha256=pQSxa3QW06_Y9JzBSnK0g0eMV1VhXww8Qym6HPOGzgM,6064
20
20
  pythonnative/cli/__init__.py,sha256=NM1psvKe8jT0vzp8Ak4MMoygZz4P_msk5g-YEsY8xLk,232
@@ -25,9 +25,9 @@ pythonnative/native_modules/file_system.py,sha256=hl-52B6cirOzY6IDDxCjgSGlteiFgy
25
25
  pythonnative/native_modules/location.py,sha256=iWfxNtnaC79rl_IP0foHBm2SeX9CgfpHxZ8oPNTBQjA,7609
26
26
  pythonnative/native_modules/notifications.py,sha256=WVtzdimc_aGfnxU6syCFPkjHF9YRRc97UVK3--TBwoU,7115
27
27
  pythonnative/native_views/__init__.py,sha256=yP0IdOmQ3Cco4kJKBcgkMBUPX30ditM_Sp6ZotKAen4,11820
28
- pythonnative/native_views/android.py,sha256=K7_8o3bWbJoSL4xLYXsAz2wIw6FKD-p4z4jS3uLVYiY,69984
28
+ pythonnative/native_views/android.py,sha256=o7HaQSP4F_xNgjJVGclsjFIEGthe-fDeWGdXJuX8HjQ,74645
29
29
  pythonnative/native_views/base.py,sha256=LXDQYRM8wJa3MmGPwslkVyvlu36_s1_J6aO7wwhwYpA,6173
30
- pythonnative/native_views/ios.py,sha256=B1fBbFA4sEejYPwGirkO8IprHXZ0l1o5jKcJsRCf7x8,102394
30
+ pythonnative/native_views/ios.py,sha256=QNXLV7dWUN_9IbCr6900E8c95ZbODaRx09N1LsUHkUE,105201
31
31
  pythonnative/sdk/__init__.py,sha256=btIRfW2yy2d2LzjdpFnlc6ym-G3iJj9sVUbb2IlFMOI,3384
32
32
  pythonnative/sdk/_components.py,sha256=Hw0cqiyJ1NEzUrhOIT8zsC_mnVtf0jgpPUsireQG2qM,14644
33
33
  pythonnative/templates/android_template/build.gradle,sha256=4gE6CRS6RuBu9kp-_e_uYYU9mBgHVZrqQg9caSxgyuc,352
@@ -82,9 +82,9 @@ pythonnative/templates/ios_template/ios_template.xcodeproj/project.xcworkspace/x
82
82
  pythonnative/templates/ios_template/ios_templateTests/ios_templateTests.swift,sha256=YnwzZx7yXB13xKAXEGNgz17VuhWeqkHTRTtBJ2Vu3_E,1238
83
83
  pythonnative/templates/ios_template/ios_templateUITests/ios_templateUITests.swift,sha256=l2Pwa50F_rv-qPu2go6e4bQernM6PTQJeNPFl_c4ivY,1387
84
84
  pythonnative/templates/ios_template/ios_templateUITests/ios_templateUITestsLaunchTests.swift,sha256=f5JrG0uVtLMeJQy26Yyz7Om-JUkT220osqcbeIVkj2g,815
85
- pythonnative-0.17.0.dist-info/licenses/LICENSE,sha256=A69iG7TIAe6KkGQf6xoVHkc5JSZtOr5eRSvC5iuivnI,1067
86
- pythonnative-0.17.0.dist-info/METADATA,sha256=9dOiKHYySXTvJ3ucurJ8BHodm-_Vg3bGKkJJzhSmGaI,7996
87
- pythonnative-0.17.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
88
- pythonnative-0.17.0.dist-info/entry_points.txt,sha256=iUtDawWSAJAEyWTycpZxDuYz73ol31butpzDIEAgPO0,48
89
- pythonnative-0.17.0.dist-info/top_level.txt,sha256=kT4SEATY2ywzrZ2Pgea6_zxyym44Q_PbOsUoOYjJLFE,13
90
- pythonnative-0.17.0.dist-info/RECORD,,
85
+ pythonnative-0.17.1.dist-info/licenses/LICENSE,sha256=A69iG7TIAe6KkGQf6xoVHkc5JSZtOr5eRSvC5iuivnI,1067
86
+ pythonnative-0.17.1.dist-info/METADATA,sha256=IVxDKjt0G2qGBb7yAQ68ocAsHDafLffgEkxi80DKgx0,7996
87
+ pythonnative-0.17.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
88
+ pythonnative-0.17.1.dist-info/entry_points.txt,sha256=iUtDawWSAJAEyWTycpZxDuYz73ol31butpzDIEAgPO0,48
89
+ pythonnative-0.17.1.dist-info/top_level.txt,sha256=kT4SEATY2ywzrZ2Pgea6_zxyym44Q_PbOsUoOYjJLFE,13
90
+ pythonnative-0.17.1.dist-info/RECORD,,