pythonnative 0.22.1__py3-none-any.whl → 0.24.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 (35) hide show
  1. pythonnative/__init__.py +43 -50
  2. pythonnative/animated.py +1 -1
  3. pythonnative/appearance.py +124 -0
  4. pythonnative/cli/pn.py +3 -3
  5. pythonnative/components.py +873 -466
  6. pythonnative/diagnostics.py +214 -0
  7. pythonnative/element.py +5 -2
  8. pythonnative/events.py +13 -8
  9. pythonnative/hooks.py +454 -49
  10. pythonnative/hot_reload.py +9 -1
  11. pythonnative/images.py +196 -0
  12. pythonnative/mutations.py +3 -12
  13. pythonnative/native_views/__init__.py +1 -7
  14. pythonnative/native_views/android.py +651 -46
  15. pythonnative/native_views/desktop.py +116 -20
  16. pythonnative/native_views/ios.py +974 -52
  17. pythonnative/navigation.py +41 -17
  18. pythonnative/preview.py +74 -7
  19. pythonnative/project/config.py +1 -9
  20. pythonnative/reconciler.py +863 -441
  21. pythonnative/screen.py +409 -27
  22. pythonnative/storage.py +3 -3
  23. pythonnative/style.py +148 -6
  24. pythonnative/templates/android_template/app/src/main/AndroidManifest.xml +2 -1
  25. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNAccessibilityDelegate.kt +47 -0
  26. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNBorderDrawable.kt +67 -0
  27. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNVirtualListView.java +172 -0
  28. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/ScreenFragment.kt +22 -0
  29. pythonnative/virtual_rows.py +137 -0
  30. {pythonnative-0.22.1.dist-info → pythonnative-0.24.0.dist-info}/METADATA +5 -4
  31. {pythonnative-0.22.1.dist-info → pythonnative-0.24.0.dist-info}/RECORD +35 -28
  32. {pythonnative-0.22.1.dist-info → pythonnative-0.24.0.dist-info}/WHEEL +1 -1
  33. {pythonnative-0.22.1.dist-info → pythonnative-0.24.0.dist-info}/entry_points.txt +0 -0
  34. {pythonnative-0.22.1.dist-info → pythonnative-0.24.0.dist-info}/licenses/LICENSE +0 -0
  35. {pythonnative-0.22.1.dist-info → pythonnative-0.24.0.dist-info}/top_level.txt +0 -0
@@ -1,16 +1,12 @@
1
- """Built-in element factories and the typed prop schemas they share.
1
+ """Built-in element factories.
2
2
 
3
- Each ``@dataclass(frozen=True)`` class in this module (``TextProps``,
4
- ``ButtonProps``, etc.) is the canonical schema for one built-in
5
- component. Each factory function (``Text``, ``Button``, …) is a thin
6
- ergonomic wrapper that builds an [`Element`][pythonnative.Element]
7
- through the shared :func:`_make_element` helper, so style resolution,
8
- ``ref`` attachment, ``None``-default dropping, and forced overrides
9
- (e.g. ``Column``'s fixed ``flex_direction``) live in exactly one place.
10
-
11
- The same Props dataclasses are used by the `pythonnative.sdk` surface
12
- for third-party components, so the built-in API and the extension API
13
- speak the same shape.
3
+ Each factory function (``Text``, ``Button``, …) is a fully-typed thin
4
+ wrapper that builds an [`Element`][pythonnative.Element] through the
5
+ shared :func:`_make_element` helper, so style resolution, ``ref``
6
+ attachment, ``None``-default dropping, and forced overrides (e.g.
7
+ ``Column``'s fixed ``flex_direction``) live in exactly one place. The
8
+ factory signatures themselves are the canonical prop schemas: editors
9
+ and type checkers validate calls directly against them.
14
10
 
15
11
  Example:
16
12
  ```python
@@ -18,20 +14,27 @@ Example:
18
14
 
19
15
  pn.Column(
20
16
  pn.Text("Hello", style=pn.style(font_size=18)),
21
- pn.Button("Tap", on_click=lambda: print("tapped")),
17
+ pn.Button("Tap", on_press=lambda: print("tapped")),
22
18
  style=pn.style(spacing=12, padding=16),
23
19
  )
24
20
  ```
25
21
  """
26
22
 
27
23
  import bisect
28
- from dataclasses import dataclass, field
29
- from typing import Any, Callable, Dict, List, Literal, Optional, Tuple
24
+ from typing import Any, Callable, Dict, List, Literal, Optional, Tuple, Union
30
25
 
31
26
  from .element import Element
32
- from .hooks import component, use_effect, use_ref, use_state
33
- from .sdk import Props
27
+ from .hooks import (
28
+ Ref,
29
+ component,
30
+ use_imperative_handle,
31
+ use_keyboard_height,
32
+ use_ref,
33
+ use_safe_area_insets,
34
+ use_state,
35
+ )
34
36
  from .style import (
37
+ AccessibilityState,
35
38
  AutoCapitalize,
36
39
  Color,
37
40
  KeyboardType,
@@ -39,6 +42,7 @@ from .style import (
39
42
  ScaleType,
40
43
  StyleProp,
41
44
  resolve_style,
45
+ validate_style_keys,
42
46
  )
43
47
 
44
48
  # ======================================================================
@@ -50,7 +54,7 @@ def _make_element(
50
54
  name: str,
51
55
  *children: Element,
52
56
  style: StyleProp = None,
53
- ref: Optional[Dict[str, Any]] = None,
57
+ ref: Optional[Ref] = None,
54
58
  key: Optional[str] = None,
55
59
  _defaults: Optional[Dict[str, Any]] = None,
56
60
  _forced: Optional[Dict[str, Any]] = None,
@@ -78,8 +82,9 @@ def _make_element(
78
82
  name: Element type name (e.g. ``"Text"``).
79
83
  *children: Child elements.
80
84
  style: Style dict, list of dicts, or ``None``.
81
- ref: Optional ``use_ref()`` dict; the reconciler populates
82
- ``ref["current"]`` with the underlying native view.
85
+ ref: Optional [`Ref`][pythonnative.Ref] from ``use_ref()``; the
86
+ reconciler populates ``ref.current`` with the underlying
87
+ native view.
83
88
  key: Stable identity for keyed reconciliation.
84
89
  _defaults: Internal: fill-only-if-missing prop defaults.
85
90
  _forced: Internal: prop overrides applied last.
@@ -89,6 +94,8 @@ def _make_element(
89
94
  A fresh [`Element`][pythonnative.Element].
90
95
  """
91
96
  out: Dict[str, Any] = dict(resolve_style(style))
97
+ if out:
98
+ validate_style_keys(out, owner=name)
92
99
  if _defaults:
93
100
  for k, v in _defaults.items():
94
101
  out.setdefault(k, v)
@@ -103,325 +110,75 @@ def _make_element(
103
110
 
104
111
 
105
112
  # ======================================================================
106
- # Props dataclasses
113
+ # Leaf factories
107
114
  # ======================================================================
108
- #
109
- # These are the canonical schemas for every built-in component. They
110
- # subclass the SDK's ``Props`` base, so the same shape works for both
111
- # the built-in factory functions and the third-party
112
- # [`element_factory`][pythonnative.element_factory] API.
113
-
114
-
115
- @dataclass(frozen=True)
116
- class TextProps(Props):
117
- """Props for [`Text`][pythonnative.Text]."""
118
-
119
- text: str = ""
120
- accessibility_label: Optional[str] = None
121
- accessibility_hint: Optional[str] = None
122
- accessibility_role: Optional[str] = None
123
- accessible: Optional[bool] = None
124
-
125
-
126
- @dataclass(frozen=True)
127
- class ButtonProps(Props):
128
- """Props for [`Button`][pythonnative.Button]."""
129
-
130
- title: str = ""
131
- on_click: Optional[Callable[[], None]] = None
132
- enabled: bool = True
133
- accessibility_label: Optional[str] = None
134
- accessibility_hint: Optional[str] = None
135
- accessibility_role: Optional[str] = None
136
- accessible: Optional[bool] = None
137
-
138
-
139
- @dataclass(frozen=True)
140
- class TextInputProps(Props):
141
- """Props for [`TextInput`][pythonnative.TextInput]."""
142
-
143
- value: str = ""
144
- placeholder: Optional[str] = None
145
- on_change: Optional[Callable[[str], None]] = None
146
- on_submit: Optional[Callable[[str], None]] = None
147
- secure: bool = False
148
- multiline: bool = False
149
- keyboard_type: Optional[KeyboardType] = None
150
- auto_capitalize: Optional[AutoCapitalize] = None
151
- auto_correct: Optional[bool] = None
152
- auto_focus: bool = False
153
- return_key_type: Optional[ReturnKeyType] = None
154
- max_length: Optional[int] = None
155
- placeholder_color: Optional[Color] = None
156
- editable: bool = True
157
- clear_button: bool = False
158
- on_focus: Optional[Callable[[], None]] = None
159
- on_blur: Optional[Callable[[], None]] = None
160
- selection_color: Optional[Color] = None
161
- text_content_type: Optional[str] = None
162
- accessibility_label: Optional[str] = None
163
- accessibility_hint: Optional[str] = None
164
- accessible: Optional[bool] = None
165
-
166
-
167
- @dataclass(frozen=True)
168
- class ImageProps(Props):
169
- """Props for [`Image`][pythonnative.Image]."""
170
-
171
- source: Optional[str] = None
172
- scale_type: Optional[ScaleType] = None
173
- tint_color: Optional[Color] = None
174
- accessibility_label: Optional[str] = None
175
- accessibility_role: Optional[str] = None
176
- accessible: Optional[bool] = None
177
-
178
-
179
- @dataclass(frozen=True)
180
- class SwitchProps(Props):
181
- """Props for [`Switch`][pythonnative.Switch]."""
182
-
183
- value: bool = False
184
- on_change: Optional[Callable[[bool], None]] = None
185
- accessibility_label: Optional[str] = None
186
-
187
-
188
- @dataclass(frozen=True)
189
- class ProgressBarProps(Props):
190
- """Props for [`ProgressBar`][pythonnative.ProgressBar]."""
191
-
192
- value: float = 0.0
193
- color: Optional[Color] = None
194
- track_color: Optional[Color] = None
195
- indeterminate: bool = False
196
-
197
-
198
- @dataclass(frozen=True)
199
- class ActivityIndicatorProps(Props):
200
- """Props for [`ActivityIndicator`][pythonnative.ActivityIndicator]."""
201
-
202
- animating: bool = True
203
- color: Optional[Color] = None
204
- size: Literal["small", "large"] = "small"
205
-
206
-
207
- @dataclass(frozen=True)
208
- class WebViewProps(Props):
209
- """Props for [`WebView`][pythonnative.WebView]."""
210
-
211
- url: Optional[str] = None
212
- html: Optional[str] = None
213
- on_load: Optional[Callable[[str], None]] = None
214
- on_message: Optional[Callable[[str], None]] = None
215
- on_navigation_state_change: Optional[Callable[[str], None]] = None
216
- inject_javascript: Optional[str] = None
217
- scroll_enabled: bool = True
218
-
219
-
220
- @dataclass(frozen=True)
221
- class SpacerProps(Props):
222
- """Props for [`Spacer`][pythonnative.Spacer]."""
223
-
224
- size: Optional[float] = None
225
- flex: Optional[float] = None
226
-
227
-
228
- @dataclass(frozen=True)
229
- class SliderProps(Props):
230
- """Props for [`Slider`][pythonnative.Slider]."""
231
-
232
- value: float = 0.0
233
- min_value: float = 0.0
234
- max_value: float = 1.0
235
- on_change: Optional[Callable[[float], None]] = None
236
- accessibility_label: Optional[str] = None
237
-
238
-
239
- @dataclass(frozen=True)
240
- class ViewProps(Props):
241
- """Props for [`View`][pythonnative.View], [`Column`][pythonnative.Column], and [`Row`][pythonnative.Row]."""
242
-
243
- gestures: Optional[List[Any]] = None
244
- accessibility_label: Optional[str] = None
245
- accessibility_hint: Optional[str] = None
246
- accessibility_role: Optional[str] = None
247
- accessible: Optional[bool] = None
248
-
249
115
 
250
- @dataclass(frozen=True)
251
- class ScrollViewProps(Props):
252
- """Props for [`ScrollView`][pythonnative.ScrollView].
253
-
254
- ``on_scroll`` receives a single payload dict with ``"x"`` and
255
- ``"y"`` content offsets in points.
256
- """
257
-
258
- refresh_control: Optional[Dict[str, Any]] = None
259
- scroll_axis: Optional[Literal["vertical", "horizontal"]] = None
260
- on_scroll: Optional[Callable[[Dict[str, float]], None]] = None
261
- shows_scroll_indicator: bool = True
262
- paging_enabled: bool = False
263
- bounces: bool = True
264
- content_container_style: StyleProp = None
265
- keyboard_dismiss_mode: Optional[Literal["none", "on_drag", "interactive"]] = None
266
-
267
-
268
- @dataclass(frozen=True)
269
- class SafeAreaViewProps(Props):
270
- """Props for [`SafeAreaView`][pythonnative.SafeAreaView]."""
271
-
272
-
273
- @dataclass(frozen=True)
274
- class ModalProps(Props):
275
- """Props for [`Modal`][pythonnative.Modal]."""
276
-
277
- visible: bool = False
278
- on_dismiss: Optional[Callable[[], None]] = None
279
- on_show: Optional[Callable[[], None]] = None
280
- title: Optional[str] = None
281
- animation_type: Literal["slide", "fade", "none"] = "slide"
282
- transparent: bool = False
283
- presentation_style: Literal["page_sheet", "form_sheet", "full_screen", "overlay"] = "page_sheet"
284
- dismiss_on_backdrop: bool = True
285
-
286
-
287
- @dataclass(frozen=True)
288
- class PressableProps(Props):
289
- """Props for [`Pressable`][pythonnative.Pressable]."""
290
-
291
- on_press: Optional[Callable[[], None]] = None
292
- on_long_press: Optional[Callable[[], None]] = None
293
- on_press_in: Optional[Callable[[], None]] = None
294
- on_press_out: Optional[Callable[[], None]] = None
295
- pressed_opacity: float = 0.6
296
- gestures: Optional[List[Any]] = None
297
- accessibility_label: Optional[str] = None
298
- accessibility_hint: Optional[str] = None
299
- accessibility_role: Optional[str] = None
300
- accessible: Optional[bool] = None
301
-
302
-
303
- @dataclass(frozen=True)
304
- class StatusBarProps(Props):
305
- """Props for [`StatusBar`][pythonnative.StatusBar]."""
306
-
307
- bar_style: Optional[Literal["light", "dark", "default"]] = None
308
- background_color: Optional[Color] = None
309
- hidden: Optional[bool] = None
310
-
311
-
312
- @dataclass(frozen=True)
313
- class KeyboardAvoidingViewProps(Props):
314
- """Props for [`KeyboardAvoidingView`][pythonnative.KeyboardAvoidingView]."""
315
-
316
- behavior: Literal["padding", "position"] = "padding"
317
116
 
117
+ # Style keys that can vary per span inside a rich-text run. Layout
118
+ # keys are excluded: spans are inline fragments of one paragraph and
119
+ # have no boxes of their own.
120
+ _SPAN_STYLE_KEYS = (
121
+ "color",
122
+ "background_color",
123
+ "font_size",
124
+ "font_family",
125
+ "font_weight",
126
+ "bold",
127
+ "italic",
128
+ "text_decoration",
129
+ "letter_spacing",
130
+ )
318
131
 
319
- @dataclass(frozen=True)
320
- class PickerProps(Props):
321
- """Props for [`Picker`][pythonnative.Picker].
322
132
 
323
- ``items`` is an ordered list of ``{"value": Any, "label": str}``
324
- entries. ``value`` is matched against ``items[i]["value"]`` to
325
- determine the currently selected row.
326
- """
133
+ def _flatten_text_spans(
134
+ parts: Tuple[Any, ...],
135
+ inherited: Dict[str, Any],
136
+ ) -> List[Dict[str, Any]]:
137
+ """Flatten nested ``Text`` parts into a flat list of styled spans.
327
138
 
328
- value: Any = None
329
- items: List[Dict[str, Any]] = field(default_factory=list)
330
- on_change: Optional[Callable[[Any], None]] = None
331
- placeholder: str = "Select…"
332
- accessibility_label: Optional[str] = None
333
- accessibility_hint: Optional[str] = None
334
- accessible: Optional[bool] = None
335
-
336
-
337
- @dataclass(frozen=True)
338
- class TouchableOpacityProps(Props):
339
- """Props for [`TouchableOpacity`][pythonnative.TouchableOpacity]."""
340
-
341
- on_press: Optional[Callable[[], None]] = None
342
- on_long_press: Optional[Callable[[], None]] = None
343
- active_opacity: float = 0.2
344
- disabled: bool = False
345
- accessibility_label: Optional[str] = None
346
- accessibility_hint: Optional[str] = None
347
- accessibility_role: Optional[str] = None
348
- accessible: Optional[bool] = None
349
-
350
-
351
- @dataclass(frozen=True)
352
- class ImageBackgroundProps(Props):
353
- """Props for [`ImageBackground`][pythonnative.ImageBackground]."""
354
-
355
- source: Optional[str] = None
356
- scale_type: Optional[ScaleType] = None
357
- accessibility_label: Optional[str] = None
358
- accessible: Optional[bool] = None
359
-
360
-
361
- @dataclass(frozen=True)
362
- class CheckboxProps(Props):
363
- """Props for [`Checkbox`][pythonnative.Checkbox]."""
364
-
365
- value: bool = False
366
- on_change: Optional[Callable[[bool], None]] = None
367
- label: Optional[str] = None
368
- disabled: bool = False
369
- color: Optional[Color] = None
370
- accessibility_label: Optional[str] = None
371
- accessibility_hint: Optional[str] = None
372
- accessible: Optional[bool] = None
373
-
374
-
375
- @dataclass(frozen=True)
376
- class SegmentedControlProps(Props):
377
- """Props for [`SegmentedControl`][pythonnative.SegmentedControl]."""
378
-
379
- segments: List[str] = field(default_factory=list)
380
- selected_index: int = 0
381
- on_change: Optional[Callable[[int], None]] = None
382
- enabled: bool = True
383
- tint_color: Optional[Color] = None
384
- accessibility_label: Optional[str] = None
385
- accessible: Optional[bool] = None
386
-
387
-
388
- @dataclass(frozen=True)
389
- class DatePickerProps(Props):
390
- """Props for [`DatePicker`][pythonnative.DatePicker].
391
-
392
- ``value`` and the value passed to ``on_change`` are ISO-8601
393
- strings (``"2026-05-31"`` for ``mode="date"``, ``"14:30"`` for
394
- ``mode="time"``, ``"2026-05-31T14:30"`` for ``mode="datetime"``),
395
- so the schema stays JSON-serializable and platform-agnostic.
139
+ Strings become spans carrying only the inherited style overrides;
140
+ nested ``Text`` elements contribute their own span-style keys
141
+ (merged over what they inherit) and recurse into their parts.
396
142
  """
397
-
398
- value: Optional[str] = None
399
- mode: Literal["date", "time", "datetime"] = "date"
400
- on_change: Optional[Callable[[str], None]] = None
401
- minimum: Optional[str] = None
402
- maximum: Optional[str] = None
403
- enabled: bool = True
404
- accessibility_label: Optional[str] = None
405
- accessible: Optional[bool] = None
406
-
407
-
408
- # ======================================================================
409
- # Leaf factories
410
- # ======================================================================
143
+ spans: List[Dict[str, Any]] = []
144
+ for part in parts:
145
+ if part is None:
146
+ continue
147
+ if isinstance(part, Element):
148
+ props = part.props or {}
149
+ child_style = dict(inherited)
150
+ for k in _SPAN_STYLE_KEYS:
151
+ if props.get(k) is not None:
152
+ child_style[k] = props[k]
153
+ nested = props.get("spans")
154
+ if nested:
155
+ for span in nested:
156
+ merged = dict(child_style)
157
+ for k in _SPAN_STYLE_KEYS:
158
+ if span.get(k) is not None:
159
+ merged[k] = span[k]
160
+ spans.append({"text": span.get("text", ""), **merged})
161
+ else:
162
+ spans.append({"text": str(props.get("text", "")), **child_style})
163
+ else:
164
+ spans.append({"text": str(part), **inherited})
165
+ return spans
411
166
 
412
167
 
413
168
  def Text(
414
- text: str = "",
415
- *,
169
+ *parts: Any,
416
170
  style: StyleProp = None,
417
171
  accessibility_label: Optional[str] = None,
418
172
  accessibility_hint: Optional[str] = None,
419
173
  accessibility_role: Optional[str] = None,
420
174
  accessible: Optional[bool] = None,
421
- ref: Optional[Dict[str, Any]] = None,
175
+ accessibility_state: Optional[AccessibilityState] = None,
176
+ accessibility_live_region: Optional[Literal["none", "polite", "assertive"]] = None,
177
+ test_id: Optional[str] = None,
178
+ ref: Optional[Ref] = None,
422
179
  key: Optional[str] = None,
423
180
  ) -> Element:
424
- """Display a string of text.
181
+ """Display a string of text, optionally with styled nested spans.
425
182
 
426
183
  Style properties: ``font_size``, ``color``, ``bold``,
427
184
  ``font_weight``, ``font_family``, ``italic``, ``text_align``,
@@ -431,43 +188,87 @@ def Text(
431
188
  ``border_color``, ``shadow_*``, ``opacity``, ``transform``, plus
432
189
  the common layout props.
433
190
 
191
+ **Rich text**: pass multiple parts, mixing plain strings and
192
+ nested ``Text`` elements, to render one paragraph with per-span
193
+ styling (a single ``TextView`` / ``UILabel`` natively, so line
194
+ wrapping flows across spans):
195
+
196
+ ```python
197
+ pn.Text(
198
+ "Hello, ",
199
+ pn.Text("world", style=pn.style(bold=True, color="#0A84FF")),
200
+ "!",
201
+ style=pn.style(font_size=18),
202
+ )
203
+ ```
204
+
205
+ Nested spans inherit the outer element's text styling and may
206
+ override ``color``, ``background_color``, ``font_size``,
207
+ ``font_family``, ``font_weight``, ``bold``, ``italic``,
208
+ ``text_decoration``, and ``letter_spacing``.
209
+
434
210
  Args:
435
- text: Text content to display.
211
+ *parts: Text content: a single string, or any mix of strings
212
+ and nested ``Text`` elements for rich text.
436
213
  style: Style dict (or list of dicts).
437
214
  accessibility_label: Spoken description for screen readers.
438
215
  accessibility_hint: Spoken extra detail (iOS only).
439
216
  accessibility_role: Semantic role for assistive tech.
440
217
  accessible: Override whether the element is exposed to AT.
441
- ref: Optional ``use_ref()`` dict.
218
+ accessibility_state: Current widget state for assistive tech,
219
+ e.g. ``{"disabled": True, "selected": False}``. Recognized
220
+ keys: ``disabled``, ``selected``, ``checked``, ``busy``,
221
+ ``expanded``.
222
+ accessibility_live_region: How AT announces dynamic changes to
223
+ this view: ``"none"``, ``"polite"``, or ``"assertive"``
224
+ (Android only).
225
+ test_id: Stable identifier for UI tests; exposed as
226
+ ``resource-id`` on Android and ``accessibilityIdentifier``
227
+ on iOS.
228
+ ref: Optional [`Ref`][pythonnative.Ref] from ``use_ref()``.
442
229
  key: Stable identity for keyed reconciliation.
443
230
 
444
231
  Returns:
445
232
  An [`Element`][pythonnative.Element] of type ``"Text"``.
446
233
  """
234
+ rich = any(isinstance(p, Element) for p in parts) or len(parts) > 1
235
+ if rich:
236
+ spans = _flatten_text_spans(parts, {})
237
+ text = "".join(s["text"] for s in spans)
238
+ else:
239
+ spans = None
240
+ text = str(parts[0]) if parts else ""
447
241
  return _make_element(
448
242
  "Text",
449
243
  style=style,
450
244
  ref=ref,
451
245
  key=key,
452
246
  text=text,
247
+ spans=spans,
453
248
  accessibility_label=accessibility_label,
454
249
  accessibility_hint=accessibility_hint,
455
250
  accessibility_role=accessibility_role,
456
251
  accessible=accessible,
252
+ accessibility_state=accessibility_state,
253
+ accessibility_live_region=accessibility_live_region,
254
+ test_id=test_id,
457
255
  )
458
256
 
459
257
 
460
258
  def Button(
461
259
  title: str = "",
462
260
  *,
463
- on_click: Optional[Callable[[], None]] = None,
261
+ on_press: Optional[Callable[[], None]] = None,
464
262
  enabled: bool = True,
465
263
  style: StyleProp = None,
466
264
  accessibility_label: Optional[str] = None,
467
265
  accessibility_hint: Optional[str] = None,
468
266
  accessibility_role: Optional[str] = None,
469
267
  accessible: Optional[bool] = None,
470
- ref: Optional[Dict[str, Any]] = None,
268
+ accessibility_state: Optional[AccessibilityState] = None,
269
+ accessibility_live_region: Optional[Literal["none", "polite", "assertive"]] = None,
270
+ test_id: Optional[str] = None,
271
+ ref: Optional[Ref] = None,
471
272
  key: Optional[str] = None,
472
273
  ) -> Element:
473
274
  """Display a tappable button.
@@ -480,7 +281,7 @@ def Button(
480
281
 
481
282
  Args:
482
283
  title: Button label.
483
- on_click: Callback invoked when the user taps the button.
284
+ on_press: Callback invoked when the user taps the button.
484
285
  enabled: When ``False``, the button is disabled and cannot be
485
286
  tapped.
486
287
  style: Style dict (or list of dicts).
@@ -488,7 +289,17 @@ def Button(
488
289
  accessibility_hint: Spoken extra detail (iOS only).
489
290
  accessibility_role: Override the default ``"button"`` role.
490
291
  accessible: Override whether the element is exposed to AT.
491
- ref: Optional ``use_ref()`` dict.
292
+ accessibility_state: Current widget state for assistive tech,
293
+ e.g. ``{"disabled": True, "selected": False}``. Recognized
294
+ keys: ``disabled``, ``selected``, ``checked``, ``busy``,
295
+ ``expanded``.
296
+ accessibility_live_region: How AT announces dynamic changes to
297
+ this view: ``"none"``, ``"polite"``, or ``"assertive"``
298
+ (Android only).
299
+ test_id: Stable identifier for UI tests; exposed as
300
+ ``resource-id`` on Android and ``accessibilityIdentifier``
301
+ on iOS.
302
+ ref: Optional [`Ref`][pythonnative.Ref] from ``use_ref()``.
492
303
  key: Stable identity for keyed reconciliation.
493
304
 
494
305
  Returns:
@@ -500,12 +311,15 @@ def Button(
500
311
  ref=ref,
501
312
  key=key,
502
313
  title=title,
503
- on_click=on_click,
314
+ on_press=on_press,
504
315
  enabled=enabled,
505
316
  accessibility_label=accessibility_label,
506
317
  accessibility_hint=accessibility_hint,
507
318
  accessibility_role=accessibility_role,
508
319
  accessible=accessible,
320
+ accessibility_state=accessibility_state,
321
+ accessibility_live_region=accessibility_live_region,
322
+ test_id=test_id,
509
323
  _defaults={"accessibility_role": "button"},
510
324
  )
511
325
 
@@ -535,7 +349,10 @@ def TextInput(
535
349
  accessibility_label: Optional[str] = None,
536
350
  accessibility_hint: Optional[str] = None,
537
351
  accessible: Optional[bool] = None,
538
- ref: Optional[Dict[str, Any]] = None,
352
+ accessibility_state: Optional[AccessibilityState] = None,
353
+ accessibility_live_region: Optional[Literal["none", "polite", "assertive"]] = None,
354
+ test_id: Optional[str] = None,
355
+ ref: Optional[Ref] = None,
539
356
  key: Optional[str] = None,
540
357
  ) -> Element:
541
358
  """Display a text-entry field (single-line by default, or ``multiline``).
@@ -575,7 +392,17 @@ def TextInput(
575
392
  accessibility_label: Spoken description for screen readers.
576
393
  accessibility_hint: Spoken extra detail (iOS only).
577
394
  accessible: Override whether the element is exposed to AT.
578
- ref: Optional ``use_ref()`` dict.
395
+ accessibility_state: Current widget state for assistive tech,
396
+ e.g. ``{"disabled": True, "selected": False}``. Recognized
397
+ keys: ``disabled``, ``selected``, ``checked``, ``busy``,
398
+ ``expanded``.
399
+ accessibility_live_region: How AT announces dynamic changes to
400
+ this view: ``"none"``, ``"polite"``, or ``"assertive"``
401
+ (Android only).
402
+ test_id: Stable identifier for UI tests; exposed as
403
+ ``resource-id`` on Android and ``accessibilityIdentifier``
404
+ on iOS.
405
+ ref: Optional [`Ref`][pythonnative.Ref] from ``use_ref()``.
579
406
  key: Stable identity for keyed reconciliation.
580
407
 
581
408
  Returns:
@@ -608,6 +435,9 @@ def TextInput(
608
435
  accessibility_label=accessibility_label,
609
436
  accessibility_hint=accessibility_hint,
610
437
  accessible=accessible,
438
+ accessibility_state=accessibility_state,
439
+ accessibility_live_region=accessibility_live_region,
440
+ test_id=test_id,
611
441
  )
612
442
 
613
443
 
@@ -616,11 +446,17 @@ def Image(
616
446
  *,
617
447
  scale_type: Optional[ScaleType] = None,
618
448
  tint_color: Optional[Color] = None,
449
+ placeholder_color: Optional[Color] = None,
450
+ on_load: Optional[Callable[[], None]] = None,
451
+ on_error: Optional[Callable[[str], None]] = None,
619
452
  style: StyleProp = None,
620
453
  accessibility_label: Optional[str] = None,
621
454
  accessibility_role: Optional[str] = None,
622
455
  accessible: Optional[bool] = None,
623
- ref: Optional[Dict[str, Any]] = None,
456
+ accessibility_state: Optional[AccessibilityState] = None,
457
+ accessibility_live_region: Optional[Literal["none", "polite", "assertive"]] = None,
458
+ test_id: Optional[str] = None,
459
+ ref: Optional[Ref] = None,
624
460
  key: Optional[str] = None,
625
461
  ) -> Element:
626
462
  """Display an image from a resource path or URL.
@@ -628,10 +464,11 @@ def Image(
628
464
  Style properties: ``background_color``, ``border_*``, ``opacity``,
629
465
  ``transform``, plus the common layout props.
630
466
 
631
- Network images (``http://`` / ``https://``) are loaded
632
- asynchronously off the main thread on both iOS (via
633
- ``NSURLSession``) and Android (via a worker thread plus
634
- ``BitmapFactory``).
467
+ Network images (``http://`` / ``https://``) go through the shared
468
+ image pipeline (`pythonnative.images`): downloads happen on a
469
+ background thread, bytes are cached in memory and on disk keyed by
470
+ URL, concurrent requests for the same URL share one download, and
471
+ large bitmaps are downsampled to the view size when decoded.
635
472
 
636
473
  Args:
637
474
  source: Image resource name or URL.
@@ -639,11 +476,27 @@ def Image(
639
476
  ``"center"``.
640
477
  tint_color: Color overlay applied to template images
641
478
  (monochrome icons).
479
+ placeholder_color: Background color shown while a remote image
480
+ is loading (and left in place if it fails).
481
+ on_load: Callback invoked once the image has been decoded and
482
+ displayed.
483
+ on_error: Callback invoked with an error message when a remote
484
+ image fails to download or decode.
642
485
  style: Style dict (or list of dicts).
643
486
  accessibility_label: Spoken description for screen readers.
644
487
  accessibility_role: Override the default ``"image"`` role.
645
488
  accessible: Override whether the element is exposed to AT.
646
- ref: Optional ``use_ref()`` dict.
489
+ accessibility_state: Current widget state for assistive tech,
490
+ e.g. ``{"disabled": True, "selected": False}``. Recognized
491
+ keys: ``disabled``, ``selected``, ``checked``, ``busy``,
492
+ ``expanded``.
493
+ accessibility_live_region: How AT announces dynamic changes to
494
+ this view: ``"none"``, ``"polite"``, or ``"assertive"``
495
+ (Android only).
496
+ test_id: Stable identifier for UI tests; exposed as
497
+ ``resource-id`` on Android and ``accessibilityIdentifier``
498
+ on iOS.
499
+ ref: Optional [`Ref`][pythonnative.Ref] from ``use_ref()``.
647
500
  key: Stable identity for keyed reconciliation.
648
501
 
649
502
  Returns:
@@ -657,9 +510,15 @@ def Image(
657
510
  source=source or None,
658
511
  scale_type=scale_type,
659
512
  tint_color=tint_color,
513
+ placeholder_color=placeholder_color,
514
+ on_load=on_load,
515
+ on_error=on_error,
660
516
  accessibility_label=accessibility_label,
661
517
  accessibility_role=accessibility_role,
662
518
  accessible=accessible,
519
+ accessibility_state=accessibility_state,
520
+ accessibility_live_region=accessibility_live_region,
521
+ test_id=test_id,
663
522
  _defaults={"accessibility_role": "image"},
664
523
  )
665
524
 
@@ -900,7 +759,10 @@ def View(
900
759
  accessibility_hint: Optional[str] = None,
901
760
  accessibility_role: Optional[str] = None,
902
761
  accessible: Optional[bool] = None,
903
- ref: Optional[Dict[str, Any]] = None,
762
+ accessibility_state: Optional[AccessibilityState] = None,
763
+ accessibility_live_region: Optional[Literal["none", "polite", "assertive"]] = None,
764
+ test_id: Optional[str] = None,
765
+ ref: Optional[Ref] = None,
904
766
  key: Optional[str] = None,
905
767
  ) -> Element:
906
768
  """Universal flex container (like React Native's ``View``).
@@ -939,7 +801,17 @@ def View(
939
801
  accessibility_hint: Spoken extra detail (iOS only).
940
802
  accessibility_role: Semantic role for assistive tech.
941
803
  accessible: Override whether the element is exposed to AT.
942
- ref: Optional ``use_ref()`` dict.
804
+ accessibility_state: Current widget state for assistive tech,
805
+ e.g. ``{"disabled": True, "selected": False}``. Recognized
806
+ keys: ``disabled``, ``selected``, ``checked``, ``busy``,
807
+ ``expanded``.
808
+ accessibility_live_region: How AT announces dynamic changes to
809
+ this view: ``"none"``, ``"polite"``, or ``"assertive"``
810
+ (Android only).
811
+ test_id: Stable identifier for UI tests; exposed as
812
+ ``resource-id`` on Android and ``accessibilityIdentifier``
813
+ on iOS.
814
+ ref: Optional [`Ref`][pythonnative.Ref] from ``use_ref()``.
943
815
  key: Stable identity for keyed reconciliation.
944
816
 
945
817
  Returns:
@@ -956,6 +828,9 @@ def View(
956
828
  accessibility_hint=accessibility_hint,
957
829
  accessibility_role=accessibility_role,
958
830
  accessible=accessible,
831
+ accessibility_state=accessibility_state,
832
+ accessibility_live_region=accessibility_live_region,
833
+ test_id=test_id,
959
834
  _defaults={"flex_direction": "column"},
960
835
  )
961
836
 
@@ -963,7 +838,7 @@ def View(
963
838
  def Column(
964
839
  *children: Element,
965
840
  style: StyleProp = None,
966
- ref: Optional[Dict[str, Any]] = None,
841
+ ref: Optional[Ref] = None,
967
842
  key: Optional[str] = None,
968
843
  ) -> Element:
969
844
  """Arrange children vertically.
@@ -975,7 +850,7 @@ def Column(
975
850
  Args:
976
851
  *children: Child elements stacked top to bottom.
977
852
  style: Style dict (or list of dicts).
978
- ref: Optional ``use_ref()`` dict for native-view access.
853
+ ref: Optional [`Ref`][pythonnative.Ref] for native-view access.
979
854
  key: Stable identity for keyed reconciliation.
980
855
 
981
856
  Returns:
@@ -994,7 +869,7 @@ def Column(
994
869
  def Row(
995
870
  *children: Element,
996
871
  style: StyleProp = None,
997
- ref: Optional[Dict[str, Any]] = None,
872
+ ref: Optional[Ref] = None,
998
873
  key: Optional[str] = None,
999
874
  ) -> Element:
1000
875
  """Arrange children horizontally.
@@ -1006,7 +881,7 @@ def Row(
1006
881
  Args:
1007
882
  *children: Child elements arranged left to right.
1008
883
  style: Style dict (or list of dicts).
1009
- ref: Optional ``use_ref()`` dict for native-view access.
884
+ ref: Optional [`Ref`][pythonnative.Ref] for native-view access.
1010
885
  key: Stable identity for keyed reconciliation.
1011
886
 
1012
887
  Returns:
@@ -1033,7 +908,7 @@ def ScrollView(
1033
908
  content_container_style: StyleProp = None,
1034
909
  keyboard_dismiss_mode: Optional[Literal["none", "on_drag", "interactive"]] = None,
1035
910
  style: StyleProp = None,
1036
- ref: Optional[Dict[str, Any]] = None,
911
+ ref: Optional[Ref] = None,
1037
912
  key: Optional[str] = None,
1038
913
  ) -> Element:
1039
914
  """Wrap children in a scrollable container.
@@ -1064,7 +939,7 @@ def ScrollView(
1064
939
  ``"interactive"``. Controls whether scrolling dismisses
1065
940
  the keyboard.
1066
941
  style: Style dict (or list of dicts).
1067
- ref: Optional ``use_ref()`` dict.
942
+ ref: Optional [`Ref`][pythonnative.Ref] from ``use_ref()``.
1068
943
  key: Stable identity for keyed reconciliation.
1069
944
 
1070
945
  Returns:
@@ -1087,25 +962,76 @@ def ScrollView(
1087
962
  )
1088
963
 
1089
964
 
965
+ _SAFE_AREA_EDGES: Tuple[str, ...] = ("top", "left", "bottom", "right")
966
+
967
+
968
+ def _numeric_edge_padding(style: Dict[str, Any], edge: str) -> float:
969
+ """Return the numeric padding already declared for ``edge`` in ``style``.
970
+
971
+ Only numeric values participate; percentage strings and dict
972
+ shorthands are left alone (the inset simply overrides them for
973
+ that edge). Resolution order matches the layout engine:
974
+ ``padding_{edge}`` beats the axis shorthand, which beats
975
+ ``padding``.
976
+ """
977
+ axis_key = "padding_vertical" if edge in ("top", "bottom") else "padding_horizontal"
978
+ for key in (f"padding_{edge}", axis_key, "padding"):
979
+ value = style.get(key)
980
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
981
+ return float(value)
982
+ return 0.0
983
+
984
+
985
+ @component
986
+ def _SafeAreaContainer(**p: Any) -> Element:
987
+ """Hook-driven body of `SafeAreaView`.
988
+
989
+ Reads the live insets via
990
+ [`use_safe_area_insets`][pythonnative.use_safe_area_insets] (so the
991
+ subtree re-renders when the platform publishes new values, e.g. on
992
+ rotation) and adds each selected edge's inset on top of any padding
993
+ the user declared for that edge.
994
+ """
995
+ insets = use_safe_area_insets()
996
+ style: Dict[str, Any] = dict(p.get("style") or {})
997
+ edges = p.get("edges") or _SAFE_AREA_EDGES
998
+ for edge in _SAFE_AREA_EDGES:
999
+ if edge not in edges:
1000
+ continue
1001
+ inset = float(insets.get(edge, 0.0) or 0.0)
1002
+ if inset > 0:
1003
+ style[f"padding_{edge}"] = _numeric_edge_padding(style, edge) + inset
1004
+ children = p.get("children") or []
1005
+ return Element("SafeAreaView", style, list(children))
1006
+
1007
+
1090
1008
  def SafeAreaView(
1091
1009
  *children: Element,
1010
+ edges: Optional[Tuple[Literal["top", "left", "bottom", "right"], ...]] = None,
1092
1011
  style: StyleProp = None,
1093
1012
  key: Optional[str] = None,
1094
1013
  ) -> Element:
1095
1014
  """Container that respects safe-area insets (notch, status bar, home indicator).
1096
1015
 
1016
+ Applies the platform-reported insets as extra padding on the
1017
+ selected edges and re-renders automatically when the insets change
1018
+ (rotation, split view). User padding on an inset edge is added to
1019
+ the inset, matching ``react-native-safe-area-context``.
1020
+
1097
1021
  Args:
1098
1022
  *children: Child elements that should avoid system UI overlays.
1023
+ edges: Which edges to pad; defaults to all four.
1099
1024
  style: Style dict (or list of dicts).
1100
1025
  key: Stable identity for keyed reconciliation.
1101
1026
 
1102
1027
  Returns:
1103
- An [`Element`][pythonnative.Element] of type ``"SafeAreaView"``.
1028
+ An [`Element`][pythonnative.Element] that renders a
1029
+ ``"SafeAreaView"`` container.
1104
1030
  """
1105
- return _make_element(
1106
- "SafeAreaView",
1107
- *children,
1108
- style=style,
1031
+ return _SafeAreaContainer(
1032
+ children=list(children),
1033
+ edges=tuple(edges) if edges else None,
1034
+ style=resolve_style(style),
1109
1035
  key=key,
1110
1036
  )
1111
1037
 
@@ -1175,6 +1101,41 @@ def Modal(
1175
1101
  )
1176
1102
 
1177
1103
 
1104
+ @component
1105
+ def _StatefulPressable(**p: Any) -> Element:
1106
+ """Hook-driven Pressable used when ``style`` is a callable.
1107
+
1108
+ Tracks the pressed state with ``use_state`` and re-invokes the
1109
+ user's style function with ``{"pressed": bool}`` on every press
1110
+ transition, mirroring React Native's function-style ``style`` prop.
1111
+ """
1112
+ pressed, set_pressed = use_state(False)
1113
+ style_fn = p["style_fn"]
1114
+ user_press_in = p.get("on_press_in")
1115
+ user_press_out = p.get("on_press_out")
1116
+
1117
+ def _press_in() -> None:
1118
+ set_pressed(True)
1119
+ if user_press_in is not None:
1120
+ user_press_in()
1121
+
1122
+ def _press_out() -> None:
1123
+ set_pressed(False)
1124
+ if user_press_out is not None:
1125
+ user_press_out()
1126
+
1127
+ forwarded = {k: v for k, v in p.items() if k not in ("children", "style_fn", "on_press_in", "on_press_out")}
1128
+ return _make_element(
1129
+ "Pressable",
1130
+ *(p.get("children") or []),
1131
+ style=resolve_style(style_fn({"pressed": pressed})),
1132
+ on_press_in=_press_in,
1133
+ on_press_out=_press_out,
1134
+ _defaults={"accessibility_role": "button"},
1135
+ **forwarded,
1136
+ )
1137
+
1138
+
1178
1139
  def Pressable(
1179
1140
  *children: Element,
1180
1141
  on_press: Optional[Callable[[], None]] = None,
@@ -1183,12 +1144,15 @@ def Pressable(
1183
1144
  on_press_out: Optional[Callable[[], None]] = None,
1184
1145
  pressed_opacity: float = 0.6,
1185
1146
  gestures: Optional[List[Any]] = None,
1186
- style: StyleProp = None,
1147
+ style: Union[StyleProp, Callable[[Dict[str, bool]], StyleProp]] = None,
1187
1148
  accessibility_label: Optional[str] = None,
1188
1149
  accessibility_hint: Optional[str] = None,
1189
1150
  accessibility_role: Optional[str] = None,
1190
1151
  accessible: Optional[bool] = None,
1191
- ref: Optional[Dict[str, Any]] = None,
1152
+ accessibility_state: Optional[AccessibilityState] = None,
1153
+ accessibility_live_region: Optional[Literal["none", "polite", "assertive"]] = None,
1154
+ test_id: Optional[str] = None,
1155
+ ref: Optional[Ref] = None,
1192
1156
  key: Optional[str] = None,
1193
1157
  ) -> Element:
1194
1158
  """Wrap children with tap / long-press / gesture handlers.
@@ -1210,17 +1174,60 @@ def Pressable(
1210
1174
  gestures: Optional list of gesture descriptors from
1211
1175
  `pythonnative.gestures` recognized natively on this view
1212
1176
  (pan / swipe / pinch / rotation / multi-tap).
1213
- style: Style dict applied to the wrapper.
1177
+ style: Style dict applied to the wrapper, or a callable
1178
+ receiving the interaction state (``{"pressed": bool}``)
1179
+ and returning a style, re-evaluated on every press
1180
+ transition:
1181
+
1182
+ ```python
1183
+ pn.Pressable(
1184
+ pn.Text("Tap"),
1185
+ style=lambda s: pn.style(
1186
+ background_color="#0051A8" if s["pressed"] else "#007AFF",
1187
+ ),
1188
+ )
1189
+ ```
1214
1190
  accessibility_label: Spoken description for screen readers.
1215
1191
  accessibility_hint: Spoken extra detail (iOS only).
1216
1192
  accessibility_role: Override the default ``"button"`` role.
1217
1193
  accessible: Override whether the element is exposed to AT.
1218
- ref: Optional ``use_ref()`` dict.
1194
+ accessibility_state: Current widget state for assistive tech,
1195
+ e.g. ``{"disabled": True, "selected": False}``. Recognized
1196
+ keys: ``disabled``, ``selected``, ``checked``, ``busy``,
1197
+ ``expanded``.
1198
+ accessibility_live_region: How AT announces dynamic changes to
1199
+ this view: ``"none"``, ``"polite"``, or ``"assertive"``
1200
+ (Android only).
1201
+ test_id: Stable identifier for UI tests; exposed as
1202
+ ``resource-id`` on Android and ``accessibilityIdentifier``
1203
+ on iOS.
1204
+ ref: Optional [`Ref`][pythonnative.Ref] from ``use_ref()``.
1219
1205
  key: Stable identity for keyed reconciliation.
1220
1206
 
1221
1207
  Returns:
1222
- An [`Element`][pythonnative.Element] of type ``"Pressable"``.
1208
+ An [`Element`][pythonnative.Element] of type ``"Pressable"``
1209
+ (wrapped in a stateful composite when ``style`` is callable).
1223
1210
  """
1211
+ if callable(style):
1212
+ return _StatefulPressable(
1213
+ children=list(children),
1214
+ style_fn=style,
1215
+ ref=ref,
1216
+ key=key,
1217
+ on_press=on_press,
1218
+ on_long_press=on_long_press,
1219
+ on_press_in=on_press_in,
1220
+ on_press_out=on_press_out,
1221
+ pressed_opacity=pressed_opacity,
1222
+ gestures=gestures,
1223
+ accessibility_label=accessibility_label,
1224
+ accessibility_hint=accessibility_hint,
1225
+ accessibility_role=accessibility_role,
1226
+ accessible=accessible,
1227
+ accessibility_state=accessibility_state,
1228
+ accessibility_live_region=accessibility_live_region,
1229
+ test_id=test_id,
1230
+ )
1224
1231
  return _make_element(
1225
1232
  "Pressable",
1226
1233
  *children,
@@ -1237,6 +1244,9 @@ def Pressable(
1237
1244
  accessibility_hint=accessibility_hint,
1238
1245
  accessibility_role=accessibility_role,
1239
1246
  accessible=accessible,
1247
+ accessibility_state=accessibility_state,
1248
+ accessibility_live_region=accessibility_live_region,
1249
+ test_id=test_id,
1240
1250
  _defaults={"accessibility_role": "button"},
1241
1251
  )
1242
1252
 
@@ -1257,6 +1267,9 @@ def TouchableOpacity(
1257
1267
  accessibility_hint: Optional[str] = None,
1258
1268
  accessibility_role: Optional[str] = None,
1259
1269
  accessible: Optional[bool] = None,
1270
+ accessibility_state: Optional[AccessibilityState] = None,
1271
+ accessibility_live_region: Optional[Literal["none", "polite", "assertive"]] = None,
1272
+ test_id: Optional[str] = None,
1260
1273
  key: Optional[str] = None,
1261
1274
  ) -> Element:
1262
1275
  """Wrap children so they fade to ``active_opacity`` while pressed.
@@ -1278,6 +1291,16 @@ def TouchableOpacity(
1278
1291
  accessibility_hint: Spoken extra detail (iOS only).
1279
1292
  accessibility_role: Override the default ``"button"`` role.
1280
1293
  accessible: Override whether the element is exposed to AT.
1294
+ accessibility_state: Current widget state for assistive tech,
1295
+ e.g. ``{"disabled": True, "selected": False}``. Recognized
1296
+ keys: ``disabled``, ``selected``, ``checked``, ``busy``,
1297
+ ``expanded``.
1298
+ accessibility_live_region: How AT announces dynamic changes to
1299
+ this view: ``"none"``, ``"polite"``, or ``"assertive"``
1300
+ (Android only).
1301
+ test_id: Stable identifier for UI tests; exposed as
1302
+ ``resource-id`` on Android and ``accessibilityIdentifier``
1303
+ on iOS.
1281
1304
  key: Stable identity for keyed reconciliation.
1282
1305
 
1283
1306
  Returns:
@@ -1300,6 +1323,9 @@ def TouchableOpacity(
1300
1323
  accessibility_hint=accessibility_hint,
1301
1324
  accessibility_role=accessibility_role,
1302
1325
  accessible=accessible,
1326
+ accessibility_state=accessibility_state,
1327
+ accessibility_live_region=accessibility_live_region,
1328
+ test_id=test_id,
1303
1329
  key=key,
1304
1330
  )
1305
1331
 
@@ -1311,6 +1337,9 @@ def ImageBackground(
1311
1337
  style: StyleProp = None,
1312
1338
  accessibility_label: Optional[str] = None,
1313
1339
  accessible: Optional[bool] = None,
1340
+ accessibility_state: Optional[AccessibilityState] = None,
1341
+ accessibility_live_region: Optional[Literal["none", "polite", "assertive"]] = None,
1342
+ test_id: Optional[str] = None,
1314
1343
  key: Optional[str] = None,
1315
1344
  ) -> Element:
1316
1345
  """Render ``children`` layered on top of a background image.
@@ -1329,6 +1358,16 @@ def ImageBackground(
1329
1358
  style: Style dict for the container (size, padding, alignment).
1330
1359
  accessibility_label: Spoken description of the background image.
1331
1360
  accessible: Override whether the image is exposed to AT.
1361
+ accessibility_state: Current widget state for assistive tech,
1362
+ e.g. ``{"disabled": True, "selected": False}``. Recognized
1363
+ keys: ``disabled``, ``selected``, ``checked``, ``busy``,
1364
+ ``expanded``.
1365
+ accessibility_live_region: How AT announces dynamic changes to
1366
+ this view: ``"none"``, ``"polite"``, or ``"assertive"``
1367
+ (Android only).
1368
+ test_id: Stable identifier for UI tests; exposed as
1369
+ ``resource-id`` on Android and ``accessibilityIdentifier``
1370
+ on iOS.
1332
1371
  key: Stable identity for keyed reconciliation.
1333
1372
 
1334
1373
  Returns:
@@ -1342,6 +1381,9 @@ def ImageBackground(
1342
1381
  style=fill,
1343
1382
  accessibility_label=accessibility_label,
1344
1383
  accessible=accessible,
1384
+ accessibility_state=accessibility_state,
1385
+ accessibility_live_region=accessibility_live_region,
1386
+ test_id=test_id,
1345
1387
  )
1346
1388
  content = View(*children, style={"flex": 1})
1347
1389
  return View(
@@ -1363,6 +1405,9 @@ def Checkbox(
1363
1405
  accessibility_label: Optional[str] = None,
1364
1406
  accessibility_hint: Optional[str] = None,
1365
1407
  accessible: Optional[bool] = None,
1408
+ accessibility_state: Optional[AccessibilityState] = None,
1409
+ accessibility_live_region: Optional[Literal["none", "polite", "assertive"]] = None,
1410
+ test_id: Optional[str] = None,
1366
1411
  key: Optional[str] = None,
1367
1412
  ) -> Element:
1368
1413
  """A boolean checkbox with an optional inline label.
@@ -1381,6 +1426,16 @@ def Checkbox(
1381
1426
  accessibility_label: Spoken description for screen readers.
1382
1427
  accessibility_hint: Spoken extra detail (iOS only).
1383
1428
  accessible: Override whether the element is exposed to AT.
1429
+ accessibility_state: Current widget state for assistive tech,
1430
+ e.g. ``{"disabled": True, "selected": False}``. Recognized
1431
+ keys: ``disabled``, ``selected``, ``checked``, ``busy``,
1432
+ ``expanded``.
1433
+ accessibility_live_region: How AT announces dynamic changes to
1434
+ this view: ``"none"``, ``"polite"``, or ``"assertive"``
1435
+ (Android only).
1436
+ test_id: Stable identifier for UI tests; exposed as
1437
+ ``resource-id`` on Android and ``accessibilityIdentifier``
1438
+ on iOS.
1384
1439
  key: Stable identity for keyed reconciliation.
1385
1440
 
1386
1441
  Returns:
@@ -1398,6 +1453,9 @@ def Checkbox(
1398
1453
  accessibility_label=accessibility_label,
1399
1454
  accessibility_hint=accessibility_hint,
1400
1455
  accessible=accessible,
1456
+ accessibility_state=accessibility_state,
1457
+ accessibility_live_region=accessibility_live_region,
1458
+ test_id=test_id,
1401
1459
  _defaults={"accessibility_role": "checkbox"},
1402
1460
  )
1403
1461
 
@@ -1412,6 +1470,9 @@ def SegmentedControl(
1412
1470
  style: StyleProp = None,
1413
1471
  accessibility_label: Optional[str] = None,
1414
1472
  accessible: Optional[bool] = None,
1473
+ accessibility_state: Optional[AccessibilityState] = None,
1474
+ accessibility_live_region: Optional[Literal["none", "polite", "assertive"]] = None,
1475
+ test_id: Optional[str] = None,
1415
1476
  key: Optional[str] = None,
1416
1477
  ) -> Element:
1417
1478
  """A horizontal multi-choice control (one selected segment at a time).
@@ -1428,6 +1489,16 @@ def SegmentedControl(
1428
1489
  style: Style dict (or list of dicts).
1429
1490
  accessibility_label: Spoken description for screen readers.
1430
1491
  accessible: Override whether the element is exposed to AT.
1492
+ accessibility_state: Current widget state for assistive tech,
1493
+ e.g. ``{"disabled": True, "selected": False}``. Recognized
1494
+ keys: ``disabled``, ``selected``, ``checked``, ``busy``,
1495
+ ``expanded``.
1496
+ accessibility_live_region: How AT announces dynamic changes to
1497
+ this view: ``"none"``, ``"polite"``, or ``"assertive"``
1498
+ (Android only).
1499
+ test_id: Stable identifier for UI tests; exposed as
1500
+ ``resource-id`` on Android and ``accessibilityIdentifier``
1501
+ on iOS.
1431
1502
  key: Stable identity for keyed reconciliation.
1432
1503
 
1433
1504
  Returns:
@@ -1445,6 +1516,9 @@ def SegmentedControl(
1445
1516
  tint_color=tint_color,
1446
1517
  accessibility_label=accessibility_label,
1447
1518
  accessible=accessible,
1519
+ accessibility_state=accessibility_state,
1520
+ accessibility_live_region=accessibility_live_region,
1521
+ test_id=test_id,
1448
1522
  )
1449
1523
 
1450
1524
 
@@ -1459,6 +1533,9 @@ def DatePicker(
1459
1533
  style: StyleProp = None,
1460
1534
  accessibility_label: Optional[str] = None,
1461
1535
  accessible: Optional[bool] = None,
1536
+ accessibility_state: Optional[AccessibilityState] = None,
1537
+ accessibility_live_region: Optional[Literal["none", "polite", "assertive"]] = None,
1538
+ test_id: Optional[str] = None,
1462
1539
  key: Optional[str] = None,
1463
1540
  ) -> Element:
1464
1541
  """A native date / time picker.
@@ -1466,7 +1543,10 @@ def DatePicker(
1466
1543
  Backed by ``UIDatePicker`` on iOS and a trigger button that opens
1467
1544
  the platform ``DatePickerDialog`` / ``TimePickerDialog`` on
1468
1545
  Android. ``value`` and the value reported to ``on_change`` are
1469
- ISO-8601 strings (see [`DatePickerProps`][pythonnative.DatePickerProps]).
1546
+ ISO-8601 strings (``"2026-05-31"`` for ``mode="date"``, ``"14:30"``
1547
+ for ``mode="time"``, ``"2026-05-31T14:30"`` for
1548
+ ``mode="datetime"``), so values stay JSON-serializable and
1549
+ platform-agnostic.
1470
1550
 
1471
1551
  Args:
1472
1552
  value: Currently selected value as an ISO-8601 string.
@@ -1478,6 +1558,16 @@ def DatePicker(
1478
1558
  style: Style dict (or list of dicts).
1479
1559
  accessibility_label: Spoken description for screen readers.
1480
1560
  accessible: Override whether the element is exposed to AT.
1561
+ accessibility_state: Current widget state for assistive tech,
1562
+ e.g. ``{"disabled": True, "selected": False}``. Recognized
1563
+ keys: ``disabled``, ``selected``, ``checked``, ``busy``,
1564
+ ``expanded``.
1565
+ accessibility_live_region: How AT announces dynamic changes to
1566
+ this view: ``"none"``, ``"polite"``, or ``"assertive"``
1567
+ (Android only).
1568
+ test_id: Stable identifier for UI tests; exposed as
1569
+ ``resource-id`` on Android and ``accessibilityIdentifier``
1570
+ on iOS.
1481
1571
  key: Stable identity for keyed reconciliation.
1482
1572
 
1483
1573
  Returns:
@@ -1495,6 +1585,9 @@ def DatePicker(
1495
1585
  enabled=False if enabled is False else None,
1496
1586
  accessibility_label=accessibility_label,
1497
1587
  accessible=accessible,
1588
+ accessibility_state=accessibility_state,
1589
+ accessibility_live_region=accessibility_live_region,
1590
+ test_id=test_id,
1498
1591
  _defaults={"accessibility_role": "button"},
1499
1592
  )
1500
1593
 
@@ -1507,14 +1600,12 @@ def DatePicker(
1507
1600
  def Fragment(*children: Optional[Element], key: Optional[str] = None) -> Element:
1508
1601
  """Group children without adding a wrapping native view.
1509
1602
 
1510
- Like React's ``<></>``: returns multiple elements from a component
1511
- without introducing an extra container. The reconciler flattens
1512
- Fragment elements at the children-list level, so each child appears
1513
- as a direct sibling of the Fragment's parent in the native tree.
1514
-
1515
- Useful inside [`Provider`][pythonnative.Provider] /
1516
- [`memo`][pythonnative.memo] / conditional logic when grouping
1517
- siblings inside another component's child list:
1603
+ Like React's ``<></>``: groups elements without introducing an
1604
+ extra container. Each child mounts as a direct sibling of the
1605
+ Fragment's position in the parent's child list. Components may
1606
+ also simply return a plain ``list`` of elements; ``Fragment``
1607
+ exists for when the group needs a ``key`` (e.g. rendering a list
1608
+ of pairs) or when a single expression reads better.
1518
1609
 
1519
1610
  ```python
1520
1611
  pn.Column(
@@ -1528,24 +1619,54 @@ def Fragment(*children: Optional[Element], key: Optional[str] = None) -> Element
1528
1619
  ```
1529
1620
 
1530
1621
  Args:
1531
- *children: Child elements to expose at the parent level. ``None``
1532
- children are dropped, which makes conditional rendering with
1533
- ``cond and pn.Text(...)`` ergonomic.
1534
- key: Optional key for the Fragment itself (rarely useful since
1535
- Fragment doesn't appear in the native tree).
1622
+ *children: Child elements to expose at the parent level.
1623
+ ``None`` and ``False`` children are dropped, which makes
1624
+ conditional rendering with ``cond and pn.Text(...)``
1625
+ ergonomic.
1626
+ key: Stable identity for keyed reconciliation. A keyed
1627
+ Fragment moves all of its children as one unit and
1628
+ preserves their state across reorders.
1536
1629
 
1537
1630
  Returns:
1538
1631
  An [`Element`][pythonnative.Element] of type ``"__Fragment__"``.
1632
+ """
1633
+ kept = [c for c in children if c is not None and c is not False]
1634
+ return Element("__Fragment__", {}, kept, key=key)
1539
1635
 
1540
- Note:
1541
- Today, returning a Fragment from a ``@pn.component`` function
1542
- only mounts its first child as the component's root. To return
1543
- multiple top-level elements from a function component, use a
1544
- container such as [`Column`][pythonnative.Column] or
1545
- [`Row`][pythonnative.Row] instead.
1636
+
1637
+ def Portal(*children: Element, key: Optional[str] = None) -> Element:
1638
+ """Render ``children`` into a full-screen overlay above everything else.
1639
+
1640
+ Like React DOM's ``createPortal``: the children stay part of this
1641
+ component's tree for state, context, and events, but their native
1642
+ views mount in a transparent overlay attached to the window (above
1643
+ the screen's content) instead of inside the surrounding parent.
1644
+ Use it for toasts, dropdowns, tooltips, and lightweight custom
1645
+ overlays that must escape ``overflow: "hidden"`` ancestors. For a
1646
+ system-styled dialog with its own presentation and dismissal
1647
+ gestures, use [`Modal`][pythonnative.Modal] instead.
1648
+
1649
+ The overlay itself does not intercept touches; only the children
1650
+ themselves are hit-testable. Children are laid out against the
1651
+ full viewport, so position them with absolute insets:
1652
+
1653
+ ```python
1654
+ pn.Portal(
1655
+ pn.View(
1656
+ pn.Text("Saved!"),
1657
+ style=pn.style(position="absolute", bottom=40, left=40, right=40),
1658
+ ),
1659
+ )
1660
+ ```
1661
+
1662
+ Args:
1663
+ *children: Overlay content.
1664
+ key: Stable identity for keyed reconciliation.
1665
+
1666
+ Returns:
1667
+ An [`Element`][pythonnative.Element] of type ``"Portal"``.
1546
1668
  """
1547
- filtered = [c for c in children if c is not None]
1548
- return Element("__Fragment__", {}, filtered, key=key)
1669
+ return Element("Portal", {}, list(children), key=key)
1549
1670
 
1550
1671
 
1551
1672
  # ======================================================================
@@ -1556,23 +1677,33 @@ def Fragment(*children: Optional[Element], key: Optional[str] = None) -> Element
1556
1677
  def ErrorBoundary(
1557
1678
  *children: Element,
1558
1679
  fallback: Optional[Any] = None,
1680
+ on_error: Optional[Callable[[BaseException], None]] = None,
1559
1681
  key: Optional[str] = None,
1560
1682
  ) -> Element:
1561
1683
  """Catch render errors in the wrapped subtree and display ``fallback`` instead.
1562
1684
 
1563
- ``fallback`` may be an [`Element`][pythonnative.Element] or a
1564
- callable that receives the exception and returns an ``Element``.
1565
- Useful for isolating risky subtrees so a single failure doesn't
1566
- crash the page.
1685
+ When any descendant raises during render (initial mount, a parent
1686
+ re-render, or a local state-driven update), the failed subtree is
1687
+ torn down and ``fallback`` is mounted in its place. Without a
1688
+ boundary the error propagates to the screen host (which shows the
1689
+ dev error overlay in dev mode).
1567
1690
 
1568
- When multiple children are passed they're grouped under a
1569
- [`Fragment`][pythonnative.Fragment] so the boundary still wraps a
1570
- single logical subtree.
1691
+ ``fallback`` may be:
1692
+
1693
+ - An [`Element`][pythonnative.Element], shown as-is.
1694
+ - ``fallback(error) -> Element``.
1695
+ - ``fallback(error, reset) -> Element``, where ``reset`` is a
1696
+ zero-arg callable that clears the error and remounts the
1697
+ original children (fresh state), for retry buttons.
1571
1698
 
1572
1699
  Args:
1573
1700
  *children: Subtree to wrap.
1574
- fallback: Element rendered when the subtree raises during
1575
- render, or a callable ``fallback(err) -> Element``.
1701
+ fallback: Fallback content (see above). Required for the
1702
+ boundary to actually catch; without it errors propagate
1703
+ to the next boundary up.
1704
+ on_error: Callback invoked with the exception when the
1705
+ boundary catches, before the fallback mounts. Use it for
1706
+ error reporting.
1576
1707
  key: Stable identity for keyed reconciliation.
1577
1708
 
1578
1709
  Returns:
@@ -1585,32 +1716,43 @@ def ErrorBoundary(
1585
1716
 
1586
1717
  pn.ErrorBoundary(
1587
1718
  MyRiskyComponent(),
1588
- fallback=lambda err: pn.Text(f"Error: {err}"),
1719
+ fallback=lambda err, reset: pn.Column(
1720
+ pn.Text(f"Error: {err}"),
1721
+ pn.Button("Retry", on_press=reset),
1722
+ ),
1723
+ on_error=lambda err: log.exception(err),
1589
1724
  )
1590
1725
  ```
1591
1726
  """
1592
1727
  props: Dict[str, Any] = {}
1593
1728
  if fallback is not None:
1594
1729
  props["__fallback__"] = fallback
1595
- if len(children) <= 1:
1596
- kids = list(children)
1597
- else:
1598
- kids = [Fragment(*children)]
1599
- return Element("__ErrorBoundary__", props, kids, key=key)
1730
+ if on_error is not None:
1731
+ props["__on_error__"] = on_error
1732
+ return Element("__ErrorBoundary__", props, list(children), key=key)
1600
1733
 
1601
1734
 
1602
1735
  # ======================================================================
1603
- # Lists (Python-windowed virtualization over ScrollView)
1736
+ # Lists (native virtualization with a Python-windowed fallback)
1604
1737
  # ======================================================================
1605
1738
  #
1606
- # FlatList and SectionList are pure Python components, not native
1607
- # elements. They render a windowed slice of rows into a ScrollView
1608
- # (leading spacer, visible rows, trailing spacer) and shift the window
1609
- # from scroll events (the same architecture as React Native's
1610
- # VirtualizedList). Because every windowed row lives in the *main*
1611
- # layout tree, rows may be any height: estimates only steer the spacer
1612
- # sizes, and each row's measured extent is fed back from the layout
1613
- # pass through its ref to correct the estimates over time.
1739
+ # FlatList and SectionList pick between two engines:
1740
+ #
1741
+ # 1. **Native virtualization** (`_NativeList` -> the ``VirtualList``
1742
+ # element): on Android and iOS, when every row extent is known up
1743
+ # front and no windowed-only feature is requested, the list is
1744
+ # backed by a real ``RecyclerView`` / ``UITableView``. The platform
1745
+ # owns row recycling; each visible row hosts a nested-reconciler
1746
+ # subtree (see ``pythonnative.virtual_rows``).
1747
+ # 2. **Python windowing** (`_VirtualizedList`): a windowed slice of
1748
+ # rows rendered into a ScrollView (leading spacer, visible rows,
1749
+ # trailing spacer), the window shifting from scroll events (the
1750
+ # same architecture as React Native's VirtualizedList). Because
1751
+ # every windowed row lives in the *main* layout tree, rows may be
1752
+ # any height: estimates steer the spacer sizes and measured extents
1753
+ # correct them over time. This is the desktop path and the fallback
1754
+ # for variable-height rows, grids, horizontal lists, ornaments, and
1755
+ # pull-to-refresh.
1614
1756
 
1615
1757
  _DEFAULT_ROW_EXTENT = 44.0
1616
1758
 
@@ -1637,7 +1779,7 @@ class _RowSpec:
1637
1779
 
1638
1780
  def _dispatch_scroll_command(scroll_ref: Any, name: str, args: Dict[str, Any]) -> Any:
1639
1781
  """Send an imperative command to the ScrollView under ``scroll_ref``."""
1640
- tag = scroll_ref.get("_pn_tag") if isinstance(scroll_ref, dict) else None
1782
+ tag = getattr(scroll_ref, "_pn_tag", None)
1641
1783
  if tag is None:
1642
1784
  return None
1643
1785
  from .native_views import get_registry
@@ -1648,6 +1790,55 @@ def _dispatch_scroll_command(scroll_ref: Any, name: str, args: Dict[str, Any]) -
1648
1790
  return None
1649
1791
 
1650
1792
 
1793
+ class ListController:
1794
+ """Imperative scroll handle published on a list's ``ref``.
1795
+
1796
+ [`FlatList`][pythonnative.FlatList] and
1797
+ [`SectionList`][pythonnative.SectionList] install a
1798
+ ``ListController`` on ``ref.current`` (via
1799
+ [`use_imperative_handle`][pythonnative.use_imperative_handle])
1800
+ after mount and clear it back to ``None`` on unmount.
1801
+
1802
+ Example:
1803
+ ```python
1804
+ import pythonnative as pn
1805
+
1806
+ @pn.component
1807
+ def Chat(messages):
1808
+ list_ref = pn.use_ref()
1809
+ pn.use_layout_effect(
1810
+ lambda: list_ref.current and list_ref.current.scroll_to_end(animated=False),
1811
+ [len(messages)],
1812
+ )
1813
+ return pn.FlatList(data=messages, render_item=Bubble, ref=list_ref)
1814
+ ```
1815
+ """
1816
+
1817
+ __slots__ = ("_scroll_to_offset", "_scroll_to_index", "_scroll_to_end")
1818
+
1819
+ def __init__(
1820
+ self,
1821
+ scroll_to_offset: Callable[[float, bool], None],
1822
+ scroll_to_index: Callable[[int, bool], None],
1823
+ scroll_to_end: Callable[[bool], None],
1824
+ ) -> None:
1825
+ self._scroll_to_offset = scroll_to_offset
1826
+ self._scroll_to_index = scroll_to_index
1827
+ self._scroll_to_end = scroll_to_end
1828
+
1829
+ def scroll_to_offset(self, offset: float, animated: bool = True) -> None:
1830
+ """Scroll to an absolute content offset in points."""
1831
+ self._scroll_to_offset(offset, animated)
1832
+
1833
+ def scroll_to_index(self, index: int, animated: bool = True) -> None:
1834
+ """Scroll so the row at ``index`` sits at the top of the viewport."""
1835
+ self._scroll_to_index(index, animated)
1836
+
1837
+ def scroll_to_end(self, animated: bool = True) -> None:
1838
+ """Scroll to the end of the content."""
1839
+ self._scroll_to_end(animated)
1840
+
1841
+
1651
1842
  @component
1652
1843
  def _VirtualizedList(**p: Any) -> Element:
1653
1844
  """Shared windowing engine behind FlatList and SectionList."""
@@ -1659,18 +1850,18 @@ def _VirtualizedList(**p: Any) -> Element:
1659
1850
  initial_extent: float = float(p.get("initial_window_extent") or 800.0)
1660
1851
 
1661
1852
  window, set_window = use_state((0, -1))
1662
- measured = use_ref({}) # row key -> measured extent (points)
1663
- row_refs = use_ref({}) # row key -> ref dict for live rows
1853
+ measured: Ref[Dict[str, float]] = use_ref({}) # row key -> measured extent (points)
1854
+ row_refs: Ref[Dict[str, Ref]] = use_ref({}) # row key -> Ref for live rows
1664
1855
  end_latch = use_ref({"fired_for": -1})
1665
- viewable_ref = use_ref({"keys": ()})
1856
+ viewable_ref: Ref[Dict[str, Tuple[str, ...]]] = use_ref({"keys": ()})
1666
1857
  scroll_pos = use_ref({"offset": 0.0})
1667
- sv_ref = use_ref(None)
1858
+ sv_ref: Ref = use_ref(None)
1668
1859
 
1669
1860
  # ------------------------------------------------------------------
1670
1861
  # Extent model: measured > per-row hint > estimate. ``starts`` are
1671
1862
  # prefix sums; ``starts[n]`` is the total content extent.
1672
1863
  # ------------------------------------------------------------------
1673
- measured_map: Dict[str, float] = measured["current"]
1864
+ measured_map: Dict[str, float] = measured.current
1674
1865
  starts: List[float] = [0.0] * (n + 1)
1675
1866
  acc = 0.0
1676
1867
  for i, spec in enumerate(rows):
@@ -1683,7 +1874,7 @@ def _VirtualizedList(**p: Any) -> Element:
1683
1874
  total_extent = acc
1684
1875
 
1685
1876
  def _viewport_extent() -> float:
1686
- frame = sv_ref.get("_pn_frame") if isinstance(sv_ref, dict) else None
1877
+ frame = sv_ref._pn_frame
1687
1878
  if frame:
1688
1879
  extent = frame[2] if horizontal else frame[3]
1689
1880
  if extent and extent > 0:
@@ -1702,7 +1893,7 @@ def _VirtualizedList(**p: Any) -> Element:
1702
1893
 
1703
1894
  first, last = window
1704
1895
  if last < 0 or first >= n:
1705
- first, last = _window_for(scroll_pos["current"]["offset"], _viewport_extent())
1896
+ first, last = _window_for(scroll_pos.current["offset"], _viewport_extent())
1706
1897
  last = min(last, n - 1)
1707
1898
  first = max(0, min(first, max(0, n - 1)))
1708
1899
 
@@ -1718,8 +1909,8 @@ def _VirtualizedList(**p: Any) -> Element:
1718
1909
  user_on_scroll = p.get("on_scroll")
1719
1910
 
1720
1911
  def _sweep_measured() -> None:
1721
- for row_key, ref in row_refs["current"].items():
1722
- frame = ref.get("_pn_frame") if isinstance(ref, dict) else None
1912
+ for row_key, row_ref in row_refs.current.items():
1913
+ frame = getattr(row_ref, "_pn_frame", None)
1723
1914
  if frame:
1724
1915
  extent = frame[2] if horizontal else frame[3]
1725
1916
  if extent and extent > 0:
@@ -1730,7 +1921,7 @@ def _VirtualizedList(**p: Any) -> Element:
1730
1921
  offset = float(payload.get("x" if horizontal else "y", 0.0) or 0.0)
1731
1922
  else:
1732
1923
  offset = float(payload or 0.0)
1733
- scroll_pos["current"]["offset"] = offset
1924
+ scroll_pos.current["offset"] = offset
1734
1925
  _sweep_measured()
1735
1926
  viewport = _viewport_extent()
1736
1927
 
@@ -1741,18 +1932,18 @@ def _VirtualizedList(**p: Any) -> Element:
1741
1932
  if on_end_reached is not None and total_extent > 0:
1742
1933
  remaining = total_extent - (offset + viewport)
1743
1934
  if remaining <= end_threshold * viewport:
1744
- if end_latch["current"]["fired_for"] != n:
1745
- end_latch["current"]["fired_for"] = n
1935
+ if end_latch.current["fired_for"] != n:
1936
+ end_latch.current["fired_for"] = n
1746
1937
  on_end_reached()
1747
1938
  elif remaining > end_threshold * viewport + viewport:
1748
- end_latch["current"]["fired_for"] = -1
1939
+ end_latch.current["fired_for"] = -1
1749
1940
 
1750
1941
  if on_viewable is not None and n > 0:
1751
1942
  v_first = max(0, bisect.bisect_right(starts, offset, 0, n) - 1)
1752
1943
  v_last = min(n - 1, bisect.bisect_left(starts, offset + viewport, 0, n))
1753
1944
  keys = tuple(rows[i].key for i in range(v_first, v_last + 1))
1754
- if keys != viewable_ref["current"]["keys"]:
1755
- viewable_ref["current"]["keys"] = keys
1945
+ if keys != viewable_ref.current["keys"]:
1946
+ viewable_ref.current["keys"] = keys
1756
1947
  on_viewable(
1757
1948
  [
1758
1949
  {"index": rows[i].index, "key": rows[i].key, "item": rows[i].item}
@@ -1764,33 +1955,26 @@ def _VirtualizedList(**p: Any) -> Element:
1764
1955
  user_on_scroll(payload)
1765
1956
 
1766
1957
  # ------------------------------------------------------------------
1767
- # Imperative controller (scroll_to_index / offset / end) exposed on
1768
- # the user's ref dict. Re-attached every render so the closures see
1769
- # fresh extents; the effect itself must run unconditionally to keep
1770
- # hook order stable.
1958
+ # Imperative controller (scroll_to_index / offset / end) published
1959
+ # on the user's ref. Rebuilt every render (deps=None) so the
1960
+ # closures see fresh extents.
1771
1961
  # ------------------------------------------------------------------
1772
- controller = p.get("controller_ref")
1773
-
1774
- def _attach_controller() -> None:
1775
- if not isinstance(controller, dict):
1776
- return
1777
-
1778
- def scroll_to_offset(offset: float, animated: bool = True) -> None:
1779
- axis = "x" if horizontal else "y"
1780
- _dispatch_scroll_command(sv_ref, "scroll_to_offset", {axis: float(offset), "animated": animated})
1781
-
1782
- def scroll_to_index(index: int, animated: bool = True) -> None:
1783
- idx = max(0, min(int(index), n - 1)) if n else 0
1784
- scroll_to_offset(starts[idx], animated)
1962
+ def _scroll_to_offset(offset: float, animated: bool = True) -> None:
1963
+ axis = "x" if horizontal else "y"
1964
+ _dispatch_scroll_command(sv_ref, "scroll_to_offset", {axis: float(offset), "animated": animated})
1785
1965
 
1786
- def scroll_to_end(animated: bool = True) -> None:
1787
- scroll_to_offset(max(0.0, total_extent - _viewport_extent()), animated)
1966
+ def _scroll_to_index(index: int, animated: bool = True) -> None:
1967
+ idx = max(0, min(int(index), n - 1)) if n else 0
1968
+ _scroll_to_offset(starts[idx], animated)
1788
1969
 
1789
- controller["scroll_to_offset"] = scroll_to_offset
1790
- controller["scroll_to_index"] = scroll_to_index
1791
- controller["scroll_to_end"] = scroll_to_end
1970
+ def _scroll_to_end(animated: bool = True) -> None:
1971
+ _scroll_to_offset(max(0.0, total_extent - _viewport_extent()), animated)
1792
1972
 
1793
- use_effect(_attach_controller, None)
1973
+ use_imperative_handle(
1974
+ p.get("controller_ref"),
1975
+ lambda: ListController(_scroll_to_offset, _scroll_to_index, _scroll_to_end),
1976
+ None,
1977
+ )
1794
1978
 
1795
1979
  # ------------------------------------------------------------------
1796
1980
  # Children: header, leading spacer, windowed rows, trailing spacer,
@@ -1809,17 +1993,17 @@ def _VirtualizedList(**p: Any) -> Element:
1809
1993
  if empty is not None:
1810
1994
  children.append(View(empty, key="__pn_empty__"))
1811
1995
  else:
1812
- live_refs: Dict[str, Any] = {}
1996
+ live_refs: Dict[str, Ref] = {}
1813
1997
  lead = starts[first]
1814
1998
  if lead > 0:
1815
1999
  lead_style: Dict[str, Any] = {spacer_key: lead}
1816
2000
  children.append(View(style=lead_style, key="__pn_lead__"))
1817
2001
  for i in range(first, last + 1):
1818
2002
  spec = rows[i]
1819
- row_ref = row_refs["current"].get(spec.key) or {"current": None}
2003
+ row_ref = row_refs.current.get(spec.key) or Ref()
1820
2004
  live_refs[spec.key] = row_ref
1821
2005
  children.append(View(spec.make(), ref=row_ref, key=spec.key))
1822
- row_refs["current"] = live_refs
2006
+ row_refs.current = live_refs
1823
2007
  trail = total_extent - starts[last + 1]
1824
2008
  if trail > 0:
1825
2009
  trail_style: Dict[str, Any] = {spacer_key: trail}
@@ -1841,6 +2025,126 @@ def _VirtualizedList(**p: Any) -> Element:
1841
2025
  )
1842
2026
 
1843
2027
 
2028
+ def _native_lists_supported() -> bool:
2029
+ """Whether the natively virtualized list path is available.
2030
+
2031
+ Android (RecyclerView) and iOS (UITableView) have native handlers;
2032
+ the desktop preview and off-device tests use the Python-windowed
2033
+ engine. Patchable in tests to exercise the native routing.
2034
+ """
2035
+ from .utils import IS_ANDROID, IS_IOS
2036
+
2037
+ return IS_ANDROID or IS_IOS
2038
+
2039
+
2040
+ @component
2041
+ def _NativeList(**p: Any) -> Element:
2042
+ """Platform-virtualized list: emits a ``VirtualList`` native element.
2043
+
2044
+ The native side (RecyclerView / UITableView) owns row windowing and
2045
+ recycling; each visible row hosts a nested-reconciler subtree (see
2046
+ ``pythonnative.virtual_rows``). This composite adapts the FlatList /
2047
+ SectionList surface onto that element: it forwards ``render_row``,
2048
+ derives ``on_end_reached`` and ``on_viewable_items_changed`` from
2049
+ native scroll reports, and wires the imperative scroll controller.
2050
+
2051
+ Requires every row's extent to be known up front (the native
2052
+ virtualizers need exact heights before rows are rendered); callers
2053
+ fall back to the Python-windowed engine otherwise.
2054
+ """
2055
+ rows: List[_RowSpec] = p.get("rows") or []
2056
+ n = len(rows)
2057
+ heights: List[float] = [float(spec.extent or 0.0) for spec in rows]
2058
+ uniform = len(set(heights)) <= 1
2059
+
2060
+ internal_ref: Ref = use_ref(None)
2061
+ end_latch = use_ref({"fired_for": -1})
2062
+ viewable_ref: Ref[Dict[str, Tuple[str, ...]]] = use_ref({"keys": ()})
2063
+
2064
+ starts: List[float] = [0.0] * (n + 1)
2065
+ acc = 0.0
2066
+ for i, extent in enumerate(heights):
2067
+ starts[i] = acc
2068
+ acc += max(0.0, extent)
2069
+ starts[n] = acc
2070
+ total_extent = acc
2071
+
2072
+ def _render_row(index: int) -> Element:
2073
+ if 0 <= index < n:
2074
+ return rows[index].make()
2075
+ return View()
2076
+
2077
+ on_end_reached = p.get("on_end_reached")
2078
+ end_threshold = float(p.get("on_end_reached_threshold") or 0.5)
2079
+ on_viewable = p.get("on_viewable_items_changed")
2080
+ user_on_scroll = p.get("on_scroll")
2081
+
2082
+ def _handle_scroll(payload: Any) -> None:
2083
+ offset = float(payload.get("y", 0.0) or 0.0) if isinstance(payload, dict) else float(payload or 0.0)
2084
+ viewport = float(payload.get("extent", 0.0) or 0.0) if isinstance(payload, dict) else 0.0
2085
+ if viewport <= 0:
2086
+ viewport = 800.0
2087
+
2088
+ if on_end_reached is not None and total_extent > 0:
2089
+ remaining = total_extent - (offset + viewport)
2090
+ if remaining <= end_threshold * viewport:
2091
+ if end_latch.current["fired_for"] != n:
2092
+ end_latch.current["fired_for"] = n
2093
+ on_end_reached()
2094
+ elif remaining > end_threshold * viewport + viewport:
2095
+ end_latch.current["fired_for"] = -1
2096
+
2097
+ if on_viewable is not None and n > 0:
2098
+ v_first = max(0, bisect.bisect_right(starts, offset, 0, n) - 1)
2099
+ v_last = min(n - 1, bisect.bisect_left(starts, offset + viewport, 0, n))
2100
+ keys = tuple(rows[i].key for i in range(v_first, v_last + 1))
2101
+ if keys != viewable_ref.current["keys"]:
2102
+ viewable_ref.current["keys"] = keys
2103
+ on_viewable(
2104
+ [
2105
+ {"index": rows[i].index, "key": rows[i].key, "item": rows[i].item}
2106
+ for i in range(v_first, v_last + 1)
2107
+ ]
2108
+ )
2109
+
2110
+ if user_on_scroll is not None:
2111
+ user_on_scroll({"x": 0.0, "y": offset})
2112
+
2113
+ def _scroll_to_offset(offset: float, animated: bool = True) -> None:
2114
+ _dispatch_scroll_command(internal_ref, "scroll_to_offset", {"y": float(offset), "animated": animated})
2115
+
2116
+ def _scroll_to_index(index: int, animated: bool = True) -> None:
2117
+ _dispatch_scroll_command(internal_ref, "scroll_to_index", {"index": int(index), "animated": animated})
2118
+
2119
+ def _scroll_to_end(animated: bool = True) -> None:
2120
+ _dispatch_scroll_command(internal_ref, "scroll_to_end", {"animated": animated})
2121
+
2122
+ use_imperative_handle(
2123
+ p.get("controller_ref"),
2124
+ lambda: ListController(_scroll_to_offset, _scroll_to_index, _scroll_to_end),
2125
+ None,
2126
+ )
2127
+
2128
+ props: Dict[str, Any] = dict(p.get("list_style") or {})
2129
+ props["count"] = n
2130
+ if uniform:
2131
+ props["row_height"] = heights[0] if heights else _DEFAULT_ROW_EXTENT
2132
+ else:
2133
+ props["row_heights"] = heights
2134
+ props["render_row"] = _render_row
2135
+ props["ref"] = internal_ref
2136
+ wants_scroll = on_end_reached is not None or on_viewable is not None or user_on_scroll is not None
2137
+ if wants_scroll:
2138
+ props["on_scroll"] = _handle_scroll
2139
+ if p.get("shows_scroll_indicator") is False:
2140
+ props["shows_scroll_indicator"] = False
2141
+ return Element("VirtualList", props, [])
2142
+
2143
+
2144
+ def _all_extents_known(rows: List[_RowSpec]) -> bool:
2145
+ return all(spec.extent is not None for spec in rows)
2146
+
2147
+
1844
2148
  def FlatList(
1845
2149
  *,
1846
2150
  data: Optional[List[Any]] = None,
@@ -1863,7 +2167,7 @@ def FlatList(
1863
2167
  shows_scroll_indicator: bool = True,
1864
2168
  content_container_style: StyleProp = None,
1865
2169
  style: StyleProp = None,
1866
- ref: Optional[Dict[str, Any]] = None,
2170
+ ref: Optional[Ref] = None,
1867
2171
  key: Optional[str] = None,
1868
2172
  ) -> Element:
1869
2173
  """Virtualized scrollable list that renders items from ``data`` lazily.
@@ -1876,10 +2180,12 @@ def FlatList(
1876
2180
  unknown rows start at ``estimated_item_height`` and are corrected
1877
2181
  with their measured extent once they've been on screen.
1878
2182
 
1879
- The ``ref`` dict (from [`use_ref`][pythonnative.use_ref]) is
1880
- populated with an imperative controller:
1881
- ``ref["scroll_to_index"](i)``, ``ref["scroll_to_offset"](pts)``,
1882
- and ``ref["scroll_to_end"]()``.
2183
+ Pass a [`Ref`][pythonnative.Ref] (from
2184
+ [`use_ref`][pythonnative.use_ref]) to receive a
2185
+ [`ListController`][pythonnative.ListController] on ``ref.current``:
2186
+ ``ref.current.scroll_to_index(i)``,
2187
+ ``ref.current.scroll_to_offset(pts)``, and
2188
+ ``ref.current.scroll_to_end()``.
1883
2189
 
1884
2190
  Args:
1885
2191
  data: List of arbitrary item values.
@@ -1914,8 +2220,9 @@ def FlatList(
1914
2220
  content_container_style: Style applied to the inner content
1915
2221
  wrapper.
1916
2222
  style: Style for the outer scroll container.
1917
- ref: Optional ``use_ref()`` dict; receives the scroll
1918
- controller functions.
2223
+ ref: Optional [`Ref`][pythonnative.Ref]; receives a
2224
+ [`ListController`][pythonnative.ListController] on
2225
+ ``ref.current`` after mount.
1919
2226
  key: Stable identity for keyed reconciliation of the list.
1920
2227
 
1921
2228
  Returns:
@@ -1994,6 +2301,32 @@ def FlatList(
1994
2301
 
1995
2302
  estimated = estimated_item_height if estimated_item_height is not None else (item_height or _DEFAULT_ROW_EXTENT)
1996
2303
 
2304
+ # Route to the platform virtualizer (RecyclerView / UITableView)
2305
+ # when it can represent this list exactly: vertical, single-column,
2306
+ # every row extent known up front, and no features that only the
2307
+ # Python-windowed engine implements (ornaments, pull-to-refresh).
2308
+ if (
2309
+ _native_lists_supported()
2310
+ and not horizontal
2311
+ and num_columns == 1
2312
+ and list_header is None
2313
+ and list_footer is None
2314
+ and list_empty is None
2315
+ and refresh_control is None
2316
+ and _all_extents_known(rows)
2317
+ ):
2318
+ return _NativeList(
2319
+ rows=rows,
2320
+ on_end_reached=on_end_reached,
2321
+ on_end_reached_threshold=on_end_reached_threshold,
2322
+ on_viewable_items_changed=on_viewable_items_changed,
2323
+ on_scroll=on_scroll,
2324
+ shows_scroll_indicator=shows_scroll_indicator,
2325
+ list_style=resolve_style(style) or None,
2326
+ controller_ref=ref,
2327
+ key=key,
2328
+ )
2329
+
1997
2330
  return _VirtualizedList(
1998
2331
  rows=rows,
1999
2332
  horizontal=horizontal,
@@ -2033,7 +2366,7 @@ def SectionList(
2033
2366
  on_end_reached_threshold: float = 0.5,
2034
2367
  on_scroll: Optional[Callable[[Dict[str, float]], None]] = None,
2035
2368
  style: StyleProp = None,
2036
- ref: Optional[Dict[str, Any]] = None,
2369
+ ref: Optional[Ref] = None,
2037
2370
  key: Optional[str] = None,
2038
2371
  ) -> Element:
2039
2372
  """Virtualized list with section headers interleaved between row groups.
@@ -2067,8 +2400,9 @@ def SectionList(
2067
2400
  multiples, at which ``on_end_reached`` fires.
2068
2401
  on_scroll: Called with the raw scroll payload.
2069
2402
  style: Style for the outer scroll container.
2070
- ref: Optional ``use_ref()`` dict; receives the scroll
2071
- controller functions.
2403
+ ref: Optional [`Ref`][pythonnative.Ref]; receives a
2404
+ [`ListController`][pythonnative.ListController] on
2405
+ ``ref.current`` after mount.
2072
2406
  key: Stable identity for keyed reconciliation of the list.
2073
2407
 
2074
2408
  Returns:
@@ -2132,6 +2466,26 @@ def SectionList(
2132
2466
 
2133
2467
  estimated = estimated_item_height if estimated_item_height is not None else (item_height or _DEFAULT_ROW_EXTENT)
2134
2468
 
2469
+ # Same native routing as FlatList: headers and items become one
2470
+ # flattened row sequence with per-row heights.
2471
+ if (
2472
+ _native_lists_supported()
2473
+ and list_header is None
2474
+ and list_footer is None
2475
+ and list_empty is None
2476
+ and refresh_control is None
2477
+ and _all_extents_known(rows)
2478
+ ):
2479
+ return _NativeList(
2480
+ rows=rows,
2481
+ on_end_reached=on_end_reached,
2482
+ on_end_reached_threshold=on_end_reached_threshold,
2483
+ on_scroll=on_scroll,
2484
+ list_style=resolve_style(style) or None,
2485
+ controller_ref=ref,
2486
+ key=key,
2487
+ )
2488
+
2135
2489
  return _VirtualizedList(
2136
2490
  rows=rows,
2137
2491
  horizontal=False,
@@ -2194,9 +2548,40 @@ def StatusBar(
2194
2548
  return Element("StatusBar", props, [], key=key)
2195
2549
 
2196
2550
 
2551
+ @component
2552
+ def _KeyboardAvoidingContainer(**p: Any) -> Element:
2553
+ """Hook-driven body of `KeyboardAvoidingView`.
2554
+
2555
+ Subscribes to the platform-reported keyboard height via
2556
+ [`use_keyboard_height`][pythonnative.use_keyboard_height] and
2557
+ applies the shift according to ``behavior``:
2558
+
2559
+ - ``"padding"``: adds the shift as bottom padding, resizing the
2560
+ content area (the default, and the right choice for forms
2561
+ inside a full-height container).
2562
+ - ``"position"``: translates the whole container upward without
2563
+ resizing it (useful for pinned footers/toolbars).
2564
+ """
2565
+ height = use_keyboard_height()
2566
+ behavior = p.get("behavior") or "padding"
2567
+ offset = float(p.get("keyboard_vertical_offset") or 0.0)
2568
+ shift = max(0.0, height - offset) if height > 0 else 0.0
2569
+ style: Dict[str, Any] = dict(p.get("style") or {})
2570
+ if shift > 0:
2571
+ if behavior == "position":
2572
+ transform = list(style.get("transform") or [])
2573
+ transform.append({"translate_y": -shift})
2574
+ style["transform"] = transform
2575
+ else:
2576
+ style["padding_bottom"] = _numeric_edge_padding(style, "bottom") + shift
2577
+ children = p.get("children") or []
2578
+ return Element("KeyboardAvoidingView", style, list(children))
2579
+
2580
+
2197
2581
  def KeyboardAvoidingView(
2198
2582
  *children: Element,
2199
2583
  behavior: Literal["padding", "position"] = "padding",
2584
+ keyboard_vertical_offset: float = 0.0,
2200
2585
  style: StyleProp = None,
2201
2586
  key: Optional[str] = None,
2202
2587
  ) -> Element:
@@ -2204,26 +2589,32 @@ def KeyboardAvoidingView(
2204
2589
 
2205
2590
  Subscribes to the platform-reported keyboard height (via
2206
2591
  [`use_keyboard_height`][pythonnative.use_keyboard_height]
2207
- internally) and applies it as bottom padding so the focused text
2208
- input stays visible.
2592
+ internally) and shifts its content so the focused text input stays
2593
+ visible. On iOS the height comes from
2594
+ ``UIKeyboardWillShowNotification``; on Android from the window's
2595
+ IME insets.
2209
2596
 
2210
2597
  Args:
2211
2598
  *children: Children rendered inside the avoiding container.
2212
- behavior: ``"padding"`` (adds bottom padding) or ``"position"``
2213
- (translates the container upward).
2599
+ behavior: ``"padding"`` (adds bottom padding, resizing the
2600
+ content) or ``"position"`` (translates the container
2601
+ upward without resizing).
2602
+ keyboard_vertical_offset: Distance in layout units already
2603
+ covered by other UI (e.g. a nav bar); subtracted from the
2604
+ keyboard height before applying the shift.
2214
2605
  style: Style dict (or list of dicts).
2215
2606
  key: Stable identity for keyed reconciliation.
2216
2607
 
2217
2608
  Returns:
2218
- An [`Element`][pythonnative.Element] of type
2219
- ``"KeyboardAvoidingView"``.
2609
+ An [`Element`][pythonnative.Element] that renders a
2610
+ ``"KeyboardAvoidingView"`` container.
2220
2611
  """
2221
- return _make_element(
2222
- "KeyboardAvoidingView",
2223
- *children,
2224
- style=style,
2225
- key=key,
2612
+ return _KeyboardAvoidingContainer(
2613
+ children=list(children),
2226
2614
  behavior=behavior,
2615
+ keyboard_vertical_offset=keyboard_vertical_offset,
2616
+ style=resolve_style(style),
2617
+ key=key,
2227
2618
  )
2228
2619
 
2229
2620
 
@@ -2291,7 +2682,10 @@ def Picker(
2291
2682
  accessibility_label: Optional[str] = None,
2292
2683
  accessibility_hint: Optional[str] = None,
2293
2684
  accessible: Optional[bool] = None,
2294
- ref: Optional[Dict[str, Any]] = None,
2685
+ accessibility_state: Optional[AccessibilityState] = None,
2686
+ accessibility_live_region: Optional[Literal["none", "polite", "assertive"]] = None,
2687
+ test_id: Optional[str] = None,
2688
+ ref: Optional[Ref] = None,
2295
2689
  key: Optional[str] = None,
2296
2690
  ) -> Element:
2297
2691
  """A real native dropdown / select widget.
@@ -2314,7 +2708,17 @@ def Picker(
2314
2708
  accessibility_label: Spoken description for screen readers.
2315
2709
  accessibility_hint: Spoken extra detail (iOS only).
2316
2710
  accessible: Override whether the element is exposed to AT.
2317
- ref: Optional ``use_ref()`` dict.
2711
+ accessibility_state: Current widget state for assistive tech,
2712
+ e.g. ``{"disabled": True, "selected": False}``. Recognized
2713
+ keys: ``disabled``, ``selected``, ``checked``, ``busy``,
2714
+ ``expanded``.
2715
+ accessibility_live_region: How AT announces dynamic changes to
2716
+ this view: ``"none"``, ``"polite"``, or ``"assertive"``
2717
+ (Android only).
2718
+ test_id: Stable identifier for UI tests; exposed as
2719
+ ``resource-id`` on Android and ``accessibilityIdentifier``
2720
+ on iOS.
2721
+ ref: Optional [`Ref`][pythonnative.Ref] from ``use_ref()``.
2318
2722
  key: Stable identity for keyed reconciliation.
2319
2723
 
2320
2724
  Returns:
@@ -2332,5 +2736,8 @@ def Picker(
2332
2736
  accessibility_label=accessibility_label,
2333
2737
  accessibility_hint=accessibility_hint,
2334
2738
  accessible=accessible,
2739
+ accessibility_state=accessibility_state,
2740
+ accessibility_live_region=accessibility_live_region,
2741
+ test_id=test_id,
2335
2742
  _defaults={"accessibility_role": "button"},
2336
2743
  )