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,809 @@
1
+ """Virtualized lists: ``FlatList`` and ``SectionList``.
2
+
3
+ FlatList and SectionList pick between two engines:
4
+
5
+ 1. **Native virtualization** (`_NativeList` -> the ``VirtualList``
6
+ element): on Android and iOS, when every row extent is known up
7
+ front and no windowed-only feature is requested, the list is
8
+ backed by a real ``RecyclerView`` / ``UITableView``. The platform
9
+ owns row recycling; each visible row hosts a nested-reconciler
10
+ subtree (see ``pythonnative.virtual_rows``).
11
+ 2. **Python windowing** (`_VirtualizedList`): a windowed slice of
12
+ rows rendered into a ScrollView (leading spacer, visible rows,
13
+ trailing spacer), the window shifting from scroll events (the
14
+ same architecture as React Native's VirtualizedList). Because
15
+ every windowed row lives in the *main* layout tree, rows may be
16
+ any height: estimates steer the spacer sizes and measured extents
17
+ correct them over time. This is the desktop path and the fallback
18
+ for variable-height rows, grids, horizontal lists, ornaments, and
19
+ pull-to-refresh.
20
+ """
21
+
22
+ import bisect
23
+ from typing import Any, Callable, Dict, List, Optional, Tuple
24
+
25
+ from ..component import component
26
+ from ..element import Element
27
+ from ..hooks import Ref, use_imperative_handle, use_ref, use_state
28
+ from ..style import StyleProp, resolve_style
29
+ from .layout import Column, Row, ScrollView, View
30
+ from .text import Text
31
+
32
+ _DEFAULT_ROW_EXTENT = 44.0
33
+
34
+
35
+ class _RowSpec:
36
+ """One virtualized row: a stable key, a lazy renderer, and an extent hint."""
37
+
38
+ __slots__ = ("key", "make", "extent", "item", "index")
39
+
40
+ def __init__(
41
+ self,
42
+ key: str,
43
+ make: Callable[[], Element],
44
+ extent: Optional[float],
45
+ item: Any = None,
46
+ index: int = 0,
47
+ ) -> None:
48
+ self.key = key
49
+ self.make = make
50
+ self.extent = extent
51
+ self.item = item
52
+ self.index = index
53
+
54
+
55
+ def _dispatch_scroll_command(scroll_ref: Any, name: str, args: Dict[str, Any]) -> Any:
56
+ """Send an imperative command to the ScrollView under ``scroll_ref``."""
57
+ tag = getattr(scroll_ref, "_pn_tag", None)
58
+ if tag is None:
59
+ return None
60
+ from ..native_views import get_registry
61
+
62
+ try:
63
+ return get_registry().command(tag, name, args)
64
+ except Exception:
65
+ return None
66
+
67
+
68
+ class ListController:
69
+ """Imperative scroll handle published on a list's ``ref``.
70
+
71
+ [`FlatList`][pythonnative.FlatList] and
72
+ [`SectionList`][pythonnative.SectionList] install a
73
+ ``ListController`` on ``ref.current`` (via
74
+ [`use_imperative_handle`][pythonnative.use_imperative_handle])
75
+ after mount and clear it back to ``None`` on unmount.
76
+
77
+ Example:
78
+ ```python
79
+ import pythonnative as pn
80
+
81
+ @pn.component
82
+ def Chat(messages):
83
+ list_ref = pn.use_ref()
84
+ pn.use_layout_effect(
85
+ lambda: list_ref.current and list_ref.current.scroll_to_end(animated=False),
86
+ [len(messages)],
87
+ )
88
+ return pn.FlatList(data=messages, render_item=Bubble, ref=list_ref)
89
+ ```
90
+ """
91
+
92
+ __slots__ = ("_scroll_to_offset", "_scroll_to_index", "_scroll_to_end")
93
+
94
+ def __init__(
95
+ self,
96
+ scroll_to_offset: Callable[[float, bool], None],
97
+ scroll_to_index: Callable[[int, bool], None],
98
+ scroll_to_end: Callable[[bool], None],
99
+ ) -> None:
100
+ self._scroll_to_offset = scroll_to_offset
101
+ self._scroll_to_index = scroll_to_index
102
+ self._scroll_to_end = scroll_to_end
103
+
104
+ def scroll_to_offset(self, offset: float, animated: bool = True) -> None:
105
+ """Scroll to an absolute content offset in points."""
106
+ self._scroll_to_offset(offset, animated)
107
+
108
+ def scroll_to_index(self, index: int, animated: bool = True) -> None:
109
+ """Scroll so the row at ``index`` sits at the top of the viewport."""
110
+ self._scroll_to_index(index, animated)
111
+
112
+ def scroll_to_end(self, animated: bool = True) -> None:
113
+ """Scroll to the end of the content."""
114
+ self._scroll_to_end(animated)
115
+
116
+
117
+ @component
118
+ def _VirtualizedList(
119
+ rows: Optional[List[_RowSpec]] = None,
120
+ horizontal: bool = False,
121
+ estimated_row_extent: Optional[float] = None,
122
+ overscan_extent: Optional[float] = None,
123
+ initial_window_extent: Optional[float] = None,
124
+ header: Optional[Element] = None,
125
+ footer: Optional[Element] = None,
126
+ empty: Optional[Element] = None,
127
+ refresh_control: Optional[Dict[str, Any]] = None,
128
+ on_end_reached: Optional[Callable[[], Any]] = None,
129
+ on_end_reached_threshold: Optional[float] = None,
130
+ on_viewable_items_changed: Optional[Callable[[List[Dict[str, Any]]], None]] = None,
131
+ on_scroll: Optional[Callable[[Any], Any]] = None,
132
+ shows_scroll_indicator: bool = True,
133
+ content_container_style: Optional[Dict[str, Any]] = None,
134
+ list_style: Optional[Dict[str, Any]] = None,
135
+ controller_ref: Optional[Ref] = None,
136
+ ) -> Element:
137
+ """Shared windowing engine behind FlatList and SectionList."""
138
+ rows = rows or []
139
+ n = len(rows)
140
+ horizontal = bool(horizontal)
141
+ estimated: float = float(estimated_row_extent or _DEFAULT_ROW_EXTENT)
142
+ overscan: float = float(overscan_extent or 0.0)
143
+ initial_extent: float = float(initial_window_extent or 800.0)
144
+
145
+ window, set_window = use_state((0, -1))
146
+ measured: Ref[Dict[str, float]] = use_ref({}) # row key -> measured extent (points)
147
+ row_refs: Ref[Dict[str, Ref]] = use_ref({}) # row key -> Ref for live rows
148
+ end_latch = use_ref({"fired_for": -1})
149
+ viewable_ref: Ref[Dict[str, Tuple[str, ...]]] = use_ref({"keys": ()})
150
+ scroll_pos = use_ref({"offset": 0.0})
151
+ sv_ref: Ref = use_ref(None)
152
+
153
+ # ------------------------------------------------------------------
154
+ # Extent model: measured > per-row hint > estimate. ``starts`` are
155
+ # prefix sums; ``starts[n]`` is the total content extent.
156
+ # ------------------------------------------------------------------
157
+ measured_map: Dict[str, float] = measured.current
158
+ starts: List[float] = [0.0] * (n + 1)
159
+ acc = 0.0
160
+ for i, spec in enumerate(rows):
161
+ starts[i] = acc
162
+ extent = measured_map.get(spec.key)
163
+ if extent is None:
164
+ extent = spec.extent if spec.extent is not None else estimated
165
+ acc += max(0.0, float(extent))
166
+ starts[n] = acc
167
+ total_extent = acc
168
+
169
+ def _viewport_extent() -> float:
170
+ frame = sv_ref._pn_frame
171
+ if frame:
172
+ extent = frame[2] if horizontal else frame[3]
173
+ if extent and extent > 0:
174
+ return float(extent)
175
+ return initial_extent
176
+
177
+ def _window_for(offset: float, viewport: float) -> Tuple[int, int]:
178
+ if n == 0:
179
+ return (0, -1)
180
+ pad = overscan if overscan > 0 else viewport
181
+ lo = max(0.0, offset - pad)
182
+ hi = offset + viewport + pad
183
+ first = max(0, bisect.bisect_right(starts, lo, 0, n) - 1)
184
+ last = min(n - 1, bisect.bisect_left(starts, hi, 0, n))
185
+ return (first, last)
186
+
187
+ first, last = window
188
+ if last < 0 or first >= n:
189
+ first, last = _window_for(scroll_pos.current["offset"], _viewport_extent())
190
+ last = min(last, n - 1)
191
+ first = max(0, min(first, max(0, n - 1)))
192
+
193
+ # ------------------------------------------------------------------
194
+ # Scroll handling: sweep measured extents, shift the window, fire
195
+ # end-reached / viewability callbacks. State only changes when the
196
+ # window actually moves, so steady scrolling inside the overscan
197
+ # region costs no re-render.
198
+ # ------------------------------------------------------------------
199
+ end_threshold = float(on_end_reached_threshold or 0.5)
200
+ on_viewable = on_viewable_items_changed
201
+ user_on_scroll = on_scroll
202
+
203
+ def _sweep_measured() -> None:
204
+ for row_key, row_ref in row_refs.current.items():
205
+ frame = getattr(row_ref, "_pn_frame", None)
206
+ if frame:
207
+ extent = frame[2] if horizontal else frame[3]
208
+ if extent and extent > 0:
209
+ measured_map[row_key] = float(extent)
210
+
211
+ def _handle_scroll(payload: Any) -> None:
212
+ if isinstance(payload, dict):
213
+ offset = float(payload.get("x" if horizontal else "y", 0.0) or 0.0)
214
+ else:
215
+ offset = float(payload or 0.0)
216
+ scroll_pos.current["offset"] = offset
217
+ _sweep_measured()
218
+ viewport = _viewport_extent()
219
+
220
+ new_window = _window_for(offset, viewport)
221
+ if new_window != (first, last):
222
+ set_window(new_window)
223
+
224
+ if on_end_reached is not None and total_extent > 0:
225
+ remaining = total_extent - (offset + viewport)
226
+ if remaining <= end_threshold * viewport:
227
+ if end_latch.current["fired_for"] != n:
228
+ end_latch.current["fired_for"] = n
229
+ on_end_reached()
230
+ elif remaining > end_threshold * viewport + viewport:
231
+ end_latch.current["fired_for"] = -1
232
+
233
+ if on_viewable is not None and n > 0:
234
+ v_first = max(0, bisect.bisect_right(starts, offset, 0, n) - 1)
235
+ v_last = min(n - 1, bisect.bisect_left(starts, offset + viewport, 0, n))
236
+ keys = tuple(rows[i].key for i in range(v_first, v_last + 1))
237
+ if keys != viewable_ref.current["keys"]:
238
+ viewable_ref.current["keys"] = keys
239
+ on_viewable(
240
+ [
241
+ {"index": rows[i].index, "key": rows[i].key, "item": rows[i].item}
242
+ for i in range(v_first, v_last + 1)
243
+ ]
244
+ )
245
+
246
+ if user_on_scroll is not None:
247
+ user_on_scroll(payload)
248
+
249
+ # ------------------------------------------------------------------
250
+ # Imperative controller (scroll_to_index / offset / end) published
251
+ # on the user's ref. Rebuilt every render (deps=None) so the
252
+ # closures see fresh extents.
253
+ # ------------------------------------------------------------------
254
+ def _scroll_to_offset(offset: float, animated: bool = True) -> None:
255
+ axis = "x" if horizontal else "y"
256
+ _dispatch_scroll_command(sv_ref, "scroll_to_offset", {axis: float(offset), "animated": animated})
257
+
258
+ def _scroll_to_index(index: int, animated: bool = True) -> None:
259
+ idx = max(0, min(int(index), n - 1)) if n else 0
260
+ _scroll_to_offset(starts[idx], animated)
261
+
262
+ def _scroll_to_end(animated: bool = True) -> None:
263
+ _scroll_to_offset(max(0.0, total_extent - _viewport_extent()), animated)
264
+
265
+ use_imperative_handle(
266
+ controller_ref,
267
+ lambda: ListController(_scroll_to_offset, _scroll_to_index, _scroll_to_end),
268
+ None,
269
+ )
270
+
271
+ # ------------------------------------------------------------------
272
+ # Children: header, leading spacer, windowed rows, trailing spacer,
273
+ # footer. Rows keep per-key refs so their measured extents survive
274
+ # recycling.
275
+ # ------------------------------------------------------------------
276
+ spacer_key = "width" if horizontal else "height"
277
+ children: List[Element] = []
278
+ if header is not None:
279
+ children.append(View(header, key="__pn_header__"))
280
+
281
+ if n == 0:
282
+ if empty is not None:
283
+ children.append(View(empty, key="__pn_empty__"))
284
+ else:
285
+ live_refs: Dict[str, Ref] = {}
286
+ lead = starts[first]
287
+ if lead > 0:
288
+ lead_style: Dict[str, Any] = {spacer_key: lead}
289
+ children.append(View(style=lead_style, key="__pn_lead__"))
290
+ for i in range(first, last + 1):
291
+ spec = rows[i]
292
+ row_ref = row_refs.current.get(spec.key) or Ref()
293
+ live_refs[spec.key] = row_ref
294
+ children.append(View(spec.make(), ref=row_ref, key=spec.key))
295
+ row_refs.current = live_refs
296
+ trail = total_extent - starts[last + 1]
297
+ if trail > 0:
298
+ trail_style: Dict[str, Any] = {spacer_key: trail}
299
+ children.append(View(style=trail_style, key="__pn_trail__"))
300
+
301
+ if footer is not None:
302
+ children.append(View(footer, key="__pn_footer__"))
303
+
304
+ wrapper = Row if horizontal else Column
305
+ inner = wrapper(*children, style=content_container_style)
306
+ return ScrollView(
307
+ inner,
308
+ scroll_axis="horizontal" if horizontal else "vertical",
309
+ on_scroll=_handle_scroll,
310
+ refresh_control=refresh_control,
311
+ shows_scroll_indicator=shows_scroll_indicator,
312
+ style=list_style,
313
+ ref=sv_ref,
314
+ )
315
+
316
+
317
+ def _native_lists_supported() -> bool:
318
+ """Whether the natively virtualized list path is available.
319
+
320
+ Android (RecyclerView) and iOS (UITableView) have native handlers;
321
+ the desktop preview and off-device tests use the Python-windowed
322
+ engine. Patchable in tests to exercise the native routing.
323
+ """
324
+ from ..utils import IS_ANDROID, IS_IOS
325
+
326
+ return IS_ANDROID or IS_IOS
327
+
328
+
329
+ def _use_native_lists() -> bool:
330
+ """Resolve ``_native_lists_supported`` through the package at call time.
331
+
332
+ Looking the function up on ``pythonnative.components`` (rather than
333
+ this module's globals) keeps
334
+ ``monkeypatch.setattr(pythonnative.components, "_native_lists_supported", ...)``
335
+ effective now that the implementation lives in a submodule.
336
+ """
337
+ from . import _native_lists_supported as supported
338
+
339
+ return supported()
340
+
341
+
342
+ @component
343
+ def _NativeList(
344
+ rows: Optional[List[_RowSpec]] = None,
345
+ on_end_reached: Optional[Callable[[], Any]] = None,
346
+ on_end_reached_threshold: Optional[float] = None,
347
+ on_viewable_items_changed: Optional[Callable[[List[Dict[str, Any]]], None]] = None,
348
+ on_scroll: Optional[Callable[[Dict[str, float]], None]] = None,
349
+ shows_scroll_indicator: Optional[bool] = None,
350
+ list_style: Optional[Dict[str, Any]] = None,
351
+ controller_ref: Optional[Ref] = None,
352
+ ) -> Element:
353
+ """Platform-virtualized list: emits a ``VirtualList`` native element.
354
+
355
+ The native side (RecyclerView / UITableView) owns row windowing and
356
+ recycling; each visible row hosts a nested-reconciler subtree (see
357
+ ``pythonnative.virtual_rows``). This composite adapts the FlatList /
358
+ SectionList surface onto that element: it forwards ``render_row``,
359
+ derives ``on_end_reached`` and ``on_viewable_items_changed`` from
360
+ native scroll reports, and wires the imperative scroll controller.
361
+
362
+ Requires every row's extent to be known up front (the native
363
+ virtualizers need exact heights before rows are rendered); callers
364
+ fall back to the Python-windowed engine otherwise.
365
+ """
366
+ rows = rows or []
367
+ n = len(rows)
368
+ heights: List[float] = [float(spec.extent or 0.0) for spec in rows]
369
+ uniform = len(set(heights)) <= 1
370
+
371
+ internal_ref: Ref = use_ref(None)
372
+ end_latch = use_ref({"fired_for": -1})
373
+ viewable_ref: Ref[Dict[str, Tuple[str, ...]]] = use_ref({"keys": ()})
374
+
375
+ starts: List[float] = [0.0] * (n + 1)
376
+ acc = 0.0
377
+ for i, extent in enumerate(heights):
378
+ starts[i] = acc
379
+ acc += max(0.0, extent)
380
+ starts[n] = acc
381
+ total_extent = acc
382
+
383
+ def _render_row(index: int) -> Element:
384
+ if 0 <= index < n:
385
+ return rows[index].make()
386
+ return View()
387
+
388
+ end_threshold = float(on_end_reached_threshold or 0.5)
389
+ on_viewable = on_viewable_items_changed
390
+ user_on_scroll = on_scroll
391
+
392
+ def _handle_scroll(payload: Any) -> None:
393
+ offset = float(payload.get("y", 0.0) or 0.0) if isinstance(payload, dict) else float(payload or 0.0)
394
+ viewport = float(payload.get("extent", 0.0) or 0.0) if isinstance(payload, dict) else 0.0
395
+ if viewport <= 0:
396
+ viewport = 800.0
397
+
398
+ if on_end_reached is not None and total_extent > 0:
399
+ remaining = total_extent - (offset + viewport)
400
+ if remaining <= end_threshold * viewport:
401
+ if end_latch.current["fired_for"] != n:
402
+ end_latch.current["fired_for"] = n
403
+ on_end_reached()
404
+ elif remaining > end_threshold * viewport + viewport:
405
+ end_latch.current["fired_for"] = -1
406
+
407
+ if on_viewable is not None and n > 0:
408
+ v_first = max(0, bisect.bisect_right(starts, offset, 0, n) - 1)
409
+ v_last = min(n - 1, bisect.bisect_left(starts, offset + viewport, 0, n))
410
+ keys = tuple(rows[i].key for i in range(v_first, v_last + 1))
411
+ if keys != viewable_ref.current["keys"]:
412
+ viewable_ref.current["keys"] = keys
413
+ on_viewable(
414
+ [
415
+ {"index": rows[i].index, "key": rows[i].key, "item": rows[i].item}
416
+ for i in range(v_first, v_last + 1)
417
+ ]
418
+ )
419
+
420
+ if user_on_scroll is not None:
421
+ user_on_scroll({"x": 0.0, "y": offset})
422
+
423
+ def _scroll_to_offset(offset: float, animated: bool = True) -> None:
424
+ _dispatch_scroll_command(internal_ref, "scroll_to_offset", {"y": float(offset), "animated": animated})
425
+
426
+ def _scroll_to_index(index: int, animated: bool = True) -> None:
427
+ _dispatch_scroll_command(internal_ref, "scroll_to_index", {"index": int(index), "animated": animated})
428
+
429
+ def _scroll_to_end(animated: bool = True) -> None:
430
+ _dispatch_scroll_command(internal_ref, "scroll_to_end", {"animated": animated})
431
+
432
+ use_imperative_handle(
433
+ controller_ref,
434
+ lambda: ListController(_scroll_to_offset, _scroll_to_index, _scroll_to_end),
435
+ None,
436
+ )
437
+
438
+ props: Dict[str, Any] = dict(list_style or {})
439
+ props["count"] = n
440
+ if uniform:
441
+ props["row_height"] = heights[0] if heights else _DEFAULT_ROW_EXTENT
442
+ else:
443
+ props["row_heights"] = heights
444
+ props["render_row"] = _render_row
445
+ props["ref"] = internal_ref
446
+ wants_scroll = on_end_reached is not None or on_viewable is not None or user_on_scroll is not None
447
+ if wants_scroll:
448
+ props["on_scroll"] = _handle_scroll
449
+ if shows_scroll_indicator is False:
450
+ props["shows_scroll_indicator"] = False
451
+ return Element("VirtualList", props, [])
452
+
453
+
454
+ def _all_extents_known(rows: List[_RowSpec]) -> bool:
455
+ return all(spec.extent is not None for spec in rows)
456
+
457
+
458
+ def FlatList(
459
+ *,
460
+ data: Optional[List[Any]] = None,
461
+ render_item: Optional[Callable[[Any, int], Element]] = None,
462
+ key_extractor: Optional[Callable[[Any, int], str]] = None,
463
+ item_height: Optional[float] = None,
464
+ get_item_height: Optional[Callable[[Any, int], float]] = None,
465
+ estimated_item_height: Optional[float] = None,
466
+ separator_height: float = 0,
467
+ refresh_control: Optional[Dict[str, Any]] = None,
468
+ horizontal: bool = False,
469
+ num_columns: int = 1,
470
+ list_header: Optional[Element] = None,
471
+ list_footer: Optional[Element] = None,
472
+ list_empty: Optional[Element] = None,
473
+ on_end_reached: Optional[Callable[[], Any]] = None,
474
+ on_end_reached_threshold: float = 0.5,
475
+ on_viewable_items_changed: Optional[Callable[[List[Dict[str, Any]]], None]] = None,
476
+ on_scroll: Optional[Callable[[Dict[str, float]], None]] = None,
477
+ shows_scroll_indicator: bool = True,
478
+ content_container_style: StyleProp = None,
479
+ style: StyleProp = None,
480
+ ref: Optional[Ref] = None,
481
+ key: Optional[str] = None,
482
+ ) -> Element:
483
+ """Virtualized scrollable list that renders items from ``data`` lazily.
484
+
485
+ Only the rows inside (and just beyond) the viewport are mounted;
486
+ leading and trailing spacers stand in for everything else, and the
487
+ window shifts as the user scrolls. Rows may have **variable
488
+ heights**: pass ``item_height`` when rows are uniform,
489
+ ``get_item_height`` for exact per-item extents, or nothing at all;
490
+ unknown rows start at ``estimated_item_height`` and are corrected
491
+ with their measured extent once they've been on screen.
492
+
493
+ Pass a [`Ref`][pythonnative.Ref] (from
494
+ [`use_ref`][pythonnative.use_ref]) to receive a
495
+ [`ListController`][pythonnative.ListController] on ``ref.current``:
496
+ ``ref.current.scroll_to_index(i)``,
497
+ ``ref.current.scroll_to_offset(pts)``, and
498
+ ``ref.current.scroll_to_end()``.
499
+
500
+ Args:
501
+ data: List of arbitrary item values.
502
+ render_item: ``render_item(item, index) -> Element``. Defaults
503
+ to wrapping each item in a [`Text`][pythonnative.Text].
504
+ key_extractor: Function returning a stable key per item
505
+ (recommended whenever ``data`` can reorder).
506
+ item_height: Uniform row extent in points, when known.
507
+ get_item_height: ``get_item_height(item, index) -> float`` for
508
+ exact variable extents without measurement.
509
+ estimated_item_height: Starting extent estimate for rows whose
510
+ true size isn't known yet (default 44).
511
+ separator_height: Gap below each row, in points.
512
+ refresh_control: Optional pull-to-refresh spec from
513
+ [`RefreshControl`][pythonnative.RefreshControl].
514
+ horizontal: Scroll horizontally (extents become widths).
515
+ num_columns: Render items in a grid of this many columns.
516
+ list_header: Element rendered once before all rows.
517
+ list_footer: Element rendered once after all rows.
518
+ list_empty: Element rendered when ``data`` is empty.
519
+ on_end_reached: Called when the user scrolls within
520
+ ``on_end_reached_threshold`` viewports of the end (fires
521
+ once per data length).
522
+ on_end_reached_threshold: Distance from the end, in viewport
523
+ multiples, at which ``on_end_reached`` fires.
524
+ on_viewable_items_changed: Called with a list of
525
+ ``{"index", "key", "item"}`` dicts whenever the set of
526
+ visible rows changes.
527
+ on_scroll: Called with the raw scroll payload
528
+ (``{"x": …, "y": …}``).
529
+ shows_scroll_indicator: When ``False``, hides the scroll bar.
530
+ content_container_style: Style applied to the inner content
531
+ wrapper.
532
+ style: Style for the outer scroll container.
533
+ ref: Optional [`Ref`][pythonnative.Ref]; receives a
534
+ [`ListController`][pythonnative.ListController] on
535
+ ``ref.current`` after mount.
536
+ key: Stable identity for keyed reconciliation of the list.
537
+
538
+ Returns:
539
+ A virtualized list element (a function component instance).
540
+
541
+ Example:
542
+ ```python
543
+ import pythonnative as pn
544
+
545
+ items = [{"id": i, "name": f"Item {i}"} for i in range(10000)]
546
+
547
+ pn.FlatList(
548
+ data=items,
549
+ item_height=44,
550
+ render_item=lambda item, _: pn.Text(item["name"]),
551
+ key_extractor=lambda item, _: str(item["id"]),
552
+ )
553
+ ```
554
+ """
555
+ items_list = list(data or [])
556
+ sep = float(separator_height or 0.0)
557
+
558
+ def _row_key(item: Any, index: int) -> str:
559
+ if key_extractor is not None:
560
+ try:
561
+ return str(key_extractor(item, index))
562
+ except Exception:
563
+ pass
564
+ return f"__pn_row_{index}__"
565
+
566
+ def _row_extent(item: Any, index: int) -> Optional[float]:
567
+ if get_item_height is not None:
568
+ try:
569
+ return float(get_item_height(item, index)) + sep
570
+ except Exception:
571
+ return None
572
+ if item_height is not None:
573
+ return float(item_height) + sep
574
+ return None
575
+
576
+ def _make_row(item: Any, index: int) -> Callable[[], Element]:
577
+ def _make() -> Element:
578
+ el = render_item(item, index) if render_item else Text(str(item))
579
+ if sep > 0:
580
+ pad_style: Dict[str, Any] = {"padding_end" if horizontal else "padding_bottom": sep}
581
+ return View(el, style=pad_style)
582
+ return el
583
+
584
+ return _make
585
+
586
+ rows: List[_RowSpec] = []
587
+ if num_columns > 1 and not horizontal:
588
+ for start in range(0, len(items_list), num_columns):
589
+ chunk = items_list[start : start + num_columns]
590
+
591
+ def _make_group(group: List[Any] = chunk, base: int = start) -> Element:
592
+ cells = [
593
+ View(
594
+ render_item(it, base + j) if render_item else Text(str(it)),
595
+ style={"flex": 1},
596
+ key=_row_key(it, base + j),
597
+ )
598
+ for j, it in enumerate(group)
599
+ ]
600
+ row = Row(*cells)
601
+ if sep > 0:
602
+ return View(row, style={"padding_bottom": sep})
603
+ return row
604
+
605
+ group_key = "__pn_grp_" + "|".join(_row_key(it, start + j) for j, it in enumerate(chunk))
606
+ extent = (float(item_height) + sep) if item_height is not None else None
607
+ rows.append(_RowSpec(group_key, _make_group, extent, item=chunk, index=start))
608
+ else:
609
+ for i, item in enumerate(items_list):
610
+ rows.append(_RowSpec(_row_key(item, i), _make_row(item, i), _row_extent(item, i), item=item, index=i))
611
+
612
+ estimated = estimated_item_height if estimated_item_height is not None else (item_height or _DEFAULT_ROW_EXTENT)
613
+
614
+ # Route to the platform virtualizer (RecyclerView / UITableView)
615
+ # when it can represent this list exactly: vertical, single-column,
616
+ # every row extent known up front, and no features that only the
617
+ # Python-windowed engine implements (ornaments, pull-to-refresh).
618
+ if (
619
+ _use_native_lists()
620
+ and not horizontal
621
+ and num_columns == 1
622
+ and list_header is None
623
+ and list_footer is None
624
+ and list_empty is None
625
+ and refresh_control is None
626
+ and _all_extents_known(rows)
627
+ ):
628
+ return _NativeList(
629
+ rows=rows,
630
+ on_end_reached=on_end_reached,
631
+ on_end_reached_threshold=on_end_reached_threshold,
632
+ on_viewable_items_changed=on_viewable_items_changed,
633
+ on_scroll=on_scroll,
634
+ shows_scroll_indicator=shows_scroll_indicator,
635
+ list_style=resolve_style(style) or None,
636
+ controller_ref=ref,
637
+ ).with_key(key)
638
+
639
+ return _VirtualizedList(
640
+ rows=rows,
641
+ horizontal=horizontal,
642
+ estimated_row_extent=float(estimated) + sep,
643
+ header=list_header,
644
+ footer=list_footer,
645
+ empty=list_empty,
646
+ refresh_control=refresh_control,
647
+ on_end_reached=on_end_reached,
648
+ on_end_reached_threshold=on_end_reached_threshold,
649
+ on_viewable_items_changed=on_viewable_items_changed,
650
+ on_scroll=on_scroll,
651
+ shows_scroll_indicator=shows_scroll_indicator,
652
+ content_container_style=resolve_style(content_container_style) or None,
653
+ list_style=resolve_style(style) or None,
654
+ controller_ref=ref,
655
+ ).with_key(key)
656
+
657
+
658
+ def SectionList(
659
+ *,
660
+ sections: Optional[List[Dict[str, Any]]] = None,
661
+ render_item: Optional[Callable[[Any, int, int], Element]] = None,
662
+ render_section_header: Optional[Callable[[Dict[str, Any], int], Element]] = None,
663
+ key_extractor: Optional[Callable[[Any, int], str]] = None,
664
+ item_height: Optional[float] = None,
665
+ get_item_height: Optional[Callable[[Any, int, int], float]] = None,
666
+ estimated_item_height: Optional[float] = None,
667
+ section_header_height: Optional[float] = None,
668
+ separator_height: float = 0,
669
+ refresh_control: Optional[Dict[str, Any]] = None,
670
+ list_header: Optional[Element] = None,
671
+ list_footer: Optional[Element] = None,
672
+ list_empty: Optional[Element] = None,
673
+ on_end_reached: Optional[Callable[[], Any]] = None,
674
+ on_end_reached_threshold: float = 0.5,
675
+ on_scroll: Optional[Callable[[Dict[str, float]], None]] = None,
676
+ style: StyleProp = None,
677
+ ref: Optional[Ref] = None,
678
+ key: Optional[str] = None,
679
+ ) -> Element:
680
+ """Virtualized list with section headers interleaved between row groups.
681
+
682
+ Flattens ``sections`` into a single virtualized sequence where each
683
+ entry is either a header or an item, then reuses the same windowing
684
+ engine as [`FlatList`][pythonnative.FlatList]; headers and items
685
+ may have different (and variable) heights.
686
+
687
+ Args:
688
+ sections: Each section is ``{"title": ..., "data": [...]}``.
689
+ render_item: ``render_item(item, item_index, section_index) ->
690
+ Element``.
691
+ render_section_header: ``render_section_header(section,
692
+ section_index) -> Element``. Defaults to a bold
693
+ [`Text`][pythonnative.Text] of the section title.
694
+ key_extractor: Stable key per item: ``key_extractor(item,
695
+ item_index) -> str``.
696
+ item_height: Uniform item extent in points, when known.
697
+ get_item_height: ``get_item_height(item, item_index,
698
+ section_index) -> float`` for exact variable extents.
699
+ estimated_item_height: Starting estimate for unmeasured rows.
700
+ section_header_height: Header extent in points, when known.
701
+ separator_height: Gap below each item, in points.
702
+ refresh_control: Optional pull-to-refresh spec.
703
+ list_header: Element rendered once before everything.
704
+ list_footer: Element rendered once after everything.
705
+ list_empty: Element rendered when there are no sections.
706
+ on_end_reached: Called near the end of the content.
707
+ on_end_reached_threshold: Distance from the end, in viewport
708
+ multiples, at which ``on_end_reached`` fires.
709
+ on_scroll: Called with the raw scroll payload.
710
+ style: Style for the outer scroll container.
711
+ ref: Optional [`Ref`][pythonnative.Ref]; receives a
712
+ [`ListController`][pythonnative.ListController] on
713
+ ``ref.current`` after mount.
714
+ key: Stable identity for keyed reconciliation of the list.
715
+
716
+ Returns:
717
+ A virtualized list element (a function component instance).
718
+ """
719
+ sections_list = list(sections or [])
720
+ sep = float(separator_height or 0.0)
721
+
722
+ def _header_el(section: Dict[str, Any], s_idx: int) -> Element:
723
+ if render_section_header is not None:
724
+ return render_section_header(section, s_idx)
725
+ return Text(str(section.get("title", "")), style={"bold": True, "padding": 8})
726
+
727
+ def _item_el(item: Any, i_idx: int, s_idx: int) -> Element:
728
+ if render_item is not None:
729
+ return render_item(item, i_idx, s_idx)
730
+ return Text(str(item))
731
+
732
+ rows: List[_RowSpec] = []
733
+ flat_index = 0
734
+ for s_idx, section in enumerate(sections_list):
735
+
736
+ def _make_header(sec: Dict[str, Any] = section, si: int = s_idx) -> Element:
737
+ return _header_el(sec, si)
738
+
739
+ rows.append(
740
+ _RowSpec(
741
+ f"__pn_sec_{s_idx}__",
742
+ _make_header,
743
+ float(section_header_height) if section_header_height is not None else None,
744
+ item=section,
745
+ index=flat_index,
746
+ )
747
+ )
748
+ flat_index += 1
749
+ for i_idx, item in enumerate(section.get("data", []) or []):
750
+ if key_extractor is not None:
751
+ try:
752
+ row_key = f"s{s_idx}:" + str(key_extractor(item, i_idx))
753
+ except Exception:
754
+ row_key = f"__pn_row_{s_idx}_{i_idx}__"
755
+ else:
756
+ row_key = f"__pn_row_{s_idx}_{i_idx}__"
757
+
758
+ def _make_item(it: Any = item, ii: int = i_idx, si: int = s_idx) -> Element:
759
+ el = _item_el(it, ii, si)
760
+ if sep > 0:
761
+ return View(el, style={"padding_bottom": sep})
762
+ return el
763
+
764
+ extent: Optional[float] = None
765
+ if get_item_height is not None:
766
+ try:
767
+ extent = float(get_item_height(item, i_idx, s_idx)) + sep
768
+ except Exception:
769
+ extent = None
770
+ elif item_height is not None:
771
+ extent = float(item_height) + sep
772
+ rows.append(_RowSpec(row_key, _make_item, extent, item=item, index=flat_index))
773
+ flat_index += 1
774
+
775
+ estimated = estimated_item_height if estimated_item_height is not None else (item_height or _DEFAULT_ROW_EXTENT)
776
+
777
+ # Same native routing as FlatList: headers and items become one
778
+ # flattened row sequence with per-row heights.
779
+ if (
780
+ _use_native_lists()
781
+ and list_header is None
782
+ and list_footer is None
783
+ and list_empty is None
784
+ and refresh_control is None
785
+ and _all_extents_known(rows)
786
+ ):
787
+ return _NativeList(
788
+ rows=rows,
789
+ on_end_reached=on_end_reached,
790
+ on_end_reached_threshold=on_end_reached_threshold,
791
+ on_scroll=on_scroll,
792
+ list_style=resolve_style(style) or None,
793
+ controller_ref=ref,
794
+ ).with_key(key)
795
+
796
+ return _VirtualizedList(
797
+ rows=rows,
798
+ horizontal=False,
799
+ estimated_row_extent=float(estimated) + sep,
800
+ header=list_header,
801
+ footer=list_footer,
802
+ empty=list_empty,
803
+ refresh_control=refresh_control,
804
+ on_end_reached=on_end_reached,
805
+ on_end_reached_threshold=on_end_reached_threshold,
806
+ on_scroll=on_scroll,
807
+ list_style=resolve_style(style) or None,
808
+ controller_ref=ref,
809
+ ).with_key(key)