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.
- pythonnative/__init__.py +14 -3
- pythonnative/animated.py +420 -135
- pythonnative/cli/pn.py +450 -956
- pythonnative/components.py +519 -235
- pythonnative/events.py +210 -0
- pythonnative/gestures.py +875 -0
- pythonnative/layout.py +463 -149
- pythonnative/mutations.py +130 -0
- pythonnative/native_views/__init__.py +161 -97
- pythonnative/native_views/android.py +1050 -1124
- pythonnative/native_views/base.py +108 -18
- pythonnative/native_views/desktop.py +460 -417
- pythonnative/native_views/ios.py +1918 -1916
- pythonnative/project/__init__.py +68 -0
- pythonnative/project/android.py +504 -0
- pythonnative/project/builder.py +555 -0
- pythonnative/project/config.py +642 -0
- pythonnative/project/doctor.py +233 -0
- pythonnative/project/icons.py +247 -0
- pythonnative/project/ios.py +344 -0
- pythonnative/project/permissions.py +343 -0
- pythonnative/project/runtime_assets.py +272 -0
- pythonnative/reconciler.py +540 -470
- pythonnative/screen.py +5 -2
- pythonnative/sdk/_components.py +2 -2
- pythonnative/templates/android_template/app/build.gradle +2 -0
- {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/METADATA +10 -2
- {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/RECORD +32 -21
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNVirtualListView.java +0 -129
- {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/WHEEL +0 -0
- {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/licenses/LICENSE +0 -0
- {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/top_level.txt +0 -0
pythonnative/reconciler.py
CHANGED
|
@@ -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
|
|
5
|
-
[`Element`][pythonnative.Element] trees to
|
|
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**
|
|
19
|
-
|
|
20
|
-
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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,
|
|
159
|
+
def __init__(self, element: Element, children: List["VNode"], tag: Optional[int] = None) -> None:
|
|
131
160
|
self.element = element
|
|
132
|
-
self.
|
|
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
|
-
# ``(
|
|
140
|
-
#
|
|
141
|
-
#
|
|
142
|
-
|
|
143
|
-
#
|
|
144
|
-
#
|
|
145
|
-
|
|
146
|
-
|
|
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]
|
|
153
|
-
[`reconcile`][pythonnative.reconciler.Reconciler.reconcile]
|
|
154
|
-
reconciler
|
|
155
|
-
|
|
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
|
|
159
|
-
(
|
|
160
|
-
|
|
161
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
221
|
-
self.
|
|
222
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
356
|
-
self.
|
|
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
|
-
|
|
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.
|
|
384
|
-
self.
|
|
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
|
|
408
|
-
"""Propagate a
|
|
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
|
|
412
|
-
changed type), the change must be reflected in (a) every
|
|
413
|
-
transparent ancestor that delegated its
|
|
414
|
-
|
|
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.
|
|
558
|
+
if self._is_native_node(node):
|
|
420
559
|
try:
|
|
421
|
-
idx
|
|
560
|
+
idx = node.children.index(child)
|
|
422
561
|
except ValueError:
|
|
423
|
-
idx =
|
|
424
|
-
if
|
|
425
|
-
self.
|
|
426
|
-
|
|
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
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
#
|
|
440
|
-
#
|
|
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
|
|
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
|
|
474
|
-
"""Emit optional
|
|
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
|
-
#
|
|
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
|
-
|
|
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,
|
|
626
|
+
return VNode(element, [])
|
|
591
627
|
child_node = self._create_tree(kids[0])
|
|
592
|
-
|
|
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,
|
|
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
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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
|
-
|
|
836
|
-
|
|
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
|
-
|
|
870
|
-
|
|
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
|
-
|
|
886
|
-
|
|
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
|
-
|
|
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
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
for
|
|
927
|
-
|
|
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
|
-
|
|
956
|
-
|
|
957
|
-
|
|
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
|
|
962
|
-
|
|
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
|
|
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
|
-
"""
|
|
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
|
|
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
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
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``).
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
"""
|
|
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
|
|
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
|
-
|
|
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
|
|
1181
|
-
|
|
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
|
-
|
|
1209
|
-
view = vnode.native_view
|
|
1210
|
-
if view is None:
|
|
1295
|
+
if vnode.tag is None:
|
|
1211
1296
|
return None
|
|
1212
|
-
|
|
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]
|
|
1217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1238
|
-
"""Walk a positioned layout tree and
|
|
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.
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
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.
|
|
1344
|
+
self._collect_frames(child, child_offset_x, child_offset_y)
|
|
1275
1345
|
|
|
1276
1346
|
# ------------------------------------------------------------------
|
|
1277
1347
|
# Test / debug accessor
|