pythonnative 0.20.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.
Files changed (33) hide show
  1. pythonnative/__init__.py +14 -3
  2. pythonnative/animated.py +420 -135
  3. pythonnative/cli/pn.py +450 -956
  4. pythonnative/components.py +519 -235
  5. pythonnative/events.py +210 -0
  6. pythonnative/gestures.py +875 -0
  7. pythonnative/layout.py +463 -149
  8. pythonnative/mutations.py +130 -0
  9. pythonnative/native_views/__init__.py +161 -97
  10. pythonnative/native_views/android.py +1050 -1124
  11. pythonnative/native_views/base.py +108 -18
  12. pythonnative/native_views/desktop.py +460 -417
  13. pythonnative/native_views/ios.py +1918 -1916
  14. pythonnative/project/__init__.py +68 -0
  15. pythonnative/project/android.py +504 -0
  16. pythonnative/project/builder.py +555 -0
  17. pythonnative/project/config.py +642 -0
  18. pythonnative/project/doctor.py +233 -0
  19. pythonnative/project/icons.py +247 -0
  20. pythonnative/project/ios.py +344 -0
  21. pythonnative/project/permissions.py +343 -0
  22. pythonnative/project/runtime_assets.py +272 -0
  23. pythonnative/reconciler.py +540 -470
  24. pythonnative/screen.py +5 -2
  25. pythonnative/sdk/_components.py +2 -2
  26. pythonnative/templates/android_template/app/build.gradle +2 -0
  27. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/METADATA +10 -2
  28. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/RECORD +32 -21
  29. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNVirtualListView.java +0 -129
  30. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/WHEEL +0 -0
  31. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/entry_points.txt +0 -0
  32. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/licenses/LICENSE +0 -0
  33. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/top_level.txt +0 -0
@@ -1,9 +1,17 @@
1
- """Virtual-tree reconciler.
1
+ """Virtual-tree reconciler with a batched, tag-based commit protocol.
2
2
 
3
3
  Maintains a tree of [`VNode`][pythonnative.reconciler.VNode] objects
4
- (each wrapping a native view) and diffs incoming
5
- [`Element`][pythonnative.Element] trees to apply the minimal set of
6
- native mutations.
4
+ (each owning an integer **tag** that identifies its native view) and
5
+ diffs incoming [`Element`][pythonnative.Element] trees to compute the
6
+ minimal set of native mutations.
7
+
8
+ The diff phase is *pure*: it never touches the native layer. Each pass
9
+ appends ops (`pythonnative.mutations`) to a transaction list, and the
10
+ commit applies them through a single
11
+ ``backend.apply_mutations(ops)`` call. Event callbacks never cross into
12
+ the native layer at all — they live in the Python-side
13
+ [`EventRegistry`][pythonnative.events.EventRegistry], keyed by tag, so
14
+ re-renders that only produce fresh closures cost nothing natively.
7
15
 
8
16
  Supports:
9
17
 
@@ -15,32 +23,43 @@ Supports:
15
23
  context values during tree traversal.
16
24
  - **Error boundary elements** (`type == "__ErrorBoundary__"`), which
17
25
  catch exceptions in child subtrees and render a fallback.
18
- - **Key-based child reconciliation** for stable identity across
19
- re-renders.
20
- - **Post-render effect flushing**. After each mount or reconcile pass,
21
- all queued effects are executed so they see the committed native tree.
22
- - **Layout pass**: after every commit, a parallel
23
- [`LayoutNode`][pythonnative.layout.LayoutNode] tree is built from
24
- the committed VNodes and fed through
25
- [`calculate_layout`][pythonnative.layout.calculate_layout]; the
26
- resulting per-node frames are applied via the backend's
27
- ``set_frame``. The viewport size is supplied by the screen host via
28
- [`set_viewport_size`][pythonnative.reconciler.Reconciler.set_viewport_size].
26
+ - **Key-based child reconciliation** with indexed, move-aware inserts
27
+ (keyed reorders emit one move per child instead of detach-all /
28
+ re-attach-all).
29
+ - **Post-render effect flushing**. After each commit, all queued
30
+ effects are executed so they see the committed native tree.
31
+ - **Incremental layout**: a parallel
32
+ [`LayoutNode`][pythonnative.layout.LayoutNode] tree is cached across
33
+ passes; clean subtrees keep their cached nodes (enabling the layout
34
+ engine's measurement memo) and only frames that actually changed are
35
+ sent to the native side.
29
36
  """
30
37
 
38
+ import itertools
31
39
  import os
32
- import sys
33
40
  from typing import Any, Dict, List, Optional, Tuple
34
41
 
35
42
  from .element import Element
43
+ from .events import extract_events, get_event_registry
36
44
  from .layout import LayoutNode, calculate_layout, extract_layout_style
45
+ from .mutations import CreateOp, DestroyOp, InsertOp, Mutation, SetFrameOp, UpdateOp
37
46
 
38
47
  # Props the reconciler consumes itself (i.e., never forwards to the
39
48
  # native handler). ``ref`` is one such prop: components pass a dict
40
49
  # from ``use_ref()`` and the reconciler populates ``ref["current"]``
41
- # with the underlying native view, mirroring React's ``ref`` semantics.
50
+ # with the underlying native view (and ``ref["_pn_tag"]`` with the
51
+ # view's tag), mirroring React's ``ref`` semantics.
42
52
  _RECONCILER_OWNED_PROPS = frozenset({"ref"})
43
53
 
54
+ # Tags are globally unique so multiple reconcilers (screens, list rows)
55
+ # can share one registry without collisions.
56
+ _tag_counter = itertools.count(1)
57
+
58
+
59
+ def next_tag() -> int:
60
+ """Allocate a fresh, process-unique view tag."""
61
+ return next(_tag_counter)
62
+
44
63
 
45
64
  def _shallow_equal_props(old: dict, new: dict) -> bool:
46
65
  """Return whether two prop dicts are equal under shallow comparison.
@@ -95,20 +114,25 @@ def _flatten_children(children: List[Element]) -> List[Element]:
95
114
 
96
115
 
97
116
  class VNode:
98
- """A mounted [`Element`][pythonnative.Element] plus its native view.
117
+ """A mounted [`Element`][pythonnative.Element] plus its native identity.
99
118
 
100
119
  The reconciler walks parallel trees of `VNode` and incoming
101
120
  `Element` to compute the minimal set of native mutations.
102
121
 
103
122
  Attributes:
104
123
  element: The `Element` last rendered into this slot.
105
- native_view: The platform-native view (e.g., an Android `View`
106
- or an iOS `UIView`). May be `None` for purely virtual
107
- wrappers such as providers and error boundaries.
124
+ tag: Integer identity of the underlying native view. Native
125
+ elements own a fresh tag; transparent wrappers (function
126
+ components, providers, error boundaries) delegate the tag
127
+ of their rendered subtree root. ``None`` before the subtree
128
+ renders anything.
129
+ native_view: The platform-native view object, resolved from the
130
+ registry after commit. May be `None` for purely virtual
131
+ wrappers that rendered nothing.
108
132
  children: Ordered list of child `VNode` instances.
109
133
  parent: The owning `VNode`, or `None` for the tree root. Used
110
134
  by local (component-scoped) re-renders to bubble a changed
111
- native view up to the nearest native container.
135
+ subtree root up to the nearest native container.
112
136
  hook_state: The component's
113
137
  [`HookState`][pythonnative.hooks.HookState] when the node
114
138
  wraps a function component, otherwise `None`.
@@ -118,47 +142,67 @@ class VNode:
118
142
 
119
143
  __slots__ = (
120
144
  "element",
145
+ "tag",
121
146
  "native_view",
122
147
  "children",
123
148
  "parent",
124
149
  "hook_state",
125
150
  "mounted",
126
151
  "_rendered",
152
+ "_clean_props",
127
153
  "_measure_cache",
154
+ "_last_frame",
155
+ "_layout_node",
156
+ "_layout_dirty",
128
157
  )
129
158
 
130
- def __init__(self, element: Element, native_view: Any, children: List["VNode"]) -> None:
159
+ def __init__(self, element: Element, children: List["VNode"], tag: Optional[int] = None) -> None:
131
160
  self.element = element
132
- self.native_view = native_view
161
+ self.tag = tag
162
+ self.native_view: Any = None
133
163
  self.children = children
134
164
  self.parent: Optional["VNode"] = None
135
165
  self.hook_state: Any = None
136
166
  self.mounted: bool = True
137
167
  self._rendered: Optional[Element] = None
168
+ # Native-safe props (callables stripped) from the last commit;
169
+ # the baseline for prop diffing.
170
+ self._clean_props: Dict[str, Any] = {}
138
171
  # Cache for the leaf intrinsic-size measure callback:
139
- # ``(element, max_w, max_h, width, height)``. Lets the layout
140
- # pass skip native ``measure_intrinsic`` calls for leaves whose
141
- # element *object* (compared by identity) and constraints are
142
- # unchanged since the last measure. Because untouched components
143
- # keep their exact ``Element`` instances across a local
144
- # re-render, this turns "re-measure everything every layout pass"
145
- # into "re-measure only what actually re-rendered".
146
- self._measure_cache: Optional[Tuple[Any, float, float, float, float]] = None
172
+ # ``(max_w, max_h, width, height)``. Invalidated whenever the
173
+ # node's props change, so unchanged leaves skip native
174
+ # ``measure_intrinsic`` calls entirely.
175
+ self._measure_cache: Optional[Tuple[float, float, float, float]] = None
176
+ # Last frame sent to the native side — frames that don't change
177
+ # are skipped (frame diffing).
178
+ self._last_frame: Optional[Tuple[float, float, float, float]] = None
179
+ # Cached LayoutNode reused across passes while the subtree is
180
+ # clean (see Reconciler._build_layout_tree_cached).
181
+ self._layout_node: Optional[LayoutNode] = None
182
+ # True when this node's layout-relevant props or child list
183
+ # changed since the last layout pass.
184
+ self._layout_dirty: bool = True
147
185
 
148
186
 
149
187
  class Reconciler:
150
188
  """Create, diff, and patch native view trees from `Element` descriptors.
151
189
 
152
- After each [`mount`][pythonnative.reconciler.Reconciler.mount] or
153
- [`reconcile`][pythonnative.reconciler.Reconciler.reconcile] call the
154
- reconciler walks the committed tree and flushes all pending effects
155
- so effect callbacks run *after* native mutations are applied.
190
+ After each [`mount`][pythonnative.reconciler.Reconciler.mount],
191
+ [`reconcile`][pythonnative.reconciler.Reconciler.reconcile], or
192
+ [`flush_dirty`][pythonnative.reconciler.Reconciler.flush_dirty]
193
+ pass the reconciler:
194
+
195
+ 1. applies the accumulated mutation ops in one batch,
196
+ 2. resolves freshly created native views and populates refs,
197
+ 3. flushes pending effects (so they see the committed tree), and
198
+ 4. runs the layout pass, emitting only changed frames.
156
199
 
157
200
  Args:
158
- backend: An object implementing the native-view protocol
159
- (`create_view`, `update_view`, `add_child`, `remove_child`,
160
- `insert_child`). PythonNative ships an Android backend and
161
- an iOS backend; tests can pass a mock.
201
+ backend: An object implementing the registry protocol
202
+ (``apply_mutations``, ``resolve_view``,
203
+ ``measure_intrinsic``, ``command``). PythonNative ships
204
+ Android, iOS, and desktop registries; tests can pass a
205
+ registry stocked with mock handlers.
162
206
  """
163
207
 
164
208
  def __init__(self, backend: Any) -> None:
@@ -167,6 +211,10 @@ class Reconciler:
167
211
  self._screen_re_render: Optional[Any] = None
168
212
  self._viewport_size: Tuple[float, float] = (0.0, 0.0)
169
213
  self._layout_pass = 0
214
+ self._events = get_event_registry()
215
+ # Transaction state for the in-flight pass.
216
+ self._ops: List[Mutation] = []
217
+ self._created: List[VNode] = []
170
218
  # Function-component VNodes whose own state changed since the
171
219
  # last flush, keyed by ``id`` to dedupe while keeping a strong
172
220
  # reference. Drained by
@@ -187,15 +235,10 @@ class Reconciler:
187
235
  The platform-native view that represents the root of the
188
236
  mounted tree.
189
237
  """
190
- self._log_viewport(
191
- f"mount: start type={self._type_label(element.type)!r} props={self._props_debug(element.props)}"
192
- )
238
+ self._log(f"mount: start type={self._type_label(element.type)!r}")
193
239
  self._dirty_nodes.clear()
194
240
  self._tree = self._create_tree(element)
195
- self._log_viewport(f"mount: tree created root={self._node_debug(self._tree)}")
196
- self._flush_effects()
197
- self._run_layout()
198
- self._log_viewport(f"mount: done root_view={self._obj_debug(self._tree.native_view)}")
241
+ self._commit()
199
242
  return self._tree.native_view
200
243
 
201
244
  def reconcile(self, new_element: Element) -> Any:
@@ -207,32 +250,39 @@ class Reconciler:
207
250
  Returns:
208
251
  The (possibly replaced) root native view.
209
252
  """
210
- self._log_viewport(
211
- "reconcile: entering "
212
- f"(have_tree={self._tree is not None}) new_type={self._type_label(new_element.type)!r} "
213
- f"new_props={self._props_debug(new_element.props)}"
214
- )
215
253
  # A full reconcile rebuilds the whole tree from the root, so any
216
254
  # pending per-component dirty marks are now obsolete.
217
255
  self._dirty_nodes.clear()
218
256
  if self._tree is None:
219
257
  self._tree = self._create_tree(new_element)
220
- self._log_viewport(f"reconcile: created initial root={self._node_debug(self._tree)}")
221
- self._flush_effects()
222
- self._run_layout()
223
- return self._tree.native_view
224
-
225
- self._tree = self._reconcile_node(self._tree, new_element)
226
- self._log_viewport(f"reconcile: tree reconciled root={self._node_debug(self._tree)}")
227
- self._flush_effects()
228
- self._run_layout()
229
- self._log_viewport("reconcile: done")
258
+ else:
259
+ self._tree = self._reconcile_node(self._tree, new_element)
260
+ self._commit()
230
261
  return self._tree.native_view
231
262
 
232
263
  def root_view(self) -> Any:
233
264
  """Return the current root native view, or ``None`` before mount."""
234
265
  return self._tree.native_view if self._tree is not None else None
235
266
 
267
+ def root_tag(self) -> Optional[int]:
268
+ """Return the root native view's tag, or ``None`` before mount."""
269
+ return self._tree.tag if self._tree is not None else None
270
+
271
+ def unmount(self) -> None:
272
+ """Destroy the entire mounted tree and release native views."""
273
+ if self._tree is None:
274
+ return
275
+ self._destroy_tree(self._tree)
276
+ self._tree = None
277
+ self._dirty_nodes.clear()
278
+ self._flush_ops()
279
+
280
+ def dispatch_command(self, tag: Optional[int], name: str, args: Optional[Dict[str, Any]] = None) -> Any:
281
+ """Run an imperative command against the view registered under ``tag``."""
282
+ if tag is None:
283
+ return None
284
+ return self.backend.command(tag, name, args or {})
285
+
236
286
  def mark_dirty(self, vnode: "VNode") -> None:
237
287
  """Queue ``vnode`` (a function component) for a local re-render.
238
288
 
@@ -258,7 +308,8 @@ class Reconciler:
258
308
  its subtree. Nodes are processed shallowest-first so that when a
259
309
  dirty ancestor's re-render already covers a dirty descendant, the
260
310
  descendant is skipped (its ``_dirty`` flag is cleared by the
261
- ancestor pass).
311
+ ancestor pass). The whole batch commits as one native
312
+ transaction.
262
313
 
263
314
  Returns:
264
315
  The (possibly replaced) root native view, so the host can
@@ -289,9 +340,94 @@ class Reconciler:
289
340
  # the exception propagates, matching a full render.
290
341
  self._handle_local_render_error(vnode, exc)
291
342
 
343
+ self._commit()
344
+ return self._tree.native_view
345
+
346
+ def set_viewport_size(self, width: float, height: float) -> None:
347
+ """Update the viewport size and re-run layout if it changed.
348
+
349
+ Called by the screen host whenever the platform reports a new
350
+ container size (Android: ``onLayoutChange``; iOS:
351
+ ``viewDidLayoutSubviews``). The first call after mount
352
+ triggers the initial layout pass; subsequent identical
353
+ sizes are no-ops.
354
+
355
+ Args:
356
+ width: Viewport width in points.
357
+ height: Viewport height in points.
358
+ """
359
+ if width <= 0 or height <= 0:
360
+ return
361
+ if self._viewport_size == (width, height):
362
+ return
363
+ self._viewport_size = (width, height)
364
+ if self._tree is not None:
365
+ self._run_layout()
366
+ self._flush_ops()
367
+
368
+ # ------------------------------------------------------------------
369
+ # Commit driver
370
+ # ------------------------------------------------------------------
371
+
372
+ def _commit(self) -> None:
373
+ """Apply the accumulated transaction and run the post-commit phases."""
374
+ self._flush_ops()
292
375
  self._flush_effects()
293
376
  self._run_layout()
294
- return self._tree.native_view
377
+ self._flush_ops()
378
+
379
+ def _flush_ops(self) -> None:
380
+ """Send pending ops to the backend and resolve created views."""
381
+ ops = self._ops
382
+ created = self._created
383
+ if ops:
384
+ self._ops = []
385
+ self._created = []
386
+ self.backend.apply_mutations(ops)
387
+ elif created:
388
+ self._created = []
389
+ for vnode in created:
390
+ if not vnode.mounted or vnode.tag is None:
391
+ continue
392
+ vnode.native_view = self.backend.resolve_view(vnode.tag)
393
+ self._attach_ref(vnode.element, vnode.native_view, vnode.tag)
394
+
395
+ # ------------------------------------------------------------------
396
+ # Effect flushing
397
+ # ------------------------------------------------------------------
398
+
399
+ def _flush_effects(self) -> None:
400
+ """Walk the committed tree and flush pending effects (depth-first).
401
+
402
+ This post-commit walk doubles as the single source of truth for
403
+ ``VNode.parent`` links and for *delegated* identity: transparent
404
+ wrappers (components, providers, boundaries) re-derive their
405
+ ``tag`` / ``native_view`` from their subtree root here, so the
406
+ rest of the reconciler never has to chase delegation chains by
407
+ hand. The cost is folded into a walk the reconciler already runs
408
+ after every commit.
409
+ """
410
+ if self._tree is not None:
411
+ self._tree.parent = None
412
+ self._flush_tree_effects(self._tree)
413
+
414
+ def _flush_tree_effects(self, node: VNode) -> None:
415
+ for child in node.children:
416
+ child.parent = node
417
+ self._flush_tree_effects(child)
418
+ if not self._is_native_node(node):
419
+ if node.children:
420
+ node.tag = node.children[0].tag
421
+ node.native_view = node.children[0].native_view
422
+ else:
423
+ node.tag = None
424
+ node.native_view = None
425
+ if node.hook_state is not None:
426
+ node.hook_state.flush_pending_effects()
427
+
428
+ # ------------------------------------------------------------------
429
+ # Internal helpers
430
+ # ------------------------------------------------------------------
295
431
 
296
432
  @staticmethod
297
433
  def _node_depth(vnode: "VNode") -> int:
@@ -338,7 +474,7 @@ class Reconciler:
338
474
  _set_hook_state(None)
339
475
  hook_state._dirty = False
340
476
 
341
- old_native = vnode.native_view
477
+ old_tag = vnode.tag
342
478
  if vnode.children:
343
479
  child = self._reconcile_node(vnode.children[0], rendered)
344
480
  else:
@@ -349,11 +485,12 @@ class Reconciler:
349
485
 
350
486
  child.parent = vnode
351
487
  vnode.children = [child]
488
+ vnode.tag = child.tag
352
489
  vnode.native_view = child.native_view
353
490
  vnode._rendered = rendered
354
491
 
355
- if child.native_view is not old_native:
356
- self._bubble_native_view_change(vnode, old_native, child.native_view)
492
+ if child.tag != old_tag:
493
+ self._bubble_root_change(vnode, child)
357
494
 
358
495
  def _handle_local_render_error(self, vnode: "VNode", exc: Exception) -> None:
359
496
  """Route a local re-render failure to the nearest ``ErrorBoundary`` ancestor.
@@ -368,7 +505,7 @@ class Reconciler:
368
505
  node = vnode.parent
369
506
  while node is not None:
370
507
  if isinstance(node.element.type, str) and node.element.type == "__ErrorBoundary__":
371
- old_native = node.native_view
508
+ old_tag = node.tag
372
509
  # Like a local component update, this re-reconcile starts
373
510
  # mid-tree, so restore the boundary's own ancestor
374
511
  # provider context first.
@@ -380,8 +517,8 @@ class Reconciler:
380
517
  finally:
381
518
  for context, _value in reversed(providers):
382
519
  context._stack.pop()
383
- if node.native_view is not old_native:
384
- self._bubble_native_view_change(node, old_native, node.native_view)
520
+ if node.tag != old_tag and node.children:
521
+ self._bubble_root_change(node, node.children[0])
385
522
  return
386
523
  node = node.parent
387
524
  raise exc
@@ -404,74 +541,46 @@ class Reconciler:
404
541
  chain.reverse()
405
542
  return chain
406
543
 
407
- def _bubble_native_view_change(self, vnode: "VNode", old_native: Any, new_native: Any) -> None:
408
- """Propagate a changed subtree-root native view up to its native parent.
544
+ def _bubble_root_change(self, vnode: "VNode", new_subtree_root: "VNode") -> None:
545
+ """Propagate a swapped subtree-root view up to its native parent.
409
546
 
410
547
  A local re-render starts below the real native container, so when
411
- the dirty component's root native view is swapped (e.g. its output
412
- changed type), the change must be reflected in (a) every
413
- transparent ancestor that delegated its ``native_view`` to this
414
- subtree and (b) the nearest native-container ancestor's child list.
548
+ the dirty component's root native view is replaced (e.g. its
549
+ output changed type), the change must be reflected in (a) every
550
+ transparent ancestor that delegated its identity to this subtree
551
+ and (b) the nearest native-container ancestor's child list. The
552
+ old view's detach is implied by its `DestroyOp` (handlers detach
553
+ on destroy); only the indexed insert of the new root is emitted.
415
554
  """
416
555
  child = vnode
417
556
  node = vnode.parent
418
557
  while node is not None:
419
- if self._is_native_container(node):
558
+ if self._is_native_node(node):
420
559
  try:
421
- idx: Optional[int] = node.children.index(child)
560
+ idx = node.children.index(child)
422
561
  except ValueError:
423
- idx = None
424
- if old_native is not None:
425
- self.backend.remove_child(node.native_view, old_native, node.element.type)
426
- if new_native is not None:
427
- if idx is None:
428
- self.backend.add_child(node.native_view, new_native, node.element.type)
429
- else:
430
- self.backend.insert_child(node.native_view, new_native, node.element.type, idx)
562
+ idx = len(node.children) - 1
563
+ if node.tag is not None and new_subtree_root.tag is not None:
564
+ self._ops.append(InsertOp(node.tag, new_subtree_root.tag, idx))
565
+ self._mark_layout_dirty(node)
431
566
  return
432
- # Transparent ancestor (component / provider / error boundary /
433
- # fragment) delegates its native view to this subtree.
434
- if node.native_view is old_native:
435
- node.native_view = new_native
567
+ # Transparent ancestor delegates its identity to this subtree.
568
+ node.tag = new_subtree_root.tag
569
+ node.native_view = new_subtree_root.native_view
436
570
  child = node
437
571
  node = node.parent
438
572
  # Reached the root with no native container above: the root's
439
- # ``native_view`` was already updated in the loop. The host
440
- # detects the change by comparing ``root_view()`` after the flush.
573
+ # identity was already updated in the loop. The host detects the
574
+ # change by comparing ``root_view()`` after the flush.
441
575
 
442
576
  @staticmethod
443
- def _is_native_container(node: "VNode") -> bool:
577
+ def _is_native_node(node: "VNode") -> bool:
444
578
  t = node.element.type
445
579
  return isinstance(t, str) and t not in ("__Provider__", "__ErrorBoundary__", "__Fragment__")
446
580
 
447
- def set_viewport_size(self, width: float, height: float) -> None:
448
- """Update the viewport size and re-run layout if it changed.
449
-
450
- Called by the screen host whenever the platform reports a new
451
- container size (Android: ``onLayoutChange``; iOS:
452
- ``viewDidLayoutSubviews``). The first call after mount
453
- triggers the initial layout pass; subsequent identical
454
- sizes are no-ops.
455
-
456
- Args:
457
- width: Viewport width in points.
458
- height: Viewport height in points.
459
- """
460
- if width <= 0 or height <= 0:
461
- self._log_viewport(f"set_viewport_size: ignored non-positive ({width},{height})")
462
- return
463
- if self._viewport_size == (width, height):
464
- self._log_viewport(f"set_viewport_size: unchanged at ({width},{height})")
465
- return
466
- prev = self._viewport_size
467
- self._viewport_size = (width, height)
468
- self._log_viewport(f"set_viewport_size: {prev} -> ({width},{height}); running layout")
469
- if self._tree is not None:
470
- self._run_layout()
471
-
472
581
  @staticmethod
473
- def _log_viewport(msg: str) -> None:
474
- """Emit optional layout diagnostics for local debugging."""
582
+ def _log(msg: str) -> None:
583
+ """Emit optional diagnostics for local debugging."""
475
584
  if os.environ.get("PYTHONNATIVE_DEBUG", "").lower() not in {"1", "true", "yes", "on"}:
476
585
  return
477
586
  try:
@@ -485,82 +594,8 @@ class Reconciler:
485
594
  return type_obj
486
595
  return getattr(type_obj, "__name__", repr(type_obj))
487
596
 
488
- @staticmethod
489
- def _obj_debug(obj: Any) -> str:
490
- if obj is None:
491
- return "<None>"
492
- ptr = getattr(obj, "ptr", None)
493
- addr: Optional[int] = None
494
- if isinstance(ptr, (bytes, bytearray)):
495
- try:
496
- addr = int.from_bytes(ptr, byteorder=sys.byteorder, signed=False)
497
- except Exception:
498
- addr = None
499
- elif isinstance(ptr, int):
500
- addr = ptr
501
- elif ptr is not None:
502
- value = getattr(ptr, "value", None)
503
- if isinstance(value, int):
504
- addr = value
505
- else:
506
- try:
507
- addr = int(ptr)
508
- except Exception:
509
- addr = None
510
- addr_part = f" ptr=0x{addr:x}" if addr is not None else ""
511
- return f"{type(obj).__name__}(py_id=0x{id(obj):x}{addr_part})"
512
-
513
- @classmethod
514
- def _props_debug(cls, props: dict) -> str:
515
- interesting = []
516
- for key in ("title", "text", "active_tab", "scroll_axis", "width", "height", "flex", "key"):
517
- if key in props:
518
- interesting.append(f"{key}={props[key]!r}")
519
- if "items" in props and isinstance(props["items"], list):
520
- names = [item.get("name", item.get("title", "")) for item in props["items"][:4]]
521
- interesting.append(f"items_len={len(props['items'])} items={names!r}")
522
- callback_keys = sorted(key for key, value in props.items() if callable(value))
523
- if callback_keys:
524
- interesting.append(f"callbacks={callback_keys!r}")
525
- return "{" + ", ".join(interesting) + "}"
526
-
527
- @classmethod
528
- def _node_debug(cls, vnode: VNode) -> str:
529
- element = vnode.element
530
- return (
531
- f"type={cls._type_label(element.type)!r} key={element.key!r} "
532
- f"props={cls._props_debug(element.props)} view={cls._obj_debug(vnode.native_view)}"
533
- )
534
-
535
- # ------------------------------------------------------------------
536
- # Effect flushing
537
- # ------------------------------------------------------------------
538
-
539
- def _flush_effects(self) -> None:
540
- """Walk the committed tree and flush pending effects (depth-first).
541
-
542
- This post-commit walk doubles as the single source of truth for
543
- ``VNode.parent``: every live node's parent pointer is re-linked
544
- here so that local re-renders
545
- ([`flush_dirty`][pythonnative.reconciler.Reconciler.flush_dirty])
546
- can compute node depth and bubble native-view changes upward
547
- without each reconcile path having to maintain parent links by
548
- hand. The cost is folded into a walk the reconciler already runs
549
- after every commit.
550
- """
551
- if self._tree is not None:
552
- self._tree.parent = None
553
- self._flush_tree_effects(self._tree)
554
-
555
- def _flush_tree_effects(self, node: VNode) -> None:
556
- for child in node.children:
557
- child.parent = node
558
- self._flush_tree_effects(child)
559
- if node.hook_state is not None:
560
- node.hook_state.flush_pending_effects()
561
-
562
597
  # ------------------------------------------------------------------
563
- # Internal helpers
598
+ # Tree creation
564
599
  # ------------------------------------------------------------------
565
600
 
566
601
  def _create_tree(self, element: Element) -> VNode:
@@ -573,9 +608,10 @@ class Reconciler:
573
608
  child_node = self._create_tree(provider_children[0]) if provider_children else None
574
609
  finally:
575
610
  context._stack.pop()
576
- native_view = child_node.native_view if child_node else None
577
611
  children = [child_node] if child_node else []
578
- return VNode(element, native_view, children)
612
+ vnode = VNode(element, children)
613
+ vnode.tag = child_node.tag if child_node else None
614
+ return vnode
579
615
 
580
616
  # Error boundary: catch exceptions in the child subtree
581
617
  if element.type == "__ErrorBoundary__":
@@ -587,9 +623,11 @@ class Reconciler:
587
623
  if element.type == "__Fragment__":
588
624
  kids = _flatten_children(element.children)
589
625
  if not kids:
590
- return VNode(element, None, [])
626
+ return VNode(element, [])
591
627
  child_node = self._create_tree(kids[0])
592
- return VNode(element, child_node.native_view, [child_node])
628
+ vnode = VNode(element, [child_node])
629
+ vnode.tag = child_node.tag
630
+ return vnode
593
631
 
594
632
  # Function component: call with hook context
595
633
  if callable(element.type):
@@ -605,7 +643,8 @@ class Reconciler:
605
643
  hook_state._dirty = False
606
644
 
607
645
  child_node = self._create_tree(rendered)
608
- vnode = VNode(element, child_node.native_view, [child_node])
646
+ vnode = VNode(element, [child_node])
647
+ vnode.tag = child_node.tag
609
648
  vnode.hook_state = hook_state
610
649
  vnode._rendered = rendered
611
650
  hook_state._vnode = vnode
@@ -613,46 +652,21 @@ class Reconciler:
613
652
  return vnode
614
653
 
615
654
  # Native element
616
- self._log_viewport(
617
- f"_create_tree: native start type={element.type!r} props={self._props_debug(element.props)} "
618
- f"children={len(element.children)}"
619
- )
620
- handler_props = self._strip_reconciler_props(element.props)
621
- try:
622
- native_view = self.backend.create_view(element.type, handler_props)
623
- except Exception as e:
624
- self._log_viewport(f"_create_tree: BACKEND.create_view({element.type!r}) RAISED {type(e).__name__}: {e!r}")
625
- raise
626
- self._log_viewport(f"_create_tree: native created type={element.type!r} view={self._obj_debug(native_view)}")
627
- self._attach_ref(element, native_view)
628
- children: List[VNode] = []
655
+ tag = next_tag()
656
+ clean_props, events = self._split_props(element.props)
657
+ vnode = VNode(element, [], tag=tag)
658
+ vnode._clean_props = clean_props
659
+ if events:
660
+ self._events.set_events(tag, events)
661
+ self._ops.append(CreateOp(tag, element.type, clean_props))
662
+ self._created.append(vnode)
663
+
629
664
  flat_children = _flatten_children(element.children)
630
665
  for i, child_el in enumerate(flat_children):
631
- child_type = self._type_label(child_el.type)
632
- self._log_viewport(f"_create_tree: creating child[{i}] type={child_type!r} of {element.type!r}")
633
- try:
634
- child_node = self._create_tree(child_el)
635
- except Exception as e:
636
- self._log_viewport(
637
- f"_create_tree: child[{i}] (type={child_type!r}) of {element.type!r} RAISED "
638
- f"{type(e).__name__}: {e!r}"
639
- )
640
- raise
641
- self._log_viewport(
642
- f"_create_tree: add child[{i}] parent={element.type!r} child={self._node_debug(child_node)}"
643
- )
644
- try:
645
- self.backend.add_child(native_view, child_node.native_view, element.type)
646
- except Exception as e:
647
- self._log_viewport(
648
- f"_create_tree: backend.add_child(parent={element.type!r}, "
649
- f"child={child_type!r}) RAISED {type(e).__name__}: {e!r}"
650
- )
651
- raise
652
- self._log_viewport(f"_create_tree: add child[{i}] done parent={element.type!r}")
653
- children.append(child_node)
654
- vnode = VNode(element, native_view, children)
655
- self._log_viewport(f"_create_tree: native done {self._node_debug(vnode)} children={len(children)}")
666
+ child_node = self._create_tree(child_el)
667
+ if child_node.tag is not None:
668
+ self._ops.append(InsertOp(tag, child_node.tag, i))
669
+ vnode.children.append(child_node)
656
670
  return vnode
657
671
 
658
672
  def _create_error_boundary(self, element: Element) -> VNode:
@@ -666,20 +680,19 @@ class Reconciler:
666
680
  child_node = self._create_tree(fallback_el)
667
681
  else:
668
682
  raise
669
- native_view = child_node.native_view if child_node else None
670
683
  children = [child_node] if child_node else []
671
- return VNode(element, native_view, children)
684
+ vnode = VNode(element, children)
685
+ vnode.tag = child_node.tag if child_node else None
686
+ return vnode
687
+
688
+ # ------------------------------------------------------------------
689
+ # Reconciliation
690
+ # ------------------------------------------------------------------
672
691
 
673
692
  def _reconcile_node(self, old: VNode, new_el: Element) -> VNode:
674
693
  if not self._same_type(old.element, new_el):
675
- self._log_viewport(
676
- "_reconcile_node: replace "
677
- f"old={self._node_debug(old)} new_type={self._type_label(new_el.type)!r} "
678
- f"new_props={self._props_debug(new_el.props)}"
679
- )
680
694
  new_node = self._create_tree(new_el)
681
695
  self._destroy_tree(old)
682
- self._log_viewport(f"_reconcile_node: replace done new={self._node_debug(new_node)}")
683
696
  return new_node
684
697
 
685
698
  # Provider
@@ -691,10 +704,12 @@ class Reconciler:
691
704
  if old.children and provider_kids:
692
705
  child = self._reconcile_node(old.children[0], provider_kids[0])
693
706
  old.children = [child]
707
+ old.tag = child.tag
694
708
  old.native_view = child.native_view
695
709
  elif provider_kids:
696
710
  child = self._create_tree(provider_kids[0])
697
711
  old.children = [child]
712
+ old.tag = child.tag
698
713
  old.native_view = child.native_view
699
714
  finally:
700
715
  context._stack.pop()
@@ -715,7 +730,6 @@ class Reconciler:
715
730
  # previously-rendered subtree without invoking the body.
716
731
  if self._can_skip_memoized(old, new_el):
717
732
  old.element = new_el
718
- self._log_viewport(f"_reconcile_node: memo skip type={self._type_label(new_el.type)!r}")
719
733
  return old
720
734
 
721
735
  hook_state = old.hook_state
@@ -737,6 +751,7 @@ class Reconciler:
737
751
  else:
738
752
  child = self._create_tree(rendered)
739
753
  old.children = [child]
754
+ old.tag = child.tag
740
755
  old.native_view = child.native_view
741
756
  old.element = new_el
742
757
  old.hook_state = hook_state
@@ -746,17 +761,17 @@ class Reconciler:
746
761
  return old
747
762
 
748
763
  # Native element
749
- changed = self._diff_props(old.element.props, new_el.props)
764
+ new_clean, events = self._split_props(new_el.props)
765
+ if old.tag is not None:
766
+ self._events.set_events(old.tag, events)
767
+ changed = self._diff_props(old._clean_props, new_clean)
750
768
  if changed:
751
- self._log_viewport(
752
- "_reconcile_node: native update "
753
- f"type={old.element.type!r} view={self._obj_debug(old.native_view)} "
754
- f"changed={self._props_debug(changed)}"
755
- )
756
- self.backend.update_view(old.native_view, old.element.type, changed)
757
- self._log_viewport(f"_reconcile_node: native update done type={old.element.type!r}")
758
- else:
759
- self._log_viewport(f"_reconcile_node: native unchanged type={old.element.type!r}")
769
+ if old.tag is not None:
770
+ self._ops.append(UpdateOp(old.tag, changed))
771
+ old._measure_cache = None
772
+ if self._affects_layout(old.element.type, changed):
773
+ self._mark_layout_dirty(old)
774
+ old._clean_props = new_clean
760
775
 
761
776
  # Re-attach the ref if the ref dict identity changed (so we
762
777
  # never leave a stale ref pointing at a destroyed view, and so
@@ -769,14 +784,10 @@ class Reconciler:
769
784
  old_ref["current"] = None
770
785
  except Exception:
771
786
  pass
772
- self._attach_ref(new_el, old.native_view)
787
+ self._attach_ref(new_el, old.native_view, old.tag)
773
788
 
774
- self._log_viewport(
775
- f"_reconcile_node: reconcile children parent={old.element.type!r} new_children={len(new_el.children)}"
776
- )
777
789
  self._reconcile_children(old, new_el.children)
778
790
  old.element = new_el
779
- self._log_viewport(f"_reconcile_node: native done {self._node_debug(old)} children={len(old.children)}")
780
791
  return old
781
792
 
782
793
  def _reconcile_error_boundary(self, old: VNode, new_el: Element) -> VNode:
@@ -786,10 +797,12 @@ class Reconciler:
786
797
  if old.children and eb_kids:
787
798
  child = self._reconcile_node(old.children[0], eb_kids[0])
788
799
  old.children = [child]
800
+ old.tag = child.tag
789
801
  old.native_view = child.native_view
790
802
  elif eb_kids:
791
803
  child = self._create_tree(eb_kids[0])
792
804
  old.children = [child]
805
+ old.tag = child.tag
793
806
  old.native_view = child.native_view
794
807
  except Exception as exc:
795
808
  for c in old.children:
@@ -798,6 +811,7 @@ class Reconciler:
798
811
  fallback_el = fallback_fn(exc) if callable(fallback_fn) else fallback_fn
799
812
  child = self._create_tree(fallback_el)
800
813
  old.children = [child]
814
+ old.tag = child.tag
801
815
  old.native_view = child.native_view
802
816
  else:
803
817
  raise
@@ -832,12 +846,8 @@ class Reconciler:
832
846
  def _reconcile_children(self, parent: VNode, new_children: List[Element]) -> None:
833
847
  new_children = _flatten_children(new_children)
834
848
  old_children = parent.children
835
- parent_type = parent.element.type
836
- is_native = isinstance(parent_type, str) and parent_type not in (
837
- "__Provider__",
838
- "__ErrorBoundary__",
839
- "__Fragment__",
840
- )
849
+ is_native = self._is_native_node(parent)
850
+ parent_tag = parent.tag if is_native else None
841
851
 
842
852
  old_by_key: dict = {}
843
853
  old_unkeyed: list = []
@@ -850,6 +860,10 @@ class Reconciler:
850
860
  new_child_nodes: List[VNode] = []
851
861
  used_keyed: set = set()
852
862
  unkeyed_iter = iter(old_unkeyed)
863
+ # ``(index, vnode)`` pairs that need an indexed insert once the
864
+ # stale children have been removed (see op-ordering note below).
865
+ pending_inserts: List[Tuple[int, VNode]] = []
866
+ structure_changed = False
853
867
 
854
868
  for i, new_el in enumerate(new_children):
855
869
  matched: Optional[VNode] = None
@@ -861,75 +875,60 @@ class Reconciler:
861
875
  matched = next(unkeyed_iter, None)
862
876
 
863
877
  if matched is None:
864
- self._log_viewport(
865
- f"_reconcile_children: create child[{i}] parent={self._type_label(parent_type)!r} "
866
- f"type={self._type_label(new_el.type)!r}"
867
- )
868
878
  node = self._create_tree(new_el)
869
- if is_native:
870
- self._log_viewport(
871
- f"_reconcile_children: add new child[{i}] parent={self._obj_debug(parent.native_view)} "
872
- f"child={self._obj_debug(node.native_view)}"
873
- )
874
- self.backend.add_child(parent.native_view, node.native_view, parent_type)
879
+ pending_inserts.append((i, node))
880
+ structure_changed = True
875
881
  new_child_nodes.append(node)
876
882
  elif not self._same_type(matched.element, new_el):
877
- self._log_viewport(
878
- f"_reconcile_children: replace child[{i}] old={self._node_debug(matched)} "
879
- f"new_type={self._type_label(new_el.type)!r}"
880
- )
881
- if is_native:
882
- self.backend.remove_child(parent.native_view, matched.native_view, parent_type)
883
- self._destroy_tree(matched)
884
883
  node = self._create_tree(new_el)
885
- if is_native:
886
- self.backend.insert_child(parent.native_view, node.native_view, parent_type, i)
884
+ self._destroy_tree(matched)
885
+ pending_inserts.append((i, node))
886
+ structure_changed = True
887
887
  new_child_nodes.append(node)
888
888
  else:
889
- old_native = matched.native_view
890
- self._log_viewport(f"_reconcile_children: update child[{i}] {self._node_debug(matched)}")
889
+ old_tag = matched.tag
891
890
  updated = self._reconcile_node(matched, new_el)
892
- if is_native and updated.native_view is not old_native:
893
- self._log_viewport(
894
- f"_reconcile_children: child[{i}] native view changed "
895
- f"old={self._obj_debug(old_native)} new={self._obj_debug(updated.native_view)}"
896
- )
897
- self.backend.remove_child(parent.native_view, old_native, parent_type)
898
- self.backend.insert_child(parent.native_view, updated.native_view, parent_type, i)
891
+ if updated.tag != old_tag:
892
+ # The child's subtree root was replaced in place
893
+ # (transparent wrapper whose output changed type).
894
+ pending_inserts.append((i, updated))
895
+ structure_changed = True
899
896
  new_child_nodes.append(updated)
900
897
 
901
- # Destroy unused old nodes
898
+ # Destroy unused old nodes first: handlers detach on destroy, so
899
+ # the native child list contains only kept children (in their old
900
+ # relative order) by the time the indexed inserts apply.
902
901
  for key, node in old_by_key.items():
903
902
  if key not in used_keyed:
904
- self._log_viewport(f"_reconcile_children: destroy unused keyed key={key!r} {self._node_debug(node)}")
905
- if is_native:
906
- self.backend.remove_child(parent.native_view, node.native_view, parent_type)
907
903
  self._destroy_tree(node)
904
+ structure_changed = True
908
905
  for node in unkeyed_iter:
909
- self._log_viewport(f"_reconcile_children: destroy unused unkeyed {self._node_debug(node)}")
910
- if is_native:
911
- self.backend.remove_child(parent.native_view, node.native_view, parent_type)
912
906
  self._destroy_tree(node)
913
-
914
- # Reorder native children when keyed children changed positions.
915
- # Without this, native sibling order drifts from the logical tree
916
- # when keyed children swap positions across reconcile passes.
917
- if is_native and used_keyed:
918
- old_key_order = [c.element.key for c in old_children if c.element.key in used_keyed]
919
- new_key_order = [n.element.key for n in new_child_nodes if n.element.key in used_keyed]
920
- if old_key_order != new_key_order:
921
- self._log_viewport(
922
- f"_reconcile_children: reorder keyed children old={old_key_order!r} new={new_key_order!r}"
923
- )
924
- for node in new_child_nodes:
925
- self.backend.remove_child(parent.native_view, node.native_view, parent_type)
926
- for node in new_child_nodes:
927
- self.backend.add_child(parent.native_view, node.native_view, parent_type)
907
+ structure_changed = True
908
+
909
+ if is_native and parent_tag is not None:
910
+ for index, node in pending_inserts:
911
+ if node.tag is not None:
912
+ self._ops.append(InsertOp(parent_tag, node.tag, index))
913
+
914
+ # Keyed reorder: when the kept children changed relative
915
+ # order, emit one move-aware insert per child in final
916
+ # order. Applying "ensure child at index i" sequentially for
917
+ # i = 0..n-1 converges to the target order, and handlers
918
+ # no-op when the child is already in place.
919
+ if used_keyed:
920
+ old_key_order = [c.element.key for c in old_children if c.element.key in used_keyed]
921
+ new_key_order = [n.element.key for n in new_child_nodes if n.element.key in used_keyed]
922
+ if old_key_order != new_key_order:
923
+ structure_changed = True
924
+ for i, node in enumerate(new_child_nodes):
925
+ if node.tag is not None:
926
+ self._ops.append(InsertOp(parent_tag, node.tag, i))
927
+
928
+ if structure_changed:
929
+ self._mark_layout_dirty(parent)
928
930
 
929
931
  parent.children = new_child_nodes
930
- self._log_viewport(
931
- f"_reconcile_children: done parent={self._type_label(parent_type)!r} children={len(parent.children)}"
932
- )
933
932
 
934
933
  def _destroy_tree(self, node: VNode) -> None:
935
934
  node.mounted = False
@@ -949,33 +948,69 @@ class Reconciler:
949
948
  self._detach_ref(node.element)
950
949
  for child in node.children:
951
950
  self._destroy_tree(child)
951
+ if self._is_native_node(node) and node.tag is not None:
952
+ self._events.clear(node.tag)
953
+ self._ops.append(DestroyOp(node.tag))
952
954
  node.children = []
953
955
  node.parent = None
956
+ node._layout_node = None
954
957
 
955
- @staticmethod
956
- def _strip_reconciler_props(props: dict) -> dict:
957
- """Return ``props`` with reconciler-owned keys removed.
958
+ # ------------------------------------------------------------------
959
+ # Prop handling
960
+ # ------------------------------------------------------------------
961
+
962
+ def _split_props(self, props: dict) -> Tuple[Dict[str, Any], Dict[str, Any]]:
963
+ """Strip reconciler-owned keys, then split events from native props.
958
964
 
959
965
  Reconciler-owned keys (``ref``, internal ``__*__`` keys) are
960
966
  consumed by the reconciler itself and must never reach the
961
- native handler handlers don't know what to do with them and
962
- would incorrectly forward them to the underlying view.
967
+ native handler. Event callables are routed to the
968
+ [`EventRegistry`][pythonnative.events.EventRegistry] and the
969
+ remaining payload is safe to diff with plain ``==``.
963
970
  """
964
971
  if not props:
965
- return props
972
+ return {}, {}
966
973
  stripped = {}
967
974
  for key, value in props.items():
968
975
  if key in _RECONCILER_OWNED_PROPS or key.startswith("__"):
969
976
  continue
970
977
  stripped[key] = value
971
- return stripped
978
+ return extract_events(stripped)
979
+
980
+ @staticmethod
981
+ def _diff_props(old: dict, new: dict) -> dict:
982
+ """Return only the props that changed between two clean prop dicts.
983
+
984
+ Event callables never appear here (they live in the event
985
+ registry), so listener identity churn produces no native
986
+ traffic — only the ``_pn_events`` name set is compared.
987
+ """
988
+ changed = {}
989
+ for key, new_val in new.items():
990
+ old_val = old.get(key)
991
+ if key not in old:
992
+ changed[key] = new_val
993
+ continue
994
+ try:
995
+ if callable(new_val) or callable(old_val):
996
+ if old_val is not new_val:
997
+ changed[key] = new_val
998
+ elif old_val != new_val:
999
+ changed[key] = new_val
1000
+ except Exception:
1001
+ changed[key] = new_val
1002
+ for key in old:
1003
+ if key not in new:
1004
+ changed[key] = None
1005
+ return changed
972
1006
 
973
1007
  @staticmethod
974
- def _attach_ref(element: Element, native_view: Any) -> None:
975
- """Set ``ref["current"]`` if the element carries a ``ref`` prop."""
1008
+ def _attach_ref(element: Element, native_view: Any, tag: Optional[int]) -> None:
1009
+ """Populate ``ref["current"]`` (and the internal tag) if a ``ref`` prop exists."""
976
1010
  ref = element.props.get("ref") if element.props else None
977
1011
  if isinstance(ref, dict):
978
1012
  ref["current"] = native_view
1013
+ ref["_pn_tag"] = tag
979
1014
 
980
1015
  @staticmethod
981
1016
  def _detach_ref(element: Element) -> None:
@@ -984,6 +1019,7 @@ class Reconciler:
984
1019
  if isinstance(ref, dict):
985
1020
  try:
986
1021
  ref["current"] = None
1022
+ ref["_pn_tag"] = None
987
1023
  except Exception:
988
1024
  pass
989
1025
 
@@ -993,93 +1029,93 @@ class Reconciler:
993
1029
  return old_el.type == new_el.type
994
1030
  return old_el.type is new_el.type
995
1031
 
996
- @staticmethod
997
- def _diff_props(old: dict, new: dict) -> dict:
998
- """Return only the props that changed.
999
-
1000
- Callables always count as changed (we cannot compare two
1001
- closures cheaply, and event handlers are usually fresh on every
1002
- render). Internal `__*__` props are skipped because they are
1003
- consumed by the reconciler itself, not the native handler.
1004
- Reconciler-owned props (``ref``) are also skipped because they
1005
- are managed via ``_attach_ref`` / ``_detach_ref`` and never
1006
- forwarded to the native handler.
1007
- """
1008
- changed = {}
1009
- for key, new_val in new.items():
1010
- if key.startswith("__") or key in _RECONCILER_OWNED_PROPS:
1011
- continue
1012
- old_val = old.get(key)
1013
- if callable(new_val) or old_val != new_val:
1014
- changed[key] = new_val
1015
- for key in old:
1016
- if key.startswith("__") or key in _RECONCILER_OWNED_PROPS:
1017
- continue
1018
- if key not in new:
1019
- changed[key] = None
1020
- return changed
1021
-
1022
1032
  # ------------------------------------------------------------------
1023
1033
  # Layout pass
1024
1034
  # ------------------------------------------------------------------
1025
1035
 
1036
+ _INTRINSIC_TYPES = frozenset(
1037
+ {
1038
+ "Text",
1039
+ "Button",
1040
+ "Image",
1041
+ "TextInput",
1042
+ "Switch",
1043
+ "Slider",
1044
+ "ProgressBar",
1045
+ "ActivityIndicator",
1046
+ "TabBar",
1047
+ "Picker",
1048
+ "Checkbox",
1049
+ "SegmentedControl",
1050
+ "DatePicker",
1051
+ }
1052
+ )
1053
+
1054
+ @classmethod
1055
+ def _affects_layout(cls, type_name: str, changed: Dict[str, Any]) -> bool:
1056
+ """Whether ``changed`` props can alter the node's layout.
1057
+
1058
+ Content-sized leaves re-measure on *any* prop change (text,
1059
+ font, image source — almost everything affects their intrinsic
1060
+ size). Containers only care about the layout style keys.
1061
+ """
1062
+ if type_name in cls._INTRINSIC_TYPES:
1063
+ return True
1064
+ from .layout import LAYOUT_STYLE_KEYS
1065
+
1066
+ return any(key in LAYOUT_STYLE_KEYS for key in changed)
1067
+
1068
+ @staticmethod
1069
+ def _mark_layout_dirty(vnode: VNode) -> None:
1070
+ vnode._layout_dirty = True
1071
+ vnode._layout_node = None
1072
+
1026
1073
  def _run_layout(self) -> None:
1027
- """Build a layout tree from the committed VNodes and apply frames.
1074
+ """Build/refresh the layout tree, compute frames, and emit changed ones.
1028
1075
 
1029
- Wraps the user's root VNode in a synthetic outer
1030
- `LayoutNode` with the viewport size so the user's root
1031
- always fills the screen by default (matching React Native).
1032
- Skipped silently until the screen host has supplied a
1033
- viewport size via
1076
+ Wraps the user's root VNode in a synthetic outer `LayoutNode`
1077
+ with the viewport size so the user's root always fills the
1078
+ screen by default (matching React Native). Skipped silently
1079
+ until the screen host has supplied a viewport size via
1034
1080
  [`set_viewport_size`][pythonnative.reconciler.Reconciler.set_viewport_size].
1035
1081
 
1082
+ Subtrees whose props and children are unchanged since the last
1083
+ pass keep their cached `LayoutNode` objects, which lets the
1084
+ layout engine reuse memoized measurements instead of re-running
1085
+ flex math (see ``pythonnative.layout``). Only frames that
1086
+ differ from the previously applied frame produce `SetFrameOp`s.
1087
+
1036
1088
  The root native view's *frame* is intentionally NOT touched:
1037
1089
  its position and size are owned by the screen host (iOS
1038
1090
  ``_sync_root_frame`` places it below the top safe-area
1039
- inset; Android attaches it with ``MATCH_PARENT``). Calling
1040
- ``set_frame(root, 0, 0, w, h)`` here would silently reset
1041
- the iOS root's ``y`` from ``insets.top`` back to ``0``,
1042
- causing the root view to overlap the status bar / dynamic
1043
- island after every tab switch.
1091
+ inset; Android attaches it with ``MATCH_PARENT``). Framing
1092
+ the root here would silently reset the iOS root's ``y`` from
1093
+ ``insets.top`` back to ``0``, causing the root view to overlap
1094
+ the status bar / dynamic island after every tab switch.
1044
1095
  """
1045
1096
  if self._tree is None:
1046
1097
  return
1047
1098
  viewport_w, viewport_h = self._viewport_size
1048
1099
  if viewport_w <= 0 or viewport_h <= 0:
1049
- self._log_viewport(f"_run_layout: skipped empty viewport=({viewport_w},{viewport_h})")
1050
1100
  return
1051
1101
 
1052
1102
  self._layout_pass += 1
1053
- layout_pass = self._layout_pass
1054
- self._log_viewport(
1055
- f"_run_layout: pass#{layout_pass} start viewport=({viewport_w},{viewport_h}) "
1056
- f"root={self._node_debug(self._tree)}"
1057
- )
1058
- layout_root = self._build_layout_tree(self._tree)
1103
+ layout_root = self._build_layout_tree_cached(self._tree)
1059
1104
  if layout_root is None:
1060
- self._log_viewport(f"_run_layout: pass#{layout_pass} no layout root")
1061
1105
  return
1062
1106
 
1063
- self._log_viewport(
1064
- f"_run_layout: pass#{layout_pass} layout root built children={len(layout_root.children)} "
1065
- f"style={layout_root.style!r}"
1066
- )
1067
1107
  viewport = LayoutNode(
1068
1108
  style={"width": viewport_w, "height": viewport_h},
1069
1109
  children=[layout_root],
1070
1110
  )
1071
- self._log_viewport(f"_run_layout: pass#{layout_pass} calling calculate_layout")
1111
+ viewport.dirty = True
1072
1112
  calculate_layout(viewport, viewport_w, viewport_h)
1073
- self._log_viewport(
1074
- f"_run_layout: pass#{layout_pass} calculate_layout done "
1075
- f"root_size=({layout_root.width:.1f},{layout_root.height:.1f})"
1076
- )
1077
1113
  # Skip set_frame for the root itself — descendants are
1078
1114
  # positioned relative to the root's local origin, which is
1079
1115
  # what they want regardless of where the host placed the
1080
1116
  # root in the screen.
1081
1117
  for child in layout_root.children:
1082
- self._apply_layout(child, 0.0, 0.0)
1118
+ self._collect_frames(child, 0.0, 0.0)
1083
1119
  # Lay out the children of every visible ``Modal`` as a fresh
1084
1120
  # subtree sized to the viewport. Modals are excluded from the
1085
1121
  # main layout tree (their content lives in a separately
@@ -1087,7 +1123,7 @@ class Reconciler:
1087
1123
  # children's frames never get computed and the modal renders
1088
1124
  # blank.
1089
1125
  self._layout_visible_modals(self._tree, viewport_w, viewport_h)
1090
- self._log_viewport(f"_run_layout: pass#{layout_pass} done")
1126
+ self._clear_layout_dirty(self._tree)
1091
1127
 
1092
1128
  def _layout_visible_modals(
1093
1129
  self,
@@ -1106,24 +1142,102 @@ class Reconciler:
1106
1142
  )
1107
1143
  calculate_layout(viewport, viewport_w, viewport_h)
1108
1144
  for c in viewport.children:
1109
- self._apply_layout(c, 0.0, 0.0)
1145
+ self._collect_frames(c, 0.0, 0.0)
1110
1146
  return
1111
1147
  for child in vnode.children:
1112
1148
  self._layout_visible_modals(child, viewport_w, viewport_h)
1113
1149
 
1150
+ def _build_layout_tree_cached(self, vnode: VNode) -> Optional[LayoutNode]:
1151
+ """Like `_build_layout_tree` but reuses cached subtrees when clean.
1152
+
1153
+ A VNode's cached `LayoutNode` is reused when the node itself is
1154
+ layout-clean and every child produced its cached node too (i.e.
1155
+ the whole subtree is untouched). Reused nodes keep
1156
+ ``dirty=False`` so the layout engine can serve their sizes from
1157
+ its measurement memo; rebuilt nodes are flagged dirty, which
1158
+ forces fresh flex math along the changed path.
1159
+ """
1160
+ element = vnode.element
1161
+ if not isinstance(element.type, str) or element.type in (
1162
+ "__Provider__",
1163
+ "__ErrorBoundary__",
1164
+ "__Fragment__",
1165
+ ):
1166
+ return self._build_layout_tree_cached(vnode.children[0]) if vnode.children else None
1167
+ if element.type == "Modal":
1168
+ return None # Off-screen placeholder; not part of the visible flow.
1169
+
1170
+ child_layouts: List[LayoutNode] = []
1171
+ for child_vnode in vnode.children:
1172
+ child_layout = self._build_layout_tree_cached(child_vnode)
1173
+ if child_layout is not None:
1174
+ child_layouts.append(child_layout)
1175
+
1176
+ cached = vnode._layout_node
1177
+ if cached is not None and not vnode._layout_dirty:
1178
+ cached_children = self._direct_child_layouts(cached, element)
1179
+ if len(cached_children) == len(child_layouts) and all(
1180
+ a is b for a, b in zip(cached_children, child_layouts)
1181
+ ):
1182
+ return cached
1183
+
1184
+ layout = LayoutNode(style=extract_layout_style(element.props), user_data=vnode)
1185
+ layout.dirty = True
1186
+ if element.type == "ScrollView":
1187
+ # Mark the scroll axis so the layout engine clamps the
1188
+ # container's own main-axis size to its parent's available
1189
+ # space (otherwise the container grows to fit its content
1190
+ # and there is no overflow for the native ScrollView to
1191
+ # actually scroll). The children are still wrapped below so
1192
+ # they see an unbounded main axis when measured.
1193
+ scroll_axis = element.props.get("scroll_axis", "vertical")
1194
+ layout._pn_scroll_axis = "x" if scroll_axis == "horizontal" else "y"
1195
+
1196
+ if not vnode.children:
1197
+ measure = self._make_measure_callback(vnode)
1198
+ if measure is not None:
1199
+ layout.measure = measure
1200
+
1201
+ for child_layout in child_layouts:
1202
+ if element.type == "ScrollView":
1203
+ axis = element.props.get("scroll_axis", "vertical")
1204
+ child_layout = self._wrap_scroll_axis(child_layout, axis="x" if axis == "horizontal" else "y")
1205
+ child_layout.dirty = True
1206
+ layout.children.append(child_layout)
1207
+
1208
+ vnode._layout_node = layout
1209
+ return layout
1210
+
1211
+ @staticmethod
1212
+ def _direct_child_layouts(layout: LayoutNode, element: Element) -> List[LayoutNode]:
1213
+ """Return the cached child layout nodes, unwrapping ScrollView wrappers."""
1214
+ if element.type == "ScrollView":
1215
+ out: List[LayoutNode] = []
1216
+ for wrapper in layout.children:
1217
+ out.extend(wrapper.children)
1218
+ return out
1219
+ return list(layout.children)
1220
+
1221
+ def _clear_layout_dirty(self, vnode: VNode) -> None:
1222
+ vnode._layout_dirty = False
1223
+ node = vnode._layout_node
1224
+ if node is not None:
1225
+ node.dirty = False
1226
+ for wrapper_or_child in node.children:
1227
+ wrapper_or_child.dirty = False
1228
+ for child in vnode.children:
1229
+ self._clear_layout_dirty(child)
1230
+
1114
1231
  def _build_layout_tree(self, vnode: VNode) -> Optional[LayoutNode]:
1115
- """Walk `vnode` and build a parallel `LayoutNode` tree of native nodes.
1232
+ """Build a fresh (uncached) `LayoutNode` tree for ``vnode``.
1116
1233
 
1234
+ Used for Modal content (laid out against the viewport each
1235
+ pass) and by
1236
+ [`compute_layout_for_test`][pythonnative.reconciler.Reconciler.compute_layout_for_test].
1117
1237
  Function components, providers, and error boundaries are
1118
1238
  transparent: they delegate to their (single) child. Native
1119
1239
  nodes contribute a `LayoutNode` whose ``user_data`` points
1120
1240
  back to the VNode so the layout pass can apply frames.
1121
-
1122
- Leaves whose intrinsic size depends on content (Text, Button,
1123
- Image, TextInput) get a measure callback that delegates to
1124
- the backend's ``measure_intrinsic``. ScrollView wraps its
1125
- single child in a synthetic node that strips the scrollable
1126
- axis bound, allowing the child to grow beyond the viewport.
1127
1241
  """
1128
1242
  element = vnode.element
1129
1243
  if not isinstance(element.type, str):
@@ -1131,29 +1245,19 @@ class Reconciler:
1131
1245
  if element.type in ("__Provider__", "__ErrorBoundary__", "__Fragment__"):
1132
1246
  return self._build_layout_tree(vnode.children[0]) if vnode.children else None
1133
1247
  if element.type == "Modal":
1134
- return None # Off-screen placeholder; not part of the visible flow.
1248
+ return None
1135
1249
 
1136
1250
  style = extract_layout_style(element.props)
1137
1251
  layout = LayoutNode(style=style, user_data=vnode)
1252
+ layout.dirty = True
1138
1253
  if element.type == "ScrollView":
1139
- # Mark the scroll axis so the layout engine clamps the
1140
- # container's own main-axis size to its parent's available
1141
- # space (otherwise the container grows to fit its content
1142
- # and there is no overflow for the native ScrollView to
1143
- # actually scroll). The children are still wrapped below so
1144
- # they see an unbounded main axis when measured.
1145
1254
  scroll_axis = element.props.get("scroll_axis", "vertical")
1146
1255
  layout._pn_scroll_axis = "x" if scroll_axis == "horizontal" else "y"
1147
- self._log_viewport(
1148
- f"_build_layout_tree: node type={element.type!r} view={self._obj_debug(vnode.native_view)} "
1149
- f"style={style!r} children={len(vnode.children)}"
1150
- )
1151
1256
 
1152
1257
  if not vnode.children:
1153
1258
  measure = self._make_measure_callback(vnode)
1154
1259
  if measure is not None:
1155
1260
  layout.measure = measure
1156
- self._log_viewport(f"_build_layout_tree: attached measure type={element.type!r}")
1157
1261
 
1158
1262
  for child_vnode in vnode.children:
1159
1263
  child_layout = self._build_layout_tree(child_vnode)
@@ -1163,10 +1267,8 @@ class Reconciler:
1163
1267
  # ScrollView's child sees an unbounded main-axis viewport so it
1164
1268
  # can size to its full content (the scrollable region).
1165
1269
  axis = element.props.get("scroll_axis", "vertical")
1166
- if axis == "horizontal":
1167
- child_layout = self._wrap_scroll_axis(child_layout, axis="x")
1168
- else:
1169
- child_layout = self._wrap_scroll_axis(child_layout, axis="y")
1270
+ child_layout = self._wrap_scroll_axis(child_layout, axis="x" if axis == "horizontal" else "y")
1271
+ child_layout.dirty = True
1170
1272
  layout.children.append(child_layout)
1171
1273
 
1172
1274
  return layout
@@ -1177,93 +1279,61 @@ class Reconciler:
1177
1279
 
1178
1280
  Used by ScrollView to let its content grow beyond the viewport
1179
1281
  on the scroll axis. The wrapper is a transparent layout node
1180
- whose ``user_data`` is the child VNode (so frames still apply
1181
- correctly to the underlying native view).
1282
+ whose ``user_data`` is ``None`` (frames still apply to the
1283
+ underlying native views through the child's own node).
1182
1284
  """
1183
1285
  wrapper_style = {"flex_direction": "column"} if axis == "y" else {"flex_direction": "row"}
1184
1286
  wrapper = LayoutNode(style=wrapper_style, user_data=None)
1185
1287
  wrapper.children.append(child)
1186
1288
  return wrapper
1187
1289
 
1188
- _INTRINSIC_TYPES = frozenset(
1189
- {
1190
- "Text",
1191
- "Button",
1192
- "Image",
1193
- "TextInput",
1194
- "Switch",
1195
- "Slider",
1196
- "ProgressBar",
1197
- "ActivityIndicator",
1198
- "TabBar",
1199
- "Picker",
1200
- }
1201
- )
1202
-
1203
1290
  def _make_measure_callback(self, vnode: VNode) -> Optional[Any]:
1204
1291
  """Return a measure callback for ``vnode`` if it has an intrinsic size."""
1205
1292
  type_name = vnode.element.type
1206
1293
  if type_name not in self._INTRINSIC_TYPES:
1207
1294
  return None
1208
- backend = self.backend
1209
- view = vnode.native_view
1210
- if view is None:
1295
+ if vnode.tag is None:
1211
1296
  return None
1212
- node_label = self._node_debug(vnode)
1297
+ backend = self.backend
1213
1298
 
1214
1299
  def measure(max_w: float, max_h: float) -> Tuple[float, float]:
1215
1300
  cache = vnode._measure_cache
1216
- if cache is not None and cache[0] is vnode.element and cache[1] == max_w and cache[2] == max_h:
1217
- self._log_viewport(f"measure: cache hit type={type_name!r} result=({cache[3]!r},{cache[4]!r})")
1218
- return (cache[3], cache[4])
1301
+ if cache is not None and cache[0] == max_w and cache[1] == max_h:
1302
+ return (cache[2], cache[3])
1219
1303
  try:
1220
- self._log_viewport(
1221
- "measure: before backend.measure_intrinsic " f"{node_label} max=({max_w!r},{max_h!r})"
1222
- )
1223
- w, h = backend.measure_intrinsic(view, type_name, max_w, max_h)
1304
+ w, h = backend.measure_intrinsic(vnode.tag, max_w, max_h)
1224
1305
  result = (float(w), float(h))
1225
- self._log_viewport(f"measure: after backend.measure_intrinsic type={type_name!r} result={result!r}")
1226
- vnode._measure_cache = (vnode.element, max_w, max_h, result[0], result[1])
1306
+ vnode._measure_cache = (max_w, max_h, result[0], result[1])
1227
1307
  return result
1228
- except Exception as e:
1229
- self._log_viewport(
1230
- "measure: backend.measure_intrinsic raised "
1231
- f"type={type_name!r} {type(e).__name__}: {e!r}; fallback=(0,0)"
1232
- )
1308
+ except Exception:
1233
1309
  return (0.0, 0.0)
1234
1310
 
1235
1311
  return measure
1236
1312
 
1237
- def _apply_layout(self, layout_node: LayoutNode, parent_x: float = 0.0, parent_y: float = 0.0) -> None:
1238
- """Walk a positioned layout tree and call ``set_frame`` for each native view.
1313
+ def _collect_frames(self, layout_node: LayoutNode, parent_x: float, parent_y: float) -> None:
1314
+ """Walk a positioned layout tree and emit `SetFrameOp`s for changed frames.
1239
1315
 
1240
1316
  Coordinates accumulate through transparent wrapper nodes
1241
1317
  (e.g., the ScrollView axis wrapper) so the underlying native
1242
1318
  view receives its position relative to its true native parent.
1243
1319
  """
1244
1320
  vnode = layout_node.user_data
1245
- if vnode is not None and vnode.native_view is not None:
1246
- try:
1247
- self._log_viewport(
1248
- "apply_layout: before set_frame "
1249
- f"{self._node_debug(vnode)} frame=("
1250
- f"{layout_node.x + parent_x:.1f},{layout_node.y + parent_y:.1f},"
1251
- f"{layout_node.width:.1f},{layout_node.height:.1f})"
1252
- )
1253
- self.backend.set_frame(
1254
- vnode.native_view,
1255
- vnode.element.type,
1256
- layout_node.x + parent_x,
1257
- layout_node.y + parent_y,
1258
- layout_node.width,
1259
- layout_node.height,
1260
- )
1261
- self._log_viewport(f"apply_layout: after set_frame type={vnode.element.type!r}")
1262
- except Exception as e:
1263
- self._log_viewport(
1264
- "apply_layout: set_frame raised " f"type={vnode.element.type!r} {type(e).__name__}: {e!r}"
1265
- )
1266
- pass
1321
+ if vnode is not None and vnode.tag is not None:
1322
+ frame = (
1323
+ layout_node.x + parent_x,
1324
+ layout_node.y + parent_y,
1325
+ layout_node.width,
1326
+ layout_node.height,
1327
+ )
1328
+ if vnode._last_frame != frame:
1329
+ vnode._last_frame = frame
1330
+ self._ops.append(SetFrameOp(vnode.tag, frame[0], frame[1], frame[2], frame[3]))
1331
+ # Mirror the frame into the element's ref (if any) so
1332
+ # Python code can read measured geometry without a
1333
+ # native round-trip (used by FlatList's virtualization).
1334
+ ref = vnode.element.props.get("ref") if vnode.element.props else None
1335
+ if isinstance(ref, dict):
1336
+ ref["_pn_frame"] = frame
1267
1337
  child_offset_x = 0.0
1268
1338
  child_offset_y = 0.0
1269
1339
  else:
@@ -1271,7 +1341,7 @@ class Reconciler:
1271
1341
  child_offset_y = layout_node.y + parent_y
1272
1342
 
1273
1343
  for child in layout_node.children:
1274
- self._apply_layout(child, child_offset_x, child_offset_y)
1344
+ self._collect_frames(child, child_offset_x, child_offset_y)
1275
1345
 
1276
1346
  # ------------------------------------------------------------------
1277
1347
  # Test / debug accessor