pythonnative 0.23.0__py3-none-any.whl → 0.25.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 +44 -53
- pythonnative/animated.py +3 -3
- pythonnative/cli/pn.py +3 -3
- pythonnative/components.py +309 -513
- pythonnative/diagnostics.py +214 -0
- pythonnative/element.py +5 -2
- pythonnative/events.py +13 -8
- pythonnative/hooks.py +761 -114
- pythonnative/hot_reload.py +9 -1
- pythonnative/native_modules/notifications.py +36 -4
- pythonnative/native_modules/permissions.py +24 -1
- pythonnative/native_views/android.py +85 -3
- pythonnative/native_views/desktop.py +38 -1
- pythonnative/native_views/ios.py +252 -7
- pythonnative/navigation.py +41 -17
- pythonnative/preview.py +43 -7
- pythonnative/reconciler.py +1360 -457
- pythonnative/runtime.py +407 -215
- pythonnative/screen.py +377 -34
- pythonnative/storage.py +5 -5
- pythonnative/style.py +83 -0
- pythonnative/suspense.py +417 -0
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/ScreenFragment.kt +22 -0
- {pythonnative-0.23.0.dist-info → pythonnative-0.25.0.dist-info}/METADATA +6 -4
- {pythonnative-0.23.0.dist-info → pythonnative-0.25.0.dist-info}/RECORD +29 -27
- {pythonnative-0.23.0.dist-info → pythonnative-0.25.0.dist-info}/WHEEL +1 -1
- {pythonnative-0.23.0.dist-info → pythonnative-0.25.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.23.0.dist-info → pythonnative-0.25.0.dist-info}/licenses/LICENSE +0 -0
- {pythonnative-0.23.0.dist-info → pythonnative-0.25.0.dist-info}/top_level.txt +0 -0
pythonnative/hooks.py
CHANGED
|
@@ -4,12 +4,26 @@ Provides React-like hooks for managing state, effects, memoization,
|
|
|
4
4
|
context, and navigation within function components decorated with
|
|
5
5
|
[`component`][pythonnative.component]. Hooks must be called at the top
|
|
6
6
|
level of a component (not inside conditionals or loops) so they map to
|
|
7
|
-
the same slot across renders.
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
effect
|
|
12
|
-
|
|
7
|
+
the same slot across renders. In dev mode the framework verifies this
|
|
8
|
+
and raises [`HookOrderError`][pythonnative.diagnostics.HookOrderError]
|
|
9
|
+
on a violation instead of silently cross-wiring state.
|
|
10
|
+
|
|
11
|
+
Two effect phases exist, mirroring React:
|
|
12
|
+
|
|
13
|
+
- [`use_layout_effect`][pythonnative.use_layout_effect] callbacks run
|
|
14
|
+
synchronously inside the commit, after native mutations and the
|
|
15
|
+
layout pass have been applied. They can measure committed frames and
|
|
16
|
+
issue imperative view commands before the user sees the new frame.
|
|
17
|
+
- [`use_effect`][pythonnative.use_effect] callbacks (passive effects)
|
|
18
|
+
run after the layout effects, at the end of the same commit. An
|
|
19
|
+
effect may be an ``async def``; it runs as a task on the framework
|
|
20
|
+
loop and is cancelled when its dependencies change or the component
|
|
21
|
+
unmounts.
|
|
22
|
+
|
|
23
|
+
The current hook state travels in a :mod:`contextvars` context rather
|
|
24
|
+
than a plain global, so ``async def`` component bodies keep their hook
|
|
25
|
+
identity across ``await`` boundaries even when several coroutine
|
|
26
|
+
renders interleave on the event loop.
|
|
13
27
|
|
|
14
28
|
Example:
|
|
15
29
|
```python
|
|
@@ -20,27 +34,83 @@ Example:
|
|
|
20
34
|
count, set_count = pn.use_state(initial)
|
|
21
35
|
return pn.Column(
|
|
22
36
|
pn.Text(f"Count: {count}"),
|
|
23
|
-
pn.Button("+",
|
|
37
|
+
pn.Button("+", on_press=lambda: set_count(count + 1)),
|
|
24
38
|
)
|
|
25
39
|
```
|
|
26
40
|
"""
|
|
27
41
|
|
|
28
42
|
import asyncio
|
|
29
43
|
import inspect
|
|
30
|
-
import threading
|
|
31
44
|
from contextlib import contextmanager
|
|
45
|
+
from contextvars import ContextVar, Token
|
|
32
46
|
from dataclasses import dataclass, field, replace
|
|
33
47
|
from typing import Any, Awaitable, Callable, Dict, Generator, Generic, List, Optional, Tuple, TypeVar
|
|
34
48
|
|
|
49
|
+
from . import diagnostics
|
|
35
50
|
from .element import Element
|
|
51
|
+
from .suspense import CoroDriver, Resource
|
|
36
52
|
|
|
37
53
|
T = TypeVar("T")
|
|
38
54
|
|
|
39
55
|
_SENTINEL = object()
|
|
40
56
|
|
|
41
|
-
|
|
57
|
+
# The component whose body is currently executing. A ContextVar (not a
|
|
58
|
+
# global or thread-local) so coroutine component bodies resume with the
|
|
59
|
+
# right hook state after every ``await``, no matter how renders
|
|
60
|
+
# interleave on the loop.
|
|
61
|
+
_hook_context: "ContextVar[Optional[HookState]]" = ContextVar("pn_hook_state", default=None)
|
|
62
|
+
|
|
63
|
+
# Depth of nested ``batch_updates`` blocks plus the trigger deferred by
|
|
64
|
+
# the outermost block, per execution context.
|
|
65
|
+
_batch_depth: "ContextVar[int]" = ContextVar("pn_batch_depth", default=0)
|
|
66
|
+
_batch_pending: "ContextVar[Optional[List[Callable[[], None]]]]" = ContextVar("pn_batch_pending", default=None)
|
|
67
|
+
|
|
68
|
+
# Whether state updates in the current execution context were marked as
|
|
69
|
+
# transitions (see ``use_transition``). Transition updates defer their
|
|
70
|
+
# re-render to a later loop turn so urgent updates stay responsive.
|
|
71
|
+
_transition_var: "ContextVar[bool]" = ContextVar("pn_transition", default=False)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ======================================================================
|
|
75
|
+
# Ref
|
|
76
|
+
# ======================================================================
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Ref(Generic[T]):
|
|
80
|
+
"""Mutable container returned by [`use_ref`][pythonnative.use_ref].
|
|
81
|
+
|
|
82
|
+
A ``Ref`` holds one value on its ``current`` attribute. Mutating
|
|
83
|
+
``current`` never triggers a re-render, which makes refs the right
|
|
84
|
+
place for timers, last-seen values, and imperative handles.
|
|
85
|
+
|
|
86
|
+
When a ``Ref`` is passed to a built-in element via the ``ref=``
|
|
87
|
+
prop, the reconciler populates ``current`` with the underlying
|
|
88
|
+
native view (``UIView`` on iOS, ``android.view.View`` on Android,
|
|
89
|
+
a Tk widget on desktop) after commit, and clears it back to
|
|
90
|
+
``None`` on unmount. Composite components (e.g.
|
|
91
|
+
[`FlatList`][pythonnative.FlatList]) instead publish a typed
|
|
92
|
+
controller object on ``current`` via
|
|
93
|
+
[`use_imperative_handle`][pythonnative.use_imperative_handle].
|
|
94
|
+
|
|
95
|
+
Attributes:
|
|
96
|
+
current: The referenced value. ``None`` until populated.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
__slots__ = ("current", "_pn_tag", "_pn_frame")
|
|
100
|
+
|
|
101
|
+
def __init__(self, initial: Optional[T] = None) -> None:
|
|
102
|
+
self.current: Optional[T] = initial
|
|
103
|
+
# Internal: the native view tag, populated by the reconciler
|
|
104
|
+
# when the ref is attached to a built-in element.
|
|
105
|
+
self._pn_tag: Optional[int] = None
|
|
106
|
+
# Internal: the last committed frame ``(x, y, w, h)``, mirrored
|
|
107
|
+
# by the layout pass so Python code can read measured geometry
|
|
108
|
+
# without a native round-trip.
|
|
109
|
+
self._pn_frame: Optional[Tuple[float, float, float, float]] = None
|
|
110
|
+
|
|
111
|
+
def __repr__(self) -> str:
|
|
112
|
+
return f"Ref({self.current!r})"
|
|
42
113
|
|
|
43
|
-
_batch_context: threading.local = threading.local()
|
|
44
114
|
|
|
45
115
|
# ======================================================================
|
|
46
116
|
# Hook state container
|
|
@@ -53,44 +123,69 @@ class HookState:
|
|
|
53
123
|
Each `@component` instance owns one `HookState`. Hooks are matched
|
|
54
124
|
to slots by call order, so they must always be called in the same
|
|
55
125
|
order across renders. Effects scheduled during render are deferred
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
126
|
+
(layout effects into ``_pending_layout_effects``, passive effects
|
|
127
|
+
into ``_pending_effects``) and flushed by the reconciler in two
|
|
128
|
+
phases after native mutations commit.
|
|
59
129
|
|
|
60
130
|
Attributes:
|
|
61
131
|
states: One entry per `use_state` / `use_reducer` call.
|
|
62
132
|
effects: One `(deps, cleanup)` tuple per `use_effect` call.
|
|
133
|
+
layout_effects: One `(deps, cleanup)` tuple per
|
|
134
|
+
`use_layout_effect` call.
|
|
63
135
|
memos: One `(deps, value)` tuple per `use_memo` / `use_callback`.
|
|
64
|
-
refs: One
|
|
136
|
+
refs: One [`Ref`][pythonnative.Ref] per `use_ref` call.
|
|
65
137
|
"""
|
|
66
138
|
|
|
67
139
|
__slots__ = (
|
|
68
140
|
"states",
|
|
69
141
|
"effects",
|
|
142
|
+
"layout_effects",
|
|
70
143
|
"memos",
|
|
71
144
|
"refs",
|
|
145
|
+
"resources",
|
|
72
146
|
"state_index",
|
|
73
147
|
"effect_index",
|
|
148
|
+
"layout_effect_index",
|
|
74
149
|
"memo_index",
|
|
75
150
|
"ref_index",
|
|
151
|
+
"resource_index",
|
|
152
|
+
"context_deps",
|
|
76
153
|
"_trigger_render",
|
|
77
154
|
"_pending_effects",
|
|
155
|
+
"_pending_layout_effects",
|
|
156
|
+
"_pending_effects_mark",
|
|
157
|
+
"_pending_layout_effects_mark",
|
|
78
158
|
"_dirty",
|
|
79
159
|
"_vnode",
|
|
80
160
|
"_reconciler",
|
|
161
|
+
"_hook_log",
|
|
162
|
+
"_hook_signature",
|
|
163
|
+
"_component_name",
|
|
164
|
+
"_async_driver",
|
|
81
165
|
)
|
|
82
166
|
|
|
83
167
|
def __init__(self) -> None:
|
|
84
168
|
self.states: List[Any] = []
|
|
85
169
|
self.effects: List[Tuple[Any, Any]] = []
|
|
170
|
+
self.layout_effects: List[Tuple[Any, Any]] = []
|
|
86
171
|
self.memos: List[Tuple[Any, Any]] = []
|
|
87
|
-
self.refs: List[
|
|
172
|
+
self.refs: List[Ref] = []
|
|
173
|
+
# One ``(deps, Resource)`` per ``use_resource`` call.
|
|
174
|
+
self.resources: List[Tuple[Any, Resource]] = []
|
|
88
175
|
self.state_index: int = 0
|
|
89
176
|
self.effect_index: int = 0
|
|
177
|
+
self.layout_effect_index: int = 0
|
|
90
178
|
self.memo_index: int = 0
|
|
91
179
|
self.ref_index: int = 0
|
|
180
|
+
self.resource_index: int = 0
|
|
181
|
+
# Contexts read during the last completed render, keyed by
|
|
182
|
+
# ``id(context)``. The reconciler consults this when a
|
|
183
|
+
# Provider's value changes so consumers re-render even when a
|
|
184
|
+
# memoized ancestor skipped (reactive context).
|
|
185
|
+
self.context_deps: Dict[int, Any] = {}
|
|
92
186
|
self._trigger_render: Optional[Callable[[], None]] = None
|
|
93
187
|
self._pending_effects: List[Tuple[int, Callable, Any]] = []
|
|
188
|
+
self._pending_layout_effects: List[Tuple[int, Callable, Any]] = []
|
|
94
189
|
# Cleared by the reconciler after each successful render.
|
|
95
190
|
# ``use_state`` / ``use_reducer`` setters flip it to ``True``
|
|
96
191
|
# whenever they actually mutate state, so [`memo`][pythonnative.memo]
|
|
@@ -104,28 +199,140 @@ class HookState:
|
|
|
104
199
|
# again when it unmounts.
|
|
105
200
|
self._vnode: Any = None
|
|
106
201
|
self._reconciler: Any = None
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
202
|
+
# Dev-mode hook-order guard: the sequence of hook kinds called
|
|
203
|
+
# during the in-flight render, and the signature captured from
|
|
204
|
+
# the first successful render.
|
|
205
|
+
self._hook_log: Optional[List[str]] = None
|
|
206
|
+
self._hook_signature: Optional[List[str]] = None
|
|
207
|
+
self._component_name: str = ""
|
|
208
|
+
# For ``async def`` components: the CoroDriver running the
|
|
209
|
+
# in-flight body, cancelled when a newer render supersedes it.
|
|
210
|
+
self._async_driver: Optional[CoroDriver] = None
|
|
211
|
+
# Effect-queue lengths at ``begin_render``, so a suspended
|
|
212
|
+
# render can be rolled back without double-queueing effects.
|
|
213
|
+
self._pending_effects_mark: int = 0
|
|
214
|
+
self._pending_layout_effects_mark: int = 0
|
|
215
|
+
|
|
216
|
+
def begin_render(self, component_name: str = "") -> None:
|
|
217
|
+
"""Prepare for a render pass: reset cursors and the dev-mode hook log.
|
|
218
|
+
|
|
219
|
+
Called by the reconciler before each invocation of the
|
|
220
|
+
component body so the next render reads slots in the same
|
|
221
|
+
order they were written.
|
|
114
222
|
"""
|
|
115
223
|
self.state_index = 0
|
|
116
224
|
self.effect_index = 0
|
|
225
|
+
self.layout_effect_index = 0
|
|
117
226
|
self.memo_index = 0
|
|
118
227
|
self.ref_index = 0
|
|
228
|
+
self.resource_index = 0
|
|
229
|
+
self.context_deps = {}
|
|
230
|
+
if component_name:
|
|
231
|
+
self._component_name = component_name
|
|
232
|
+
self._hook_log = [] if diagnostics.is_dev() else None
|
|
233
|
+
self._pending_effects_mark = len(self._pending_effects)
|
|
234
|
+
self._pending_layout_effects_mark = len(self._pending_layout_effects)
|
|
235
|
+
|
|
236
|
+
def abort_render(self) -> None:
|
|
237
|
+
"""Roll back a suspended render's effect queue.
|
|
238
|
+
|
|
239
|
+
A suspended body re-runs from the top on retry, so any effects
|
|
240
|
+
it queued before suspending would otherwise be queued twice.
|
|
241
|
+
"""
|
|
242
|
+
del self._pending_effects[self._pending_effects_mark :]
|
|
243
|
+
del self._pending_layout_effects[self._pending_layout_effects_mark :]
|
|
244
|
+
self._hook_log = None
|
|
245
|
+
|
|
246
|
+
def finish_render(self) -> None:
|
|
247
|
+
"""Finalize a successful render: lock in / verify the hook signature.
|
|
248
|
+
|
|
249
|
+
Only called when the component body returned without raising,
|
|
250
|
+
so a failed render never corrupts the signature.
|
|
251
|
+
|
|
252
|
+
Raises:
|
|
253
|
+
HookOrderError: In dev mode, when this render called fewer
|
|
254
|
+
hooks than the previous one.
|
|
255
|
+
"""
|
|
256
|
+
log = self._hook_log
|
|
257
|
+
self._hook_log = None
|
|
258
|
+
if log is None:
|
|
259
|
+
return
|
|
260
|
+
if self._hook_signature is None:
|
|
261
|
+
self._hook_signature = log
|
|
262
|
+
return
|
|
263
|
+
if len(log) < len(self._hook_signature):
|
|
264
|
+
missing = self._hook_signature[len(log)]
|
|
265
|
+
raise diagnostics.HookOrderError(
|
|
266
|
+
f"{self._component_name or 'Component'} rendered fewer hooks than the previous "
|
|
267
|
+
f"render (expected {missing!r} at position {len(log) + 1}). Hooks must be called "
|
|
268
|
+
"unconditionally, in the same order, on every render."
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
def record_hook(self, kind: str) -> None:
|
|
272
|
+
"""Record a hook call for the dev-mode order guard.
|
|
273
|
+
|
|
274
|
+
Raises:
|
|
275
|
+
HookOrderError: In dev mode, when the hook at this position
|
|
276
|
+
differs from (or extends past) the previous render.
|
|
277
|
+
"""
|
|
278
|
+
log = self._hook_log
|
|
279
|
+
if log is None:
|
|
280
|
+
return
|
|
281
|
+
position = len(log)
|
|
282
|
+
log.append(kind)
|
|
283
|
+
signature = self._hook_signature
|
|
284
|
+
if signature is None:
|
|
285
|
+
return
|
|
286
|
+
if position >= len(signature):
|
|
287
|
+
raise diagnostics.HookOrderError(
|
|
288
|
+
f"{self._component_name or 'Component'} rendered more hooks than the previous "
|
|
289
|
+
f"render ({kind!r} at position {position + 1}). Hooks must be called "
|
|
290
|
+
"unconditionally, in the same order, on every render."
|
|
291
|
+
)
|
|
292
|
+
if signature[position] != kind:
|
|
293
|
+
raise diagnostics.HookOrderError(
|
|
294
|
+
f"{self._component_name or 'Component'} called {kind!r} at position "
|
|
295
|
+
f"{position + 1}, but the previous render called {signature[position]!r} there. "
|
|
296
|
+
"Hooks must be called unconditionally, in the same order, on every render."
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
def reset_hook_signature(self) -> None:
|
|
300
|
+
"""Forget the recorded hook signature (used by Fast Refresh).
|
|
301
|
+
|
|
302
|
+
After a hot reload swaps in a new component body, the old
|
|
303
|
+
signature no longer applies; the next render records a fresh
|
|
304
|
+
one.
|
|
305
|
+
"""
|
|
306
|
+
self._hook_signature = None
|
|
307
|
+
|
|
308
|
+
def flush_layout_effects(self) -> None:
|
|
309
|
+
"""Run layout effects queued during render (commit phase, pre-paint)."""
|
|
310
|
+
pending = self._pending_layout_effects
|
|
311
|
+
self._pending_layout_effects = []
|
|
312
|
+
self._pending_layout_effects_mark = 0
|
|
313
|
+
for idx, effect_fn, deps in pending:
|
|
314
|
+
_, prev_cleanup = self.layout_effects[idx]
|
|
315
|
+
if callable(prev_cleanup):
|
|
316
|
+
try:
|
|
317
|
+
prev_cleanup()
|
|
318
|
+
except Exception:
|
|
319
|
+
pass
|
|
320
|
+
cleanup = _activate_effect(effect_fn)
|
|
321
|
+
self.layout_effects[idx] = (list(deps) if deps is not None else None, cleanup)
|
|
119
322
|
|
|
120
323
|
def flush_pending_effects(self) -> None:
|
|
121
|
-
"""Run effects queued during render, after native commit.
|
|
324
|
+
"""Run passive effects queued during render, after native commit.
|
|
122
325
|
|
|
123
326
|
For each pending effect, the previous cleanup is invoked first
|
|
124
327
|
(if any), then the new effect callback. The new return value
|
|
125
|
-
becomes the next cleanup.
|
|
328
|
+
becomes the next cleanup. Effects that are ``async def`` (or
|
|
329
|
+
that return an awaitable) run as tasks on the framework loop;
|
|
330
|
+
their cleanup cancels the task, and a callable returned by the
|
|
331
|
+
coroutine runs as an additional cleanup once it completed.
|
|
126
332
|
"""
|
|
127
333
|
pending = self._pending_effects
|
|
128
334
|
self._pending_effects = []
|
|
335
|
+
self._pending_effects_mark = 0
|
|
129
336
|
for idx, effect_fn, deps in pending:
|
|
130
337
|
_, prev_cleanup = self.effects[idx]
|
|
131
338
|
if callable(prev_cleanup):
|
|
@@ -133,16 +340,25 @@ class HookState:
|
|
|
133
340
|
prev_cleanup()
|
|
134
341
|
except Exception:
|
|
135
342
|
pass
|
|
136
|
-
cleanup = effect_fn
|
|
343
|
+
cleanup = _activate_effect(effect_fn)
|
|
137
344
|
self.effects[idx] = (list(deps) if deps is not None else None, cleanup)
|
|
138
345
|
|
|
139
346
|
def cleanup_all_effects(self) -> None:
|
|
140
347
|
"""Run every outstanding cleanup function, then clear state.
|
|
141
348
|
|
|
142
|
-
|
|
143
|
-
|
|
349
|
+
Layout-effect cleanups run before passive-effect cleanups,
|
|
350
|
+
matching the mount order in reverse. Also cancels in-flight
|
|
351
|
+
resources and any pending ``async def`` body. Called when the
|
|
352
|
+
component instance is unmounted by the reconciler.
|
|
144
353
|
"""
|
|
145
|
-
for i, (
|
|
354
|
+
for i, (_deps, cleanup) in enumerate(self.layout_effects):
|
|
355
|
+
if callable(cleanup):
|
|
356
|
+
try:
|
|
357
|
+
cleanup()
|
|
358
|
+
except Exception:
|
|
359
|
+
pass
|
|
360
|
+
self.layout_effects[i] = (_SENTINEL, None)
|
|
361
|
+
for i, (_deps, cleanup) in enumerate(self.effects):
|
|
146
362
|
if callable(cleanup):
|
|
147
363
|
try:
|
|
148
364
|
cleanup()
|
|
@@ -150,21 +366,39 @@ class HookState:
|
|
|
150
366
|
pass
|
|
151
367
|
self.effects[i] = (_SENTINEL, None)
|
|
152
368
|
self._pending_effects = []
|
|
369
|
+
self._pending_layout_effects = []
|
|
370
|
+
self._pending_effects_mark = 0
|
|
371
|
+
self._pending_layout_effects_mark = 0
|
|
372
|
+
for _deps, resource in self.resources:
|
|
373
|
+
try:
|
|
374
|
+
resource.cancel()
|
|
375
|
+
except Exception:
|
|
376
|
+
pass
|
|
377
|
+
self.resources = []
|
|
378
|
+
driver = self._async_driver
|
|
379
|
+
self._async_driver = None
|
|
380
|
+
if driver is not None:
|
|
381
|
+
driver.cancel()
|
|
153
382
|
|
|
154
383
|
|
|
155
384
|
# ======================================================================
|
|
156
|
-
#
|
|
385
|
+
# Context helpers
|
|
157
386
|
# ======================================================================
|
|
158
387
|
|
|
159
388
|
|
|
160
389
|
def _get_hook_state() -> Optional[HookState]:
|
|
161
390
|
"""Return the active `HookState`, or `None` if no render is in flight."""
|
|
162
|
-
return
|
|
391
|
+
return _hook_context.get()
|
|
163
392
|
|
|
164
393
|
|
|
165
|
-
def _set_hook_state(state: Optional[HookState]) ->
|
|
166
|
-
"""Install `state` as the active `HookState
|
|
167
|
-
_hook_context.
|
|
394
|
+
def _set_hook_state(state: Optional[HookState]) -> "Token[Optional[HookState]]":
|
|
395
|
+
"""Install `state` as the active `HookState`; returns the reset token."""
|
|
396
|
+
return _hook_context.set(state)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _reset_hook_state(token: "Token[Optional[HookState]]") -> None:
|
|
400
|
+
"""Restore the hook state that was active before `_set_hook_state`."""
|
|
401
|
+
_hook_context.reset(token)
|
|
168
402
|
|
|
169
403
|
|
|
170
404
|
def _deps_changed(prev: Any, current: Any) -> bool:
|
|
@@ -178,19 +412,126 @@ def _deps_changed(prev: Any, current: Any) -> bool:
|
|
|
178
412
|
return any(p is not c and p != c for p, c in zip(prev, current))
|
|
179
413
|
|
|
180
414
|
|
|
415
|
+
def _activate_effect(effect_fn: Callable) -> Any:
|
|
416
|
+
"""Invoke an effect callback, running coroutine effects as tasks.
|
|
417
|
+
|
|
418
|
+
Synchronous effects return their cleanup directly. When the effect
|
|
419
|
+
is an ``async def`` (or returns an awaitable), the coroutine runs
|
|
420
|
+
as a task on the framework loop and the returned cleanup cancels
|
|
421
|
+
it; if the coroutine already finished and returned a callable, that
|
|
422
|
+
callable runs as the cleanup instead.
|
|
423
|
+
"""
|
|
424
|
+
result = effect_fn()
|
|
425
|
+
if not inspect.isawaitable(result):
|
|
426
|
+
return result
|
|
427
|
+
|
|
428
|
+
from .runtime import run_async
|
|
429
|
+
|
|
430
|
+
future = run_async(result)
|
|
431
|
+
|
|
432
|
+
def _observe(fut: Any) -> None:
|
|
433
|
+
# Surface unhandled async-effect crashes instead of letting the
|
|
434
|
+
# future's exception vanish unobserved: RedBox in dev mode,
|
|
435
|
+
# traceback in production.
|
|
436
|
+
if fut.cancelled():
|
|
437
|
+
return
|
|
438
|
+
exc = fut.exception()
|
|
439
|
+
if exc is None or isinstance(exc, asyncio.CancelledError):
|
|
440
|
+
return
|
|
441
|
+
if not diagnostics.report_error(exc, phase="async effect"):
|
|
442
|
+
import traceback
|
|
443
|
+
|
|
444
|
+
traceback.print_exception(type(exc), exc, exc.__traceback__)
|
|
445
|
+
|
|
446
|
+
future.add_done_callback(_observe)
|
|
447
|
+
|
|
448
|
+
def _cleanup() -> None:
|
|
449
|
+
if future.cancelled():
|
|
450
|
+
return
|
|
451
|
+
if future.done():
|
|
452
|
+
if future.exception() is None:
|
|
453
|
+
returned = future.result()
|
|
454
|
+
if callable(returned):
|
|
455
|
+
try:
|
|
456
|
+
returned()
|
|
457
|
+
except Exception:
|
|
458
|
+
pass
|
|
459
|
+
return
|
|
460
|
+
future.cancel()
|
|
461
|
+
|
|
462
|
+
return _cleanup
|
|
463
|
+
|
|
464
|
+
|
|
181
465
|
# ======================================================================
|
|
182
|
-
# Batching
|
|
466
|
+
# Batching and transition scheduling
|
|
183
467
|
# ======================================================================
|
|
184
468
|
|
|
185
469
|
|
|
186
470
|
def _schedule_trigger(trigger: Callable[[], None]) -> None:
|
|
187
471
|
"""Run ``trigger`` immediately, or defer it inside a `batch_updates` block."""
|
|
188
|
-
|
|
189
|
-
|
|
472
|
+
pending = _batch_pending.get()
|
|
473
|
+
if _batch_depth.get() > 0 and pending is not None:
|
|
474
|
+
if trigger not in pending:
|
|
475
|
+
pending.append(trigger)
|
|
190
476
|
else:
|
|
191
477
|
trigger()
|
|
192
478
|
|
|
193
479
|
|
|
480
|
+
# Transition scheduling state. Deferred triggers (and the callbacks
|
|
481
|
+
# that flip ``is_pending`` back off) run together on a later loop turn.
|
|
482
|
+
_deferred_triggers: List[Callable[[], None]] = []
|
|
483
|
+
_post_transition_callbacks: List[Callable[[], None]] = []
|
|
484
|
+
_transition_flush_scheduled = False
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _flush_transitions() -> None:
|
|
488
|
+
"""Run every deferred transition render, then the completion callbacks."""
|
|
489
|
+
global _transition_flush_scheduled
|
|
490
|
+
_transition_flush_scheduled = False
|
|
491
|
+
triggers = list(_deferred_triggers)
|
|
492
|
+
_deferred_triggers.clear()
|
|
493
|
+
callbacks = list(_post_transition_callbacks)
|
|
494
|
+
_post_transition_callbacks.clear()
|
|
495
|
+
for trigger in triggers:
|
|
496
|
+
try:
|
|
497
|
+
trigger()
|
|
498
|
+
except Exception as exc:
|
|
499
|
+
if not diagnostics.report_error(exc, phase="transition"):
|
|
500
|
+
raise
|
|
501
|
+
for callback in callbacks:
|
|
502
|
+
try:
|
|
503
|
+
callback()
|
|
504
|
+
except Exception as exc:
|
|
505
|
+
if not diagnostics.report_error(exc, phase="transition"):
|
|
506
|
+
raise
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _schedule_transition_flush() -> None:
|
|
510
|
+
"""Ensure a transition flush is queued on the framework loop."""
|
|
511
|
+
global _transition_flush_scheduled
|
|
512
|
+
if _transition_flush_scheduled:
|
|
513
|
+
return
|
|
514
|
+
_transition_flush_scheduled = True
|
|
515
|
+
from .runtime import get_loop
|
|
516
|
+
|
|
517
|
+
get_loop().call_soon(_flush_transitions)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _defer_transition_trigger(trigger: Callable[[], None]) -> None:
|
|
521
|
+
if trigger not in _deferred_triggers:
|
|
522
|
+
_deferred_triggers.append(trigger)
|
|
523
|
+
_schedule_transition_flush()
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _run_in_transition(fn: Callable[[], None]) -> None:
|
|
527
|
+
"""Run ``fn`` with its state updates marked as transitions."""
|
|
528
|
+
token = _transition_var.set(True)
|
|
529
|
+
try:
|
|
530
|
+
fn()
|
|
531
|
+
finally:
|
|
532
|
+
_transition_var.reset(token)
|
|
533
|
+
|
|
534
|
+
|
|
194
535
|
def _notify_state_changed(ctx: "HookState") -> None:
|
|
195
536
|
"""Mark ``ctx``'s component dirty and schedule a render after a state change.
|
|
196
537
|
|
|
@@ -199,7 +540,9 @@ def _notify_state_changed(ctx: "HookState") -> None:
|
|
|
199
540
|
``flush_dirty``, which re-renders only the components marked here
|
|
200
541
|
rather than the whole app. The dirty mark is eager (so several
|
|
201
542
|
setters coalesce), while the render trigger respects
|
|
202
|
-
[`batch_updates`][pythonnative.batch_updates]
|
|
543
|
+
[`batch_updates`][pythonnative.batch_updates] and defers to a later
|
|
544
|
+
loop turn inside a transition (see
|
|
545
|
+
[`use_transition`][pythonnative.use_transition]).
|
|
203
546
|
"""
|
|
204
547
|
ctx._dirty = True
|
|
205
548
|
reconciler = ctx._reconciler
|
|
@@ -207,7 +550,10 @@ def _notify_state_changed(ctx: "HookState") -> None:
|
|
|
207
550
|
if reconciler is not None and vnode is not None:
|
|
208
551
|
reconciler.mark_dirty(vnode)
|
|
209
552
|
if ctx._trigger_render:
|
|
210
|
-
|
|
553
|
+
if _transition_var.get():
|
|
554
|
+
_defer_transition_trigger(ctx._trigger_render)
|
|
555
|
+
else:
|
|
556
|
+
_schedule_trigger(ctx._trigger_render)
|
|
211
557
|
|
|
212
558
|
|
|
213
559
|
@contextmanager
|
|
@@ -231,18 +577,19 @@ def batch_updates() -> Generator[None, None, None]:
|
|
|
231
577
|
set_name("hello")
|
|
232
578
|
```
|
|
233
579
|
"""
|
|
234
|
-
depth =
|
|
235
|
-
|
|
580
|
+
depth = _batch_depth.get()
|
|
581
|
+
depth_token = _batch_depth.set(depth + 1)
|
|
582
|
+
pending_token = None
|
|
236
583
|
if depth == 0:
|
|
237
|
-
|
|
584
|
+
pending_token = _batch_pending.set([])
|
|
238
585
|
try:
|
|
239
586
|
yield
|
|
240
587
|
finally:
|
|
241
|
-
|
|
242
|
-
if
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
588
|
+
_batch_depth.reset(depth_token)
|
|
589
|
+
if pending_token is not None:
|
|
590
|
+
triggers = _batch_pending.get() or []
|
|
591
|
+
_batch_pending.reset(pending_token)
|
|
592
|
+
for trigger in triggers:
|
|
246
593
|
trigger()
|
|
247
594
|
|
|
248
595
|
|
|
@@ -278,13 +625,14 @@ def use_state(initial: Any = None) -> Tuple[Any, Callable]:
|
|
|
278
625
|
count, set_count = pn.use_state(0)
|
|
279
626
|
return pn.Button(
|
|
280
627
|
f"Count: {count}",
|
|
281
|
-
|
|
628
|
+
on_press=lambda: set_count(count + 1),
|
|
282
629
|
)
|
|
283
630
|
```
|
|
284
631
|
"""
|
|
285
632
|
ctx = _get_hook_state()
|
|
286
633
|
if ctx is None:
|
|
287
634
|
raise RuntimeError("use_state must be called inside a @component function")
|
|
635
|
+
ctx.record_hook("use_state")
|
|
288
636
|
|
|
289
637
|
idx = ctx.state_index
|
|
290
638
|
ctx.state_index += 1
|
|
@@ -343,14 +691,15 @@ def use_reducer(reducer: Callable[[Any, Any], Any], initial_state: Any) -> Tuple
|
|
|
343
691
|
def Counter():
|
|
344
692
|
count, dispatch = pn.use_reducer(reducer, 0)
|
|
345
693
|
return pn.Row(
|
|
346
|
-
pn.Button("+",
|
|
347
|
-
pn.Button("Reset",
|
|
694
|
+
pn.Button("+", on_press=lambda: dispatch("increment")),
|
|
695
|
+
pn.Button("Reset", on_press=lambda: dispatch("reset")),
|
|
348
696
|
)
|
|
349
697
|
```
|
|
350
698
|
"""
|
|
351
699
|
ctx = _get_hook_state()
|
|
352
700
|
if ctx is None:
|
|
353
701
|
raise RuntimeError("use_reducer must be called inside a @component function")
|
|
702
|
+
ctx.record_hook("use_reducer")
|
|
354
703
|
|
|
355
704
|
idx = ctx.state_index
|
|
356
705
|
ctx.state_index += 1
|
|
@@ -384,12 +733,20 @@ def use_effect(effect: Callable, deps: Optional[list] = None) -> None:
|
|
|
384
733
|
- `[]`: mount only.
|
|
385
734
|
- `[a, b]`: when `a` or `b` change (compared by identity, then `==`).
|
|
386
735
|
|
|
387
|
-
`effect` may return a cleanup callable; the previous
|
|
388
|
-
before the next effect (and on unmount).
|
|
736
|
+
A synchronous `effect` may return a cleanup callable; the previous
|
|
737
|
+
cleanup runs before the next effect (and on unmount).
|
|
738
|
+
|
|
739
|
+
An **async** `effect` (an ``async def``) runs as a task on the
|
|
740
|
+
framework loop. When `deps` change or the component unmounts, the
|
|
741
|
+
in-flight task is cancelled (:class:`asyncio.CancelledError` is
|
|
742
|
+
raised at its current ``await``), giving async effects structured
|
|
743
|
+
cancellation for free. If the coroutine finishes and returns a
|
|
744
|
+
callable, that callable runs as the cleanup instead.
|
|
389
745
|
|
|
390
746
|
Args:
|
|
391
|
-
effect: A zero-arg callable invoked after commit
|
|
392
|
-
|
|
747
|
+
effect: A zero-arg callable invoked after commit: either a
|
|
748
|
+
synchronous function (optionally returning a cleanup
|
|
749
|
+
callable) or an ``async def``.
|
|
393
750
|
deps: Dependency list, or `None` to run on every render.
|
|
394
751
|
|
|
395
752
|
Raises:
|
|
@@ -397,25 +754,27 @@ def use_effect(effect: Callable, deps: Optional[list] = None) -> None:
|
|
|
397
754
|
|
|
398
755
|
Example:
|
|
399
756
|
```python
|
|
757
|
+
import asyncio
|
|
758
|
+
|
|
400
759
|
import pythonnative as pn
|
|
401
760
|
|
|
402
761
|
@pn.component
|
|
403
|
-
def
|
|
404
|
-
|
|
762
|
+
def Clock():
|
|
763
|
+
now, set_now = pn.use_state("")
|
|
405
764
|
|
|
406
|
-
def tick():
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
return t.cancel
|
|
765
|
+
async def tick():
|
|
766
|
+
while True:
|
|
767
|
+
set_now(time.strftime("%H:%M:%S"))
|
|
768
|
+
await asyncio.sleep(1)
|
|
411
769
|
|
|
412
|
-
pn.use_effect(tick, [
|
|
413
|
-
return pn.Text(
|
|
770
|
+
pn.use_effect(tick, [])
|
|
771
|
+
return pn.Text(now)
|
|
414
772
|
```
|
|
415
773
|
"""
|
|
416
774
|
ctx = _get_hook_state()
|
|
417
775
|
if ctx is None:
|
|
418
776
|
raise RuntimeError("use_effect must be called inside a @component function")
|
|
777
|
+
ctx.record_hook("use_effect")
|
|
419
778
|
|
|
420
779
|
idx = ctx.effect_index
|
|
421
780
|
ctx.effect_index += 1
|
|
@@ -430,6 +789,61 @@ def use_effect(effect: Callable, deps: Optional[list] = None) -> None:
|
|
|
430
789
|
ctx._pending_effects.append((idx, effect, deps))
|
|
431
790
|
|
|
432
791
|
|
|
792
|
+
def use_layout_effect(effect: Callable, deps: Optional[list] = None) -> None:
|
|
793
|
+
"""Schedule a side effect that runs synchronously inside the commit.
|
|
794
|
+
|
|
795
|
+
Like [`use_effect`][pythonnative.use_effect], but the callback
|
|
796
|
+
fires *before* passive effects, immediately after native mutations
|
|
797
|
+
and the layout pass are applied. Use it when you need to measure a
|
|
798
|
+
committed frame (via a [`Ref`][pythonnative.Ref]) or issue an
|
|
799
|
+
imperative view command before the user sees the new frame, for
|
|
800
|
+
example scrolling a list into position on mount.
|
|
801
|
+
|
|
802
|
+
Prefer `use_effect` for everything else; layout effects block the
|
|
803
|
+
commit, so heavy work here delays the frame.
|
|
804
|
+
|
|
805
|
+
Args:
|
|
806
|
+
effect: A zero-arg callable invoked during commit. Optionally
|
|
807
|
+
returns a cleanup callable.
|
|
808
|
+
deps: Dependency list, or `None` to run on every render.
|
|
809
|
+
|
|
810
|
+
Raises:
|
|
811
|
+
RuntimeError: If called outside a `@component` function.
|
|
812
|
+
|
|
813
|
+
Example:
|
|
814
|
+
```python
|
|
815
|
+
import pythonnative as pn
|
|
816
|
+
|
|
817
|
+
@pn.component
|
|
818
|
+
def AutoScrollList(items):
|
|
819
|
+
list_ref = pn.use_ref()
|
|
820
|
+
|
|
821
|
+
def scroll_to_bottom():
|
|
822
|
+
if list_ref.current is not None:
|
|
823
|
+
list_ref.current.scroll_to_end(animated=False)
|
|
824
|
+
|
|
825
|
+
pn.use_layout_effect(scroll_to_bottom, [len(items)])
|
|
826
|
+
return pn.FlatList(items, render_item=Row, ref=list_ref)
|
|
827
|
+
```
|
|
828
|
+
"""
|
|
829
|
+
ctx = _get_hook_state()
|
|
830
|
+
if ctx is None:
|
|
831
|
+
raise RuntimeError("use_layout_effect must be called inside a @component function")
|
|
832
|
+
ctx.record_hook("use_layout_effect")
|
|
833
|
+
|
|
834
|
+
idx = ctx.layout_effect_index
|
|
835
|
+
ctx.layout_effect_index += 1
|
|
836
|
+
|
|
837
|
+
if idx >= len(ctx.layout_effects):
|
|
838
|
+
ctx.layout_effects.append((_SENTINEL, None))
|
|
839
|
+
ctx._pending_layout_effects.append((idx, effect, deps))
|
|
840
|
+
return
|
|
841
|
+
|
|
842
|
+
prev_deps, _prev_cleanup = ctx.layout_effects[idx]
|
|
843
|
+
if _deps_changed(prev_deps, deps):
|
|
844
|
+
ctx._pending_layout_effects.append((idx, effect, deps))
|
|
845
|
+
|
|
846
|
+
|
|
433
847
|
def use_memo(factory: Callable[[], T], deps: list) -> T:
|
|
434
848
|
"""Return a memoized value that is recomputed only when `deps` change.
|
|
435
849
|
|
|
@@ -451,6 +865,7 @@ def use_memo(factory: Callable[[], T], deps: list) -> T:
|
|
|
451
865
|
ctx = _get_hook_state()
|
|
452
866
|
if ctx is None:
|
|
453
867
|
raise RuntimeError("use_memo must be called inside a @component function")
|
|
868
|
+
ctx.record_hook("use_memo")
|
|
454
869
|
|
|
455
870
|
idx = ctx.memo_index
|
|
456
871
|
ctx.memo_index += 1
|
|
@@ -486,23 +901,26 @@ def use_callback(callback: Callable, deps: list) -> Callable:
|
|
|
486
901
|
return use_memo(lambda: callback, deps)
|
|
487
902
|
|
|
488
903
|
|
|
489
|
-
def use_ref(initial:
|
|
490
|
-
"""Return a
|
|
904
|
+
def use_ref(initial: Optional[T] = None) -> Ref[T]:
|
|
905
|
+
"""Return a [`Ref`][pythonnative.Ref] that persists across renders.
|
|
491
906
|
|
|
492
907
|
Refs are useful for storing values that must survive renders without
|
|
493
908
|
triggering them: timers, last-seen values, native handles, and so on.
|
|
494
909
|
|
|
495
|
-
|
|
910
|
+
``ref.current`` is also populated by the reconciler with the
|
|
496
911
|
underlying native view (`UIView` on iOS, `android.view.View` on
|
|
497
|
-
Android) when the ref is passed via the
|
|
498
|
-
|
|
499
|
-
|
|
912
|
+
Android, a Tk widget on desktop) when the ref is passed via the
|
|
913
|
+
``ref=`` prop on a built-in element, and cleared to ``None`` when
|
|
914
|
+
that element unmounts. Composite components such as
|
|
915
|
+
[`FlatList`][pythonnative.FlatList] publish a typed controller
|
|
916
|
+
object instead (see
|
|
917
|
+
[`use_imperative_handle`][pythonnative.use_imperative_handle]).
|
|
500
918
|
|
|
501
919
|
Args:
|
|
502
|
-
initial: Value placed at
|
|
920
|
+
initial: Value placed at ``ref.current`` on first render.
|
|
503
921
|
|
|
504
922
|
Returns:
|
|
505
|
-
A
|
|
923
|
+
A [`Ref`][pythonnative.Ref]. Mutations to ``ref.current`` do
|
|
506
924
|
*not* trigger re-renders.
|
|
507
925
|
|
|
508
926
|
Raises:
|
|
@@ -511,40 +929,153 @@ def use_ref(initial: Any = None) -> dict:
|
|
|
511
929
|
ctx = _get_hook_state()
|
|
512
930
|
if ctx is None:
|
|
513
931
|
raise RuntimeError("use_ref must be called inside a @component function")
|
|
932
|
+
ctx.record_hook("use_ref")
|
|
514
933
|
|
|
515
934
|
idx = ctx.ref_index
|
|
516
935
|
ctx.ref_index += 1
|
|
517
936
|
|
|
518
937
|
if idx >= len(ctx.refs):
|
|
519
|
-
ref:
|
|
938
|
+
ref: Ref[T] = Ref(initial)
|
|
520
939
|
ctx.refs.append(ref)
|
|
521
940
|
return ref
|
|
522
941
|
|
|
523
942
|
return ctx.refs[idx]
|
|
524
943
|
|
|
525
944
|
|
|
945
|
+
def use_imperative_handle(
|
|
946
|
+
ref: Optional[Ref],
|
|
947
|
+
factory: Callable[[], Any],
|
|
948
|
+
deps: Optional[list] = None,
|
|
949
|
+
) -> None:
|
|
950
|
+
"""Publish a controller object on ``ref.current``.
|
|
951
|
+
|
|
952
|
+
The composite-component counterpart to passing ``ref=`` to a
|
|
953
|
+
built-in element. Call it inside a component that accepts a
|
|
954
|
+
``ref`` prop to expose a curated imperative API (rather than the
|
|
955
|
+
raw native view) to the parent. The handle is installed during the
|
|
956
|
+
commit's layout-effect phase and cleared back to ``None`` on
|
|
957
|
+
unmount.
|
|
958
|
+
|
|
959
|
+
Args:
|
|
960
|
+
ref: The [`Ref`][pythonnative.Ref] received via the component's
|
|
961
|
+
``ref`` prop. ``None`` is allowed (the parent didn't
|
|
962
|
+
request a handle), in which case this is a no-op.
|
|
963
|
+
factory: Zero-arg callable returning the handle object.
|
|
964
|
+
deps: Dependency list controlling when the handle is rebuilt.
|
|
965
|
+
Defaults to ``[]`` semantics only if you pass ``[]``;
|
|
966
|
+
``None`` rebuilds on every render, matching effects.
|
|
967
|
+
|
|
968
|
+
Raises:
|
|
969
|
+
RuntimeError: If called outside a `@component` function.
|
|
970
|
+
|
|
971
|
+
Example:
|
|
972
|
+
```python
|
|
973
|
+
import pythonnative as pn
|
|
974
|
+
|
|
975
|
+
@pn.component
|
|
976
|
+
def VideoPlayer(source, ref=None):
|
|
977
|
+
pn.use_imperative_handle(ref, lambda: PlayerController(...), [source])
|
|
978
|
+
return pn.View(...)
|
|
979
|
+
```
|
|
980
|
+
"""
|
|
981
|
+
|
|
982
|
+
def _install() -> Optional[Callable[[], None]]:
|
|
983
|
+
if ref is None:
|
|
984
|
+
return None
|
|
985
|
+
ref.current = factory()
|
|
986
|
+
|
|
987
|
+
def _clear() -> None:
|
|
988
|
+
ref.current = None
|
|
989
|
+
|
|
990
|
+
return _clear
|
|
991
|
+
|
|
992
|
+
use_layout_effect(_install, deps)
|
|
993
|
+
|
|
994
|
+
|
|
526
995
|
# ======================================================================
|
|
527
996
|
# Async hooks
|
|
528
997
|
# ======================================================================
|
|
529
998
|
|
|
530
999
|
|
|
531
|
-
def
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
)
|
|
535
|
-
|
|
1000
|
+
def use_resource(fetcher: Callable[[], Any], deps: Optional[list] = None) -> Resource[Any]:
|
|
1001
|
+
"""Start an async fetch and cache it across renders.
|
|
1002
|
+
|
|
1003
|
+
The fetch starts immediately (during render, not after commit) and
|
|
1004
|
+
the resulting [`Resource`][pythonnative.Resource] is cached until
|
|
1005
|
+
``deps`` change, at which point the old fetch is cancelled and a
|
|
1006
|
+
new one starts. Because results are cached, re-renders resolve
|
|
1007
|
+
instantly; only genuinely new data suspends.
|
|
536
1008
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
1009
|
+
Consume the resource with ``resource.read()`` (suspends the render
|
|
1010
|
+
while pending; pair with a [`Suspense`][pythonnative.Suspense]
|
|
1011
|
+
boundary) or ``await resource`` inside an ``async def`` component.
|
|
1012
|
+
Errors raised by the fetcher re-raise at the read site, so an
|
|
1013
|
+
enclosing [`ErrorBoundary`][pythonnative.ErrorBoundary] catches
|
|
1014
|
+
failures declaratively.
|
|
543
1015
|
|
|
544
1016
|
Args:
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
1017
|
+
fetcher: Zero-arg ``async def`` (or plain callable) producing
|
|
1018
|
+
the value. Synchronous fetchers resolve immediately and
|
|
1019
|
+
never suspend.
|
|
1020
|
+
deps: Dependency list controlling when to refetch. Defaults to
|
|
1021
|
+
``[]`` (fetch once per component instance).
|
|
1022
|
+
|
|
1023
|
+
Returns:
|
|
1024
|
+
The cached [`Resource`][pythonnative.Resource].
|
|
1025
|
+
|
|
1026
|
+
Raises:
|
|
1027
|
+
RuntimeError: If called outside a ``@component`` function.
|
|
1028
|
+
|
|
1029
|
+
Example:
|
|
1030
|
+
```python
|
|
1031
|
+
import pythonnative as pn
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
@pn.component
|
|
1035
|
+
async def UserCard(user_id):
|
|
1036
|
+
user = await pn.use_resource(lambda: api.get_user(user_id), [user_id])
|
|
1037
|
+
return pn.Text(user["name"])
|
|
1038
|
+
```
|
|
1039
|
+
"""
|
|
1040
|
+
from .suspense import start_resource
|
|
1041
|
+
|
|
1042
|
+
ctx = _get_hook_state()
|
|
1043
|
+
if ctx is None:
|
|
1044
|
+
raise RuntimeError("use_resource must be called inside a @component function")
|
|
1045
|
+
ctx.record_hook("use_resource")
|
|
1046
|
+
|
|
1047
|
+
idx = ctx.resource_index
|
|
1048
|
+
ctx.resource_index += 1
|
|
1049
|
+
deps = [] if deps is None else deps
|
|
1050
|
+
|
|
1051
|
+
if idx >= len(ctx.resources):
|
|
1052
|
+
resource = start_resource(fetcher)
|
|
1053
|
+
ctx.resources.append((list(deps), resource))
|
|
1054
|
+
return resource
|
|
1055
|
+
|
|
1056
|
+
prev_deps, prev_resource = ctx.resources[idx]
|
|
1057
|
+
if not _deps_changed(prev_deps, deps):
|
|
1058
|
+
return prev_resource
|
|
1059
|
+
|
|
1060
|
+
prev_resource.cancel()
|
|
1061
|
+
resource = start_resource(fetcher)
|
|
1062
|
+
ctx.resources[idx] = (list(deps), resource)
|
|
1063
|
+
return resource
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def use_transition() -> Tuple[bool, Callable[[Callable[[], None]], None]]:
|
|
1067
|
+
"""Return ``(is_pending, start_transition)`` for low-priority updates.
|
|
1068
|
+
|
|
1069
|
+
State updates made inside ``start_transition(fn)`` are marked as
|
|
1070
|
+
*transitions*: instead of re-rendering synchronously, their render
|
|
1071
|
+
is deferred to a later turn of the framework loop, so urgent
|
|
1072
|
+
updates (typing, presses) queued in the meantime render first.
|
|
1073
|
+
``is_pending`` is ``True`` from the moment ``start_transition`` is
|
|
1074
|
+
called until the deferred render has committed, which is exactly
|
|
1075
|
+
when to show a lightweight busy indicator.
|
|
1076
|
+
|
|
1077
|
+
Returns:
|
|
1078
|
+
A 2-tuple ``(is_pending, start_transition)``.
|
|
548
1079
|
|
|
549
1080
|
Raises:
|
|
550
1081
|
RuntimeError: If called outside a ``@component`` function.
|
|
@@ -555,27 +1086,67 @@ def use_async_effect(
|
|
|
555
1086
|
|
|
556
1087
|
|
|
557
1088
|
@pn.component
|
|
558
|
-
def
|
|
559
|
-
|
|
1089
|
+
def Search():
|
|
1090
|
+
query, set_query = pn.use_state("")
|
|
1091
|
+
results_for, set_results_for = pn.use_state("")
|
|
1092
|
+
is_pending, start_transition = pn.use_transition()
|
|
560
1093
|
|
|
561
|
-
|
|
562
|
-
|
|
1094
|
+
def on_change(text):
|
|
1095
|
+
set_query(text) # urgent: keep the input responsive
|
|
1096
|
+
start_transition(lambda: set_results_for(text))
|
|
563
1097
|
|
|
564
|
-
pn.
|
|
565
|
-
|
|
1098
|
+
return pn.Column(
|
|
1099
|
+
pn.TextInput(value=query, on_change=on_change),
|
|
1100
|
+
pn.ActivityIndicator() if is_pending else Results(results_for),
|
|
1101
|
+
)
|
|
566
1102
|
```
|
|
567
1103
|
"""
|
|
568
|
-
|
|
1104
|
+
ctx = _get_hook_state()
|
|
1105
|
+
if ctx is None:
|
|
1106
|
+
raise RuntimeError("use_transition must be called inside a @component function")
|
|
569
1107
|
|
|
570
|
-
|
|
571
|
-
future = run_async(effect())
|
|
1108
|
+
is_pending, set_pending = use_state(False)
|
|
572
1109
|
|
|
573
|
-
|
|
574
|
-
|
|
1110
|
+
def start_transition(fn: Callable[[], None]) -> None:
|
|
1111
|
+
set_pending(True)
|
|
1112
|
+
_run_in_transition(fn)
|
|
1113
|
+
_post_transition_callbacks.append(lambda: set_pending(False))
|
|
1114
|
+
_schedule_transition_flush()
|
|
575
1115
|
|
|
576
|
-
|
|
1116
|
+
start = use_callback(start_transition, [])
|
|
1117
|
+
return is_pending, start
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
def use_deferred_value(value: Any) -> Any:
|
|
1121
|
+
"""Return a copy of ``value`` that lags behind during fast updates.
|
|
1122
|
+
|
|
1123
|
+
The returned value updates in a deferred (transition-priority)
|
|
1124
|
+
render after the urgent render that changed ``value`` has
|
|
1125
|
+
committed. Pass the deferred value to expensive subtrees (a
|
|
1126
|
+
filtered list, a chart) so the urgent part of the UI stays
|
|
1127
|
+
responsive while the expensive part catches up a beat later.
|
|
1128
|
+
|
|
1129
|
+
Args:
|
|
1130
|
+
value: The latest value.
|
|
1131
|
+
|
|
1132
|
+
Returns:
|
|
1133
|
+
The previous value while a newer one is still being adopted,
|
|
1134
|
+
then the latest value.
|
|
1135
|
+
|
|
1136
|
+
Raises:
|
|
1137
|
+
RuntimeError: If called outside a ``@component`` function.
|
|
1138
|
+
"""
|
|
1139
|
+
ctx = _get_hook_state()
|
|
1140
|
+
if ctx is None:
|
|
1141
|
+
raise RuntimeError("use_deferred_value must be called inside a @component function")
|
|
1142
|
+
|
|
1143
|
+
deferred, set_deferred = use_state(value)
|
|
1144
|
+
|
|
1145
|
+
def _adopt() -> None:
|
|
1146
|
+
_run_in_transition(lambda: set_deferred(value))
|
|
577
1147
|
|
|
578
|
-
use_effect(
|
|
1148
|
+
use_effect(_adopt, [value])
|
|
1149
|
+
return deferred
|
|
579
1150
|
|
|
580
1151
|
|
|
581
1152
|
@dataclass(frozen=True)
|
|
@@ -704,7 +1275,7 @@ class MutationCall(Generic[T]):
|
|
|
704
1275
|
Example:
|
|
705
1276
|
```python
|
|
706
1277
|
# Fire-and-forget:
|
|
707
|
-
save_button.
|
|
1278
|
+
save_button.on_press = lambda: mutate(post)
|
|
708
1279
|
|
|
709
1280
|
# Or await for the result:
|
|
710
1281
|
async def submit():
|
|
@@ -721,7 +1292,10 @@ class MutationCall(Generic[T]):
|
|
|
721
1292
|
self._future = future
|
|
722
1293
|
|
|
723
1294
|
def __await__(self) -> Any:
|
|
724
|
-
|
|
1295
|
+
future = self._future
|
|
1296
|
+
if isinstance(future, asyncio.Future):
|
|
1297
|
+
return future.__await__()
|
|
1298
|
+
return asyncio.wrap_future(future).__await__()
|
|
725
1299
|
|
|
726
1300
|
def cancel(self) -> bool:
|
|
727
1301
|
"""Cancel the underlying mutation. Returns whether cancellation succeeded."""
|
|
@@ -760,7 +1334,7 @@ def use_mutation(
|
|
|
760
1334
|
state, save = pn.use_mutation(api.create_post)
|
|
761
1335
|
|
|
762
1336
|
return pn.Column(
|
|
763
|
-
pn.Button("Save",
|
|
1337
|
+
pn.Button("Save", on_press=lambda: save(post)),
|
|
764
1338
|
pn.Text("Saving…") if state.loading else pn.Text(""),
|
|
765
1339
|
pn.Text(str(state.error)) if state.error else pn.Text(""),
|
|
766
1340
|
)
|
|
@@ -952,6 +1526,10 @@ class Context:
|
|
|
952
1526
|
via [`use_context`][pythonnative.use_context]. Use
|
|
953
1527
|
[`Provider`][pythonnative.Provider] to set the value for a subtree.
|
|
954
1528
|
|
|
1529
|
+
Context is *reactive*: when a Provider's value changes, every
|
|
1530
|
+
component that read the context on its last render re-renders,
|
|
1531
|
+
even if a memoized ancestor skipped its own re-render.
|
|
1532
|
+
|
|
955
1533
|
Attributes:
|
|
956
1534
|
default: The value returned when no `Provider` ancestor exists.
|
|
957
1535
|
"""
|
|
@@ -989,6 +1567,9 @@ def use_context(context: Context) -> Any:
|
|
|
989
1567
|
"""Read the current value of `context` from the nearest `Provider`.
|
|
990
1568
|
|
|
991
1569
|
If no enclosing `Provider` exists, returns the context's default.
|
|
1570
|
+
The component is registered as a subscriber: when the nearest
|
|
1571
|
+
Provider's value changes, the component re-renders even if a
|
|
1572
|
+
memoized ancestor skipped.
|
|
992
1573
|
|
|
993
1574
|
Args:
|
|
994
1575
|
context: The `Context` to read from.
|
|
@@ -1002,7 +1583,10 @@ def use_context(context: Context) -> Any:
|
|
|
1002
1583
|
ctx = _get_hook_state()
|
|
1003
1584
|
if ctx is None:
|
|
1004
1585
|
raise RuntimeError("use_context must be called inside a @component function")
|
|
1005
|
-
|
|
1586
|
+
ctx.record_hook("use_context")
|
|
1587
|
+
value = context._current()
|
|
1588
|
+
ctx.context_deps[id(context)] = value
|
|
1589
|
+
return value
|
|
1006
1590
|
|
|
1007
1591
|
|
|
1008
1592
|
# ======================================================================
|
|
@@ -1013,10 +1597,14 @@ def use_context(context: Context) -> Any:
|
|
|
1013
1597
|
def Provider(context: "Context", value: Any, *children: Element) -> Element:
|
|
1014
1598
|
"""Provide ``value`` for ``context`` to all descendants of ``children``.
|
|
1015
1599
|
|
|
1016
|
-
Accepts any number of children (varargs).
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1600
|
+
Accepts any number of children (varargs). A Provider contributes no
|
|
1601
|
+
native view of its own; its children mount directly into the
|
|
1602
|
+
surrounding native parent.
|
|
1603
|
+
|
|
1604
|
+
When ``value`` differs from the previous render (identity, then
|
|
1605
|
+
``==``), every descendant that read the context via
|
|
1606
|
+
[`use_context`][pythonnative.use_context] re-renders, including
|
|
1607
|
+
descendants of memoized components that skipped.
|
|
1020
1608
|
|
|
1021
1609
|
Args:
|
|
1022
1610
|
context: The [`Context`][pythonnative.hooks.Context] to set.
|
|
@@ -1044,13 +1632,7 @@ def Provider(context: "Context", value: Any, *children: Element) -> Element:
|
|
|
1044
1632
|
)
|
|
1045
1633
|
```
|
|
1046
1634
|
"""
|
|
1047
|
-
|
|
1048
|
-
kids: List[Element] = []
|
|
1049
|
-
elif len(children) == 1:
|
|
1050
|
-
kids = [children[0]]
|
|
1051
|
-
else:
|
|
1052
|
-
kids = [Element("__Fragment__", {}, list(children))]
|
|
1053
|
-
return Element("__Provider__", {"__context__": context, "__value__": value}, kids)
|
|
1635
|
+
return Element("__Provider__", {"__context__": context, "__value__": value}, list(children))
|
|
1054
1636
|
|
|
1055
1637
|
|
|
1056
1638
|
def memo(component_fn: Callable[..., Element]) -> Callable[..., Element]:
|
|
@@ -1123,7 +1705,7 @@ class NavigationHandle:
|
|
|
1123
1705
|
nav = pn.use_navigation()
|
|
1124
1706
|
return pn.Button(
|
|
1125
1707
|
"Open Detail",
|
|
1126
|
-
|
|
1708
|
+
on_press=lambda: nav.navigate("Detail", {"id": 42}),
|
|
1127
1709
|
)
|
|
1128
1710
|
```
|
|
1129
1711
|
"""
|
|
@@ -1180,6 +1762,71 @@ def use_navigation() -> NavigationHandle:
|
|
|
1180
1762
|
return handle
|
|
1181
1763
|
|
|
1182
1764
|
|
|
1765
|
+
def use_back_handler(handler: Callable[[], bool]) -> None:
|
|
1766
|
+
"""Intercept the system back action for this screen.
|
|
1767
|
+
|
|
1768
|
+
On Android this handles the hardware back button and predictive
|
|
1769
|
+
back gesture; in the desktop preview it handles the Escape key.
|
|
1770
|
+
iOS has no system back button, so the handler never fires there
|
|
1771
|
+
(swipe-back is controlled by the navigation stack instead).
|
|
1772
|
+
|
|
1773
|
+
Handlers registered later run first, so a component mounted on top
|
|
1774
|
+
of existing content (a modal, a confirmation sheet) takes priority
|
|
1775
|
+
over handlers that were already mounted. Return ``True`` to consume
|
|
1776
|
+
the event and stop both remaining handlers and the platform's
|
|
1777
|
+
default behavior (popping the screen); return ``False`` to pass it
|
|
1778
|
+
along.
|
|
1779
|
+
|
|
1780
|
+
The latest ``handler`` closure from the most recent render is
|
|
1781
|
+
always the one invoked; registration order is fixed at mount, so
|
|
1782
|
+
re-renders never change priority.
|
|
1783
|
+
|
|
1784
|
+
Args:
|
|
1785
|
+
handler: Zero-arg callable returning ``True`` if it consumed
|
|
1786
|
+
the back action.
|
|
1787
|
+
|
|
1788
|
+
Raises:
|
|
1789
|
+
RuntimeError: If called outside a `@component` function.
|
|
1790
|
+
|
|
1791
|
+
Example:
|
|
1792
|
+
```python
|
|
1793
|
+
import pythonnative as pn
|
|
1794
|
+
|
|
1795
|
+
@pn.component
|
|
1796
|
+
def Editor():
|
|
1797
|
+
dirty, set_dirty = pn.use_state(False)
|
|
1798
|
+
pn.use_back_handler(lambda: dirty) # block back while dirty
|
|
1799
|
+
...
|
|
1800
|
+
```
|
|
1801
|
+
"""
|
|
1802
|
+
ctx = _get_hook_state()
|
|
1803
|
+
if ctx is None:
|
|
1804
|
+
raise RuntimeError("use_back_handler must be called inside a @component function")
|
|
1805
|
+
|
|
1806
|
+
latest: Ref[Callable[[], bool]] = use_ref(handler)
|
|
1807
|
+
latest.current = handler
|
|
1808
|
+
|
|
1809
|
+
def _register() -> Optional[Callable[[], None]]:
|
|
1810
|
+
reconciler = ctx._reconciler
|
|
1811
|
+
if reconciler is None or not hasattr(reconciler, "register_back_handler"):
|
|
1812
|
+
return None
|
|
1813
|
+
|
|
1814
|
+
def _trampoline() -> bool:
|
|
1815
|
+
fn = latest.current
|
|
1816
|
+
if fn is None:
|
|
1817
|
+
return False
|
|
1818
|
+
try:
|
|
1819
|
+
return bool(fn())
|
|
1820
|
+
except Exception as exc:
|
|
1821
|
+
if not diagnostics.report_error(exc, phase="back handler"):
|
|
1822
|
+
raise
|
|
1823
|
+
return True
|
|
1824
|
+
|
|
1825
|
+
return reconciler.register_back_handler(_trampoline)
|
|
1826
|
+
|
|
1827
|
+
use_effect(_register, [])
|
|
1828
|
+
|
|
1829
|
+
|
|
1183
1830
|
# ======================================================================
|
|
1184
1831
|
# @component decorator
|
|
1185
1832
|
# ======================================================================
|