pythonnative 0.32.0__py3-none-any.whl → 0.34.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. pythonnative/__init__.py +27 -15
  2. pythonnative/animated.py +7 -13
  3. pythonnative/cli/pn.py +1 -2
  4. pythonnative/component.py +255 -0
  5. pythonnative/components/__init__.py +106 -0
  6. pythonnative/components/_base.py +132 -0
  7. pythonnative/components/controls.py +515 -0
  8. pythonnative/components/layout.py +459 -0
  9. pythonnative/components/lists.py +809 -0
  10. pythonnative/components/media.py +204 -0
  11. pythonnative/components/overlays.py +107 -0
  12. pythonnative/components/pressable.py +252 -0
  13. pythonnative/components/structural.py +167 -0
  14. pythonnative/components/text.py +291 -0
  15. pythonnative/diagnostics.py +1 -1
  16. pythonnative/element.py +108 -29
  17. pythonnative/gestures.py +1 -1
  18. pythonnative/hooks.py +366 -671
  19. pythonnative/hosts/__init__.py +85 -0
  20. pythonnative/hosts/android.py +269 -0
  21. pythonnative/hosts/base.py +665 -0
  22. pythonnative/hosts/desktop.py +107 -0
  23. pythonnative/hosts/ios.py +404 -0
  24. pythonnative/hot_reload.py +16 -24
  25. pythonnative/layout.py +372 -66
  26. pythonnative/native_modules/__init__.py +23 -0
  27. pythonnative/native_modules/net_info.py +5 -1
  28. pythonnative/native_views/__init__.py +1 -1
  29. pythonnative/native_views/android.py +50 -6
  30. pythonnative/native_views/base.py +64 -1
  31. pythonnative/native_views/desktop.py +11 -3
  32. pythonnative/native_views/ios.py +72 -12
  33. pythonnative/navigation/__init__.py +102 -0
  34. pythonnative/navigation/container.py +154 -0
  35. pythonnative/navigation/handle.py +573 -0
  36. pythonnative/navigation/hooks.py +94 -0
  37. pythonnative/navigation/host.py +58 -0
  38. pythonnative/navigation/linking.py +200 -0
  39. pythonnative/navigation/navigators.py +637 -0
  40. pythonnative/navigation/screen.py +149 -0
  41. pythonnative/navigation/state.py +248 -0
  42. pythonnative/net.py +4 -0
  43. pythonnative/platform_metrics.py +1 -1
  44. pythonnative/preview.py +21 -19
  45. pythonnative/project/android.py +7 -1
  46. pythonnative/project/doctor.py +1 -1
  47. pythonnative/project/ios.py +1 -0
  48. pythonnative/project/runtime_assets.py +1 -1
  49. pythonnative/reconciler/__init__.py +29 -0
  50. pythonnative/reconciler/boundaries.py +365 -0
  51. pythonnative/reconciler/children.py +88 -0
  52. pythonnative/reconciler/core.py +1153 -0
  53. pythonnative/reconciler/layout_pass.py +359 -0
  54. pythonnative/reconciler/vnode.py +266 -0
  55. pythonnative/scheduler.py +159 -0
  56. pythonnative/sdk/_components.py +2 -4
  57. pythonnative/style.py +95 -13
  58. pythonnative/suspense.py +8 -12
  59. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt +85 -1
  60. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/ScreenFragment.kt +6 -7
  61. pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml +1 -1
  62. pythonnative/templates/android_template/app/src/main/res/values/strings.xml +3 -1
  63. pythonnative/templates/ios_template/ios_template/AppDelegate.swift +33 -0
  64. pythonnative/templates/ios_template/ios_template/PythonRuntime.swift +1 -1
  65. pythonnative/templates/ios_template/ios_template/ViewController.swift +12 -10
  66. pythonnative/testing/__init__.py +53 -0
  67. pythonnative/testing/backend.py +276 -0
  68. pythonnative/testing/harness.py +391 -0
  69. {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/METADATA +2 -21
  70. {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/RECORD +74 -43
  71. pythonnative/components.py +0 -2856
  72. pythonnative/navigation.py +0 -1031
  73. pythonnative/reconciler.py +0 -2298
  74. pythonnative/screen.py +0 -2068
  75. {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/WHEEL +0 -0
  76. {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/entry_points.txt +0 -0
  77. {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/licenses/LICENSE +0 -0
  78. {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,573 @@
1
+ """The [`Navigation`][pythonnative.Navigation] handle and the navigator core behind it.
2
+
3
+ Every screen rendered by a navigator receives a ``Navigation`` object
4
+ through [`use_navigation`][pythonnative.use_navigation]. The handle is
5
+ scoped to the screen's route (so ``add_listener("focus", ...)`` fires
6
+ for *that* screen) and forwards every action to the
7
+ [`NavigatorCore`][pythonnative.navigation.handle.NavigatorCore] that
8
+ owns the navigator's state.
9
+
10
+ Actions the core can't satisfy (an unknown route, popping past the
11
+ first screen) bubble to the parent navigator, so a stack nested in a
12
+ tab still pops correctly and ``navigate("Settings")`` from deep inside
13
+ one tab can switch to another.
14
+
15
+ When the core belongs to the **root stack of a native host** it never
16
+ mutates its own state for pushes and pops: it asks the host to push or
17
+ pop a real native screen carrying the serialized next state, and the
18
+ new screen's navigator boots from that state.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import Any, Callable, Dict, List, Literal, Mapping, Optional, Protocol, Sequence, Tuple, Union
24
+
25
+ from .. import diagnostics
26
+ from ..hooks import Context, create_context
27
+ from .screen import ScreenDef
28
+ from .state import NavigationState, Route
29
+
30
+ __all__ = [
31
+ "DrawerNavigation",
32
+ "FocusContext",
33
+ "HostNavigator",
34
+ "Navigation",
35
+ "NavigationContext",
36
+ "NavigationEvent",
37
+ "NavigatorCore",
38
+ "TabNavigation",
39
+ ]
40
+
41
+ NavigatorKind = Literal["stack", "tab", "drawer"]
42
+ EventName = Literal["focus", "blur", "before_remove", "state"]
43
+ Listener = Callable[["NavigationEvent"], None]
44
+
45
+
46
+ class HostNavigator(Protocol):
47
+ """What a root stack needs from the native screen host.
48
+
49
+ Hosts (iOS view controller, Android fragment, desktop preview) and
50
+ [`FakeHost`][pythonnative.testing.FakeHost] implement these. Each
51
+ method receives the *serialized* next navigation state so the
52
+ screen it creates can boot with the full history.
53
+ """
54
+
55
+ is_focused: bool
56
+
57
+ def initial_navigation_state(self) -> Optional[Dict[str, Any]]:
58
+ """The serialized state this screen was pushed with, if any."""
59
+ ...
60
+
61
+ def push_screen(self, state: Dict[str, Any], options: Dict[str, Any]) -> None:
62
+ """Push a native screen that boots from ``state``."""
63
+ ...
64
+
65
+ def pop_screens(self, count: int) -> None:
66
+ """Pop ``count`` native screens."""
67
+ ...
68
+
69
+ def replace_screen(self, state: Dict[str, Any], options: Dict[str, Any]) -> None:
70
+ """Replace the current native screen with one booting from ``state``."""
71
+ ...
72
+
73
+ def reset_screens(self, state: Dict[str, Any], options: Dict[str, Any]) -> None:
74
+ """Replace the whole native stack with one booting from ``state``."""
75
+ ...
76
+
77
+ def set_screen_options(self, options: Dict[str, Any]) -> None:
78
+ """Apply header options (``title`` and friends) to the native bar."""
79
+ ...
80
+
81
+ def add_focus_listener(self, callback: Callable[[bool], None]) -> Callable[[], None]:
82
+ """Subscribe to the host covering / revealing this screen; returns an unsubscribe."""
83
+ ...
84
+
85
+
86
+ class NavigationEvent:
87
+ """Payload delivered to ``add_listener`` callbacks.
88
+
89
+ Attributes:
90
+ type: ``"focus"``, ``"blur"``, ``"before_remove"``, or ``"state"``.
91
+ route: The route the event concerns.
92
+ data: Extra event data (``before_remove`` carries the
93
+ ``action`` that caused it).
94
+ """
95
+
96
+ __slots__ = ("type", "route", "data", "default_prevented")
97
+
98
+ def __init__(self, type_: str, route: Route, data: Optional[Mapping[str, Any]] = None) -> None:
99
+ self.type = type_
100
+ self.route = route
101
+ self.data: Dict[str, Any] = dict(data or {})
102
+ self.default_prevented = False
103
+
104
+ def prevent_default(self) -> None:
105
+ """Cancel the action (only meaningful for ``before_remove``)."""
106
+ self.default_prevented = True
107
+
108
+ def __repr__(self) -> str:
109
+ return f"NavigationEvent({self.type!r}, {self.route.name!r})"
110
+
111
+
112
+ class NavigatorCore:
113
+ """State machine shared by every ``Navigation`` handle a navigator hands out.
114
+
115
+ Owned by the navigator component: created once per mount, updated
116
+ every render with the latest state and setter (see ``update``).
117
+ """
118
+
119
+ def __init__(
120
+ self,
121
+ kind: NavigatorKind,
122
+ screens: Mapping[str, ScreenDef],
123
+ state: NavigationState,
124
+ set_state: Callable[[Any], None],
125
+ parent: Optional["Navigation"] = None,
126
+ host: Optional[HostNavigator] = None,
127
+ request_render: Optional[Callable[[], None]] = None,
128
+ ) -> None:
129
+ self.kind = kind
130
+ self.screens: Dict[str, ScreenDef] = dict(screens)
131
+ self.state = state
132
+ self._set_state = set_state
133
+ self.parent = parent
134
+ self.host = host
135
+ self._request_render = request_render
136
+ self._listeners: Dict[Tuple[str, str], List[Listener]] = {}
137
+ # ``set_options`` results, keyed by route key.
138
+ self.runtime_options: Dict[str, Dict[str, Any]] = {}
139
+ self._handles: Dict[str, Navigation] = {}
140
+ self.drawer_open = False
141
+ self._set_drawer_open: Optional[Callable[[bool], None]] = None
142
+ self.on_state_change: Optional[Callable[[NavigationState], None]] = None
143
+
144
+ # ------------------------------------------------------------------
145
+ # Wiring from the owning component
146
+ # ------------------------------------------------------------------
147
+
148
+ def update(
149
+ self,
150
+ screens: Mapping[str, ScreenDef],
151
+ state: NavigationState,
152
+ set_state: Callable[[Any], None],
153
+ parent: Optional["Navigation"],
154
+ host: Optional[HostNavigator],
155
+ ) -> None:
156
+ """Sync the core with the owning component's latest render.
157
+
158
+ Handles and ``set_options`` overrides for routes no longer in ``state`` are dropped.
159
+ """
160
+ self.screens = dict(screens)
161
+ self.state = state
162
+ self._set_state = set_state
163
+ self.parent = parent
164
+ self.host = host
165
+ live = {r.key for r in state.routes}
166
+ for key in list(self._handles):
167
+ if key not in live:
168
+ del self._handles[key]
169
+ for key in list(self.runtime_options):
170
+ if key not in live:
171
+ del self.runtime_options[key]
172
+
173
+ @property
174
+ def is_native_root(self) -> bool:
175
+ """Whether this core drives a native screen stack through the host."""
176
+ return self.kind == "stack" and self.host is not None and self.parent is None
177
+
178
+ def handle_for(self, route: Route) -> "Navigation":
179
+ """The (cached) handle scoped to ``route``."""
180
+ handle = self._handles.get(route.key)
181
+ if handle is None:
182
+ cls = {"tab": TabNavigation, "drawer": DrawerNavigation}.get(self.kind, Navigation)
183
+ handle = cls(self, route.key)
184
+ self._handles[route.key] = handle
185
+ return handle
186
+
187
+ def route_by_key(self, key: str) -> Route:
188
+ """Return the route with ``key``, falling back to the active route once it has left the state."""
189
+ for route in self.state.routes:
190
+ if route.key == key:
191
+ return route
192
+ return self.state.current
193
+
194
+ def options_for(self, route: Route) -> Dict[str, Any]:
195
+ """Static screen options merged with any ``set_options`` overrides."""
196
+ screen = self.screens.get(route.name)
197
+ options = screen.resolve_options(route) if screen is not None else {}
198
+ options.update(self.runtime_options.get(route.key, {}))
199
+ return options
200
+
201
+ # ------------------------------------------------------------------
202
+ # Events
203
+ # ------------------------------------------------------------------
204
+
205
+ def add_listener(self, route_key: str, event: str, listener: Listener) -> Callable[[], None]:
206
+ """Subscribe ``listener`` to ``event`` for the route with ``route_key``; returns an unsubscribe callable."""
207
+ bucket = self._listeners.setdefault((route_key, event), [])
208
+ bucket.append(listener)
209
+
210
+ def remove() -> None:
211
+ try:
212
+ bucket.remove(listener)
213
+ except ValueError:
214
+ pass
215
+
216
+ return remove
217
+
218
+ def emit(self, route: Route, event: str, data: Optional[Mapping[str, Any]] = None) -> NavigationEvent:
219
+ """Deliver ``event`` to the listeners registered for ``route`` and return the event.
220
+
221
+ Check ``default_prevented`` on the result to see whether a ``before_remove`` listener cancelled the action.
222
+ """
223
+ evt = NavigationEvent(event, route, data)
224
+ for listener in list(self._listeners.get((route.key, event), ())):
225
+ try:
226
+ listener(evt)
227
+ except Exception as exc:
228
+ if not diagnostics.report_error(exc, phase=f"navigation {event} listener"):
229
+ raise
230
+ return evt
231
+
232
+ # ------------------------------------------------------------------
233
+ # State transitions
234
+ # ------------------------------------------------------------------
235
+
236
+ def _commit(self, new_state: NavigationState) -> None:
237
+ if new_state == self.state:
238
+ return
239
+ self._set_state(new_state)
240
+ if self.on_state_change is not None:
241
+ self.on_state_change(new_state)
242
+
243
+ def _validate(self, name: str) -> bool:
244
+ return name in self.screens
245
+
246
+ def _with_initial_params(self, name: str, params: Mapping[str, Any]) -> Dict[str, Any]:
247
+ screen = self.screens.get(name)
248
+ base = dict(screen.initial_params) if screen is not None else {}
249
+ base.update(params)
250
+ return base
251
+
252
+ def navigate(self, name: str, params: Mapping[str, Any], nested: Optional[NavigationState] = None) -> None:
253
+ """Go to the screen ``name``: stacks pop back to it or push it; tabs and drawers jump to it.
254
+
255
+ Unknown routes bubble to the parent navigator. On a native root stack, popping back is delegated to the host.
256
+ """
257
+ if not self._validate(name):
258
+ if self.parent is not None:
259
+ self.parent._core.navigate(name, params, nested)
260
+ return
261
+ raise ValueError(f"Unknown route {name!r}. Known routes: {list(self.screens)}")
262
+ if self.kind == "stack":
263
+ existing = self.state.find(name)
264
+ if existing is None:
265
+ self.push(name, params, nested)
266
+ return
267
+ if existing == self.state.index:
268
+ new_state = self.state.set_params(params) if params else self.state
269
+ if nested is not None:
270
+ routes = list(new_state.routes)
271
+ routes[new_state.index] = routes[new_state.index].with_state(nested)
272
+ new_state = NavigationState(routes, new_state.index)
273
+ if new_state is not self.state:
274
+ self._commit(new_state)
275
+ return
276
+ new_state = self.state.pop_to(name, params, nested)
277
+ if self.is_native_root:
278
+ self.host.pop_screens(len(self.state) - len(new_state))
279
+ return
280
+ self._commit(new_state)
281
+ return
282
+ self._commit(self.state.jump_to(name, params, nested))
283
+
284
+ def push(self, name: str, params: Mapping[str, Any], nested: Optional[NavigationState] = None) -> None:
285
+ """Push a new instance of ``name`` with its ``initial_params`` merged under ``params``.
286
+
287
+ Non-stack navigators fall back to ``navigate``; unknown routes bubble to the parent navigator.
288
+ """
289
+ if self.kind != "stack":
290
+ self.navigate(name, params, nested)
291
+ return
292
+ if not self._validate(name):
293
+ if self.parent is not None:
294
+ self.parent._core.push(name, params, nested)
295
+ return
296
+ raise ValueError(f"Unknown route {name!r}. Known routes: {list(self.screens)}")
297
+ new_state = self.state.push(name, self._with_initial_params(name, params), nested)
298
+ if self.is_native_root:
299
+ self.host.push_screen(new_state.to_dict(), self.options_for(new_state.current))
300
+ return
301
+ self._commit(new_state)
302
+
303
+ def replace(self, name: str, params: Mapping[str, Any], nested: Optional[NavigationState] = None) -> None:
304
+ """Swap the active screen for a fresh ``name`` (stacks); tabs and drawers fall back to ``navigate``."""
305
+ if not self._validate(name):
306
+ if self.parent is not None:
307
+ self.parent._core.replace(name, params, nested)
308
+ return
309
+ raise ValueError(f"Unknown route {name!r}. Known routes: {list(self.screens)}")
310
+ if self.kind != "stack":
311
+ self.navigate(name, params, nested)
312
+ return
313
+ new_state = self.state.replace(name, self._with_initial_params(name, params), nested)
314
+ if self.is_native_root:
315
+ self.host.replace_screen(new_state.to_dict(), self.options_for(new_state.current))
316
+ return
317
+ self._commit(new_state)
318
+
319
+ def pop(self, count: int = 1, *, source: str = "pop") -> bool:
320
+ """Pop ``count`` screens. Returns whether anything was popped here or by a parent."""
321
+ if self.kind != "stack" or len(self.state) <= 1:
322
+ if self.parent is not None:
323
+ return self.parent.pop(count)
324
+ return False
325
+ count = max(1, min(count, len(self.state) - 1))
326
+ for route in reversed(self.state.routes[len(self.state) - count :]):
327
+ evt = self.emit(route, "before_remove", {"action": source})
328
+ if evt.default_prevented:
329
+ return True
330
+ if self.is_native_root:
331
+ self.host.pop_screens(count)
332
+ return True
333
+ self._commit(self.state.pop(count))
334
+ return True
335
+
336
+ def pop_to_top(self) -> None:
337
+ """Pop every screen above the first one (no-op when only one screen is present)."""
338
+ if len(self.state) > 1:
339
+ self.pop(len(self.state) - 1, source="pop_to_top")
340
+
341
+ def reset(self, routes: Sequence[Route], index: Optional[int] = None) -> None:
342
+ """Replace the whole history with ``routes``, activating ``index`` (the last route by default).
343
+
344
+ Raises ``ValueError`` if any route name is unknown to this navigator.
345
+ """
346
+ for route in routes:
347
+ if not self._validate(route.name):
348
+ raise ValueError(f"Unknown route {route.name!r}. Known routes: {list(self.screens)}")
349
+ new_state = NavigationState(routes, index)
350
+ if self.is_native_root:
351
+ self.host.reset_screens(new_state.to_dict(), self.options_for(new_state.current))
352
+ return
353
+ self._commit(new_state)
354
+
355
+ def set_params(self, route_key: str, params: Mapping[str, Any]) -> None:
356
+ """Merge ``params`` into the route with ``route_key`` and commit the new state."""
357
+ routes = list(self.state.routes)
358
+ for i, route in enumerate(routes):
359
+ if route.key == route_key:
360
+ routes[i] = route.with_params(params)
361
+ self._commit(NavigationState(routes, self.state.index))
362
+ return
363
+
364
+ def set_options(self, route_key: str, options: Mapping[str, Any]) -> None:
365
+ """Merge runtime ``options`` for the route with ``route_key`` and request a render if anything changed."""
366
+ current = self.runtime_options.setdefault(route_key, {})
367
+ if all(current.get(k) == v for k, v in options.items()) and all(k in current for k in options):
368
+ return
369
+ current.update(options)
370
+ if self._request_render is not None:
371
+ self._request_render()
372
+
373
+ def set_drawer_open(self, open_: bool) -> None:
374
+ """Open or close the drawer (no-op unless a drawer navigator owns this core)."""
375
+ if self._set_drawer_open is not None:
376
+ self._set_drawer_open(bool(open_))
377
+
378
+
379
+ class Navigation:
380
+ """Imperative navigation API for one screen.
381
+
382
+ Obtained with [`use_navigation`][pythonnative.use_navigation]. Every
383
+ method that changes screens accepts the destination route name
384
+ followed by params as keyword arguments:
385
+
386
+ ```python
387
+ nav.navigate("Detail", id=42)
388
+ nav.push("Detail", id=43)
389
+ nav.replace("Login")
390
+ nav.pop()
391
+ nav.pop_to_top()
392
+ nav.set_params(id=44)
393
+ nav.set_options(title="Edited")
394
+ unsubscribe = nav.add_listener("focus", lambda e: print("focused", e.route.name))
395
+ ```
396
+
397
+ Unknown routes bubble to the parent navigator, so screens can
398
+ navigate across nested navigators without knowing the tree shape.
399
+ """
400
+
401
+ __slots__ = ("_core", "_route_key")
402
+
403
+ def __init__(self, core: NavigatorCore, route_key: str) -> None:
404
+ self._core = core
405
+ self._route_key = route_key
406
+
407
+ # ------------------------------------------------------------------
408
+ # Introspection
409
+ # ------------------------------------------------------------------
410
+
411
+ @property
412
+ def route(self) -> Route:
413
+ """The route this handle belongs to."""
414
+ return self._core.route_by_key(self._route_key)
415
+
416
+ @property
417
+ def kind(self) -> NavigatorKind:
418
+ """``"stack"``, ``"tab"``, or ``"drawer"``."""
419
+ return self._core.kind
420
+
421
+ def get_params(self) -> Dict[str, Any]:
422
+ """Params of this handle's route."""
423
+ return dict(self.route.params)
424
+
425
+ def get_state(self) -> NavigationState:
426
+ """The owning navigator's current state."""
427
+ return self._core.state
428
+
429
+ def get_parent(self) -> Optional["Navigation"]:
430
+ """The handle of the enclosing navigator, or ``None`` at the top."""
431
+ return self._core.parent
432
+
433
+ def get_options(self) -> Dict[str, Any]:
434
+ """Effective options for this handle's route (static merged with ``set_options``)."""
435
+ return self._core.options_for(self.route)
436
+
437
+ def can_go_back(self) -> bool:
438
+ """Whether ``pop()`` would do anything (here or in a parent)."""
439
+ if self._core.kind == "stack" and len(self._core.state) > 1:
440
+ return True
441
+ parent = self._core.parent
442
+ return parent.can_go_back() if parent is not None else False
443
+
444
+ def is_focused(self) -> bool:
445
+ """Whether this handle's route is the navigator's active route."""
446
+ return self._core.state.current.key == self._route_key
447
+
448
+ # ------------------------------------------------------------------
449
+ # Actions
450
+ # ------------------------------------------------------------------
451
+
452
+ def navigate(self, route: str, /, *, screen: Optional[str] = None, **params: Any) -> None:
453
+ """Go to ``route``: switch to it if already present, otherwise push it.
454
+
455
+ When ``route`` renders a nested navigator, ``screen`` names the
456
+ screen to show inside it and ``params`` go to that screen:
457
+
458
+ ```python
459
+ nav.navigate("Tabs", screen="Profile", user="ada")
460
+ ```
461
+ """
462
+ self._core.navigate(route, *_split_nested(screen, params))
463
+
464
+ def push(self, route: str, /, *, screen: Optional[str] = None, **params: Any) -> None:
465
+ """Push a new instance of ``route`` (stacks; tabs fall back to ``navigate``)."""
466
+ self._core.push(route, *_split_nested(screen, params))
467
+
468
+ def replace(self, route: str, /, *, screen: Optional[str] = None, **params: Any) -> None:
469
+ """Replace the current screen with ``route``."""
470
+ self._core.replace(route, *_split_nested(screen, params))
471
+
472
+ def pop(self, count: int = 1) -> bool:
473
+ """Pop ``count`` screens off the nearest stack; returns whether anything happened."""
474
+ return self._core.pop(count)
475
+
476
+ def go_back(self) -> bool:
477
+ """Alias for ``pop()``."""
478
+ return self._core.pop(1, source="go_back")
479
+
480
+ def pop_to_top(self) -> None:
481
+ """Pop every screen above the first one."""
482
+ self._core.pop_to_top()
483
+
484
+ def reset(self, *routes: Union[str, Route], index: Optional[int] = None, **params: Any) -> None:
485
+ """Replace the whole history.
486
+
487
+ ``nav.reset("Home")`` installs a single route (``params`` apply
488
+ to it); ``nav.reset(Route("A"), Route("B", {...}))`` installs
489
+ several, with ``index`` selecting the active one (last by
490
+ default).
491
+ """
492
+ if not routes:
493
+ raise TypeError("reset() needs at least one route")
494
+ if len(routes) == 1 and isinstance(routes[0], str):
495
+ self._core.reset([Route(routes[0], params)], index)
496
+ return
497
+ if params:
498
+ raise TypeError("params keywords are only allowed when resetting to a single route name")
499
+ resolved = [Route(r) if isinstance(r, str) else r for r in routes]
500
+ self._core.reset(resolved, index)
501
+
502
+ def set_params(self, **params: Any) -> None:
503
+ """Merge ``params`` into this handle's route."""
504
+ self._core.set_params(self._route_key, params)
505
+
506
+ def set_options(self, **options: Any) -> None:
507
+ """Override [`ScreenOptions`][pythonnative.ScreenOptions] for this route at runtime."""
508
+ self._core.set_options(self._route_key, options)
509
+
510
+ def add_listener(self, event: EventName, listener: Listener) -> Callable[[], None]:
511
+ """Subscribe to ``"focus"``, ``"blur"``, ``"before_remove"``, or ``"state"`` for this route.
512
+
513
+ Returns an unsubscribe callable. ``before_remove`` listeners may
514
+ call ``event.prevent_default()`` to keep the screen (useful for
515
+ unsaved-changes prompts). Native back gestures on iOS can't be
516
+ intercepted this way; use ``gesture_enabled=False`` to disable
517
+ them for such screens.
518
+ """
519
+ return self._core.add_listener(self._route_key, event, listener)
520
+
521
+ def __repr__(self) -> str:
522
+ return f"<Navigation {self._core.kind} route={self.route.name!r}>"
523
+
524
+
525
+ def _split_nested(screen: Optional[str], params: Dict[str, Any]) -> Tuple[Dict[str, Any], Optional[NavigationState]]:
526
+ """Turn ``screen=...`` into a nested seed state (params then belong to the nested screen)."""
527
+ if screen is None:
528
+ return params, None
529
+ return {}, NavigationState([Route(screen, params)])
530
+
531
+
532
+ class TabNavigation(Navigation):
533
+ """Handle for screens inside a tab navigator (adds ``jump_to``)."""
534
+
535
+ __slots__ = ()
536
+
537
+ def jump_to(self, route: str, /, **params: Any) -> None:
538
+ """Switch to the tab named ``route``."""
539
+ self._core.navigate(route, params)
540
+
541
+
542
+ class DrawerNavigation(Navigation):
543
+ """Handle for screens inside a drawer navigator (adds drawer controls)."""
544
+
545
+ __slots__ = ()
546
+
547
+ def jump_to(self, route: str, /, **params: Any) -> None:
548
+ """Switch to the drawer screen named ``route`` and close the drawer."""
549
+ self._core.navigate(route, params)
550
+ self._core.set_drawer_open(False)
551
+
552
+ def open_drawer(self) -> None:
553
+ """Slide the drawer menu open."""
554
+ self._core.set_drawer_open(True)
555
+
556
+ def close_drawer(self) -> None:
557
+ """Close the drawer menu."""
558
+ self._core.set_drawer_open(False)
559
+
560
+ def toggle_drawer(self) -> None:
561
+ """Open the drawer menu if it's closed, otherwise close it."""
562
+ self._core.set_drawer_open(not self._core.drawer_open)
563
+
564
+ def is_drawer_open(self) -> bool:
565
+ """Return whether the drawer menu is currently open."""
566
+ return self._core.drawer_open
567
+
568
+
569
+ NavigationContext: Context[Optional[Navigation]] = create_context(None, name="Navigation")
570
+ """Provides the [`Navigation`][pythonnative.Navigation] handle for the current screen."""
571
+
572
+ FocusContext: Context[bool] = create_context(True, name="Focus")
573
+ """Whether the current subtree is the focused screen (see [`use_is_focused`][pythonnative.use_is_focused])."""
@@ -0,0 +1,94 @@
1
+ """Hooks for reading navigation state from inside a screen."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Callable, Optional, Sequence
6
+
7
+ from ..hooks import use_context, use_effect
8
+ from .handle import FocusContext, Navigation, NavigationContext
9
+ from .state import Route
10
+
11
+ __all__ = ["use_focus_effect", "use_is_focused", "use_navigation", "use_route"]
12
+
13
+
14
+ def use_navigation() -> Navigation:
15
+ """Return the [`Navigation`][pythonnative.Navigation] handle for the current screen.
16
+
17
+ Raises:
18
+ RuntimeError: When no navigator encloses the calling component.
19
+
20
+ Example:
21
+ ```python
22
+ @pn.component
23
+ def HomeScreen():
24
+ nav = pn.use_navigation()
25
+ return pn.Button("Open", on_press=lambda: nav.navigate("Detail", id=42))
26
+ ```
27
+ """
28
+ nav = use_context(NavigationContext)
29
+ if nav is None:
30
+ raise RuntimeError(
31
+ "use_navigation() was called outside a navigator. Render the component inside "
32
+ "Stack.Navigator / Tab.Navigator / Drawer.Navigator (see pn.NavigationContainer)."
33
+ )
34
+ return nav
35
+
36
+
37
+ def use_route() -> Route:
38
+ """Return the current screen's [`Route`][pythonnative.navigation.Route].
39
+
40
+ Outside any navigator a placeholder route named ``"__root__"`` with
41
+ empty params is returned, so components can be rendered standalone
42
+ (previews, tests) without special-casing.
43
+
44
+ Example:
45
+ ```python
46
+ @pn.component
47
+ def DetailScreen():
48
+ route = pn.use_route()
49
+ return pn.Text(f"Item {route.params['id']}")
50
+ ```
51
+ """
52
+ nav = use_context(NavigationContext)
53
+ if nav is None:
54
+ return Route("__root__", {}, key="__root__")
55
+ return nav.route
56
+
57
+
58
+ def use_is_focused() -> bool:
59
+ """Whether the calling component is on the focused screen.
60
+
61
+ Combines the native host's lifecycle (a screen covered by a pushed
62
+ native screen is not focused) with the in-tree state of declarative
63
+ navigators (inactive tabs are not focused).
64
+ """
65
+ return bool(use_context(FocusContext))
66
+
67
+
68
+ def use_focus_effect(effect: Callable[[], Any], deps: Optional[Sequence[Any]] = None) -> None:
69
+ """Run ``effect`` while the screen is focused; its cleanup runs on blur.
70
+
71
+ Like [`use_effect`][pythonnative.use_effect], but the callback runs
72
+ only when [`use_is_focused`][pythonnative.use_is_focused] is
73
+ ``True`` and re-runs each time the screen regains focus.
74
+
75
+ Args:
76
+ effect: Zero-arg callable, optionally returning a cleanup.
77
+ deps: Extra dependencies (``None`` re-runs on every focused render).
78
+
79
+ Example:
80
+ ```python
81
+ @pn.component
82
+ def Feed():
83
+ pn.use_focus_effect(lambda: refresh(), [])
84
+ ...
85
+ ```
86
+ """
87
+ focused = use_is_focused()
88
+
89
+ def wrapped() -> Any:
90
+ if focused:
91
+ return effect()
92
+ return None
93
+
94
+ use_effect(wrapped, None if deps is None else [focused, *deps])