pythonnative 0.23.0__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.
pythonnative/screen.py CHANGED
@@ -27,7 +27,7 @@ Example:
27
27
  count, set_count = pn.use_state(0)
28
28
  return pn.Column(
29
29
  pn.Text(f"Count: {count}", style={"font_size": 24}),
30
- pn.Button("Tap me", on_click=lambda: set_count(count + 1)),
30
+ pn.Button("Tap me", on_press=lambda: set_count(count + 1)),
31
31
  style={"spacing": 12, "padding": 16},
32
32
  )
33
33
  ```
@@ -48,8 +48,10 @@ import json
48
48
  import os
49
49
  import sys
50
50
  import threading
51
- from typing import Any, Dict, Optional, Sequence
51
+ import traceback
52
+ from typing import Any, Dict, Optional, Sequence, Tuple
52
53
 
54
+ from . import diagnostics
53
55
  from .utils import IS_ANDROID, IS_DESKTOP, IS_IOS, set_android_context
54
56
 
55
57
  _MAX_RENDER_PASSES = 25
@@ -186,6 +188,10 @@ def _init_host_common(host: Any, component_path: str, component_func: Any) -> No
186
188
  host._hot_reload_manifest_path = None
187
189
  host._hot_reload_last_version = None
188
190
  host._layout_listener = None # retained on Android to prevent GC
191
+ # RedBox state: a dedicated reconciler that mounts the dev error
192
+ # overlay in place of the app tree (see ``_show_redbox``).
193
+ host._redbox_reconciler = None
194
+ host._redbox_root = None
189
195
  # Focus state: drives ``use_focus_effect``. Starts focused because
190
196
  # a host is only created when the screen is being presented; the
191
197
  # platform lifecycle hooks (``on_resume`` / ``on_pause``) flip this
@@ -207,6 +213,54 @@ def _set_host_focused(host: Any, focused: bool) -> None:
207
213
  pass
208
214
 
209
215
 
216
+ def _destroy_host(host: Any) -> None:
217
+ """Tear down a screen host when the native screen is destroyed for good.
218
+
219
+ Called from each platform's ``on_destroy``. Unmounting the
220
+ reconciler runs every pending effect cleanup, destroys the native
221
+ views (releasing their event registrations), and clears back
222
+ handlers, so a popped screen doesn't leak its whole tree. Also
223
+ unregisters the host's RedBox reporter so runtime errors from other
224
+ screens don't route to a dead overlay.
225
+ """
226
+ _clear_redbox(host, reattach=False)
227
+ diagnostics.set_error_reporter(host, None)
228
+ reconciler = host._reconciler
229
+ host._reconciler = None
230
+ if reconciler is not None:
231
+ try:
232
+ reconciler.unmount()
233
+ except Exception:
234
+ _log_pn("_destroy_host: reconciler.unmount() failed")
235
+ root = host._root_native_view
236
+ host._root_native_view = None
237
+ if root is not None:
238
+ try:
239
+ host._detach_root(root)
240
+ except Exception:
241
+ pass
242
+ host._nav_handle = None
243
+ host._focus_subscribers = []
244
+
245
+
246
+ def _host_back_pressed(host: Any) -> bool:
247
+ """Offer the system back action to ``use_back_handler`` subscribers.
248
+
249
+ Called by the native templates before running the platform's
250
+ default back behavior. Returns ``True`` when a handler consumed
251
+ the event, in which case the platform must *not* pop the screen.
252
+ """
253
+ reconciler = host._reconciler
254
+ if reconciler is None:
255
+ return False
256
+ try:
257
+ return bool(reconciler.dispatch_back_press())
258
+ except Exception as exc:
259
+ if not diagnostics.report_error(exc, phase="back handler"):
260
+ traceback.print_exc()
261
+ return False
262
+
263
+
210
264
  def _push_viewport_size(host: Any, width: float, height: float) -> None:
211
265
  """Forward a viewport-size change to the reconciler.
212
266
 
@@ -223,6 +277,9 @@ def _push_viewport_size(host: Any, width: float, height: float) -> None:
223
277
  if width <= 0 or height <= 0:
224
278
  return
225
279
  host._reconciler.set_viewport_size(float(width), float(height))
280
+ redbox = getattr(host, "_redbox_reconciler", None)
281
+ if redbox is not None:
282
+ redbox.set_viewport_size(float(width), float(height))
226
283
  try:
227
284
  from . import platform_metrics
228
285
 
@@ -269,6 +326,32 @@ def _flush_scheduled_renders(hosts: Sequence[Any]) -> None:
269
326
  _re_render(host)
270
327
 
271
328
 
329
+ def _seed_initial_viewport(host: Any) -> None:
330
+ """Give the reconciler a plausible viewport size *before* the first mount.
331
+
332
+ The authoritative size arrives right after attach (Android's
333
+ ``OnLayoutChangeListener``, iOS ``viewDidLayoutSubviews``, the
334
+ desktop stage size), but by then the mount commit has already run:
335
+ without a viewport its layout pass is skipped, so mount-time
336
+ [`use_layout_effect`][pythonnative.use_layout_effect] callbacks
337
+ would observe no committed frames. Hosts opt in by providing
338
+ ``_initial_viewport_size()``; the estimate only needs to be
339
+ plausible (screen-sized), not pixel-perfect.
340
+ """
341
+ getter = getattr(host, "_initial_viewport_size", None)
342
+ if not callable(getter):
343
+ return
344
+ try:
345
+ size = getter()
346
+ except Exception:
347
+ return
348
+ if not size:
349
+ return
350
+ width, height = size
351
+ if width > 0 and height > 0:
352
+ host._reconciler.set_viewport_size(float(width), float(height))
353
+
354
+
272
355
  def _on_create(host: Any) -> None:
273
356
  from .hooks import NavigationHandle, Provider, _NavigationContext
274
357
 
@@ -285,23 +368,30 @@ def _on_create(host: Any) -> None:
285
368
  # If we're already mounted, just re-attach the existing root view
286
369
  # to the (newly created) native container; ``on_resume`` will
287
370
  # fire the focus subscribers separately.
371
+ _register_redbox_reporter(host)
288
372
  if host._reconciler is not None and host._root_native_view is not None:
289
373
  host._attach_root(host._root_native_view)
290
374
  return
291
375
 
292
376
  host._nav_handle = NavigationHandle(host)
293
377
  host._reconciler = _new_reconciler(host)
378
+ _seed_initial_viewport(host)
294
379
 
295
- app_element = _render_app(host)
296
- provider_element = Provider(_NavigationContext, host._nav_handle, app_element)
297
-
298
- host._is_rendering = True
299
380
  try:
300
- host._root_native_view = host._reconciler.mount(provider_element)
301
- host._attach_root(host._root_native_view)
302
- _drain_renders(host)
303
- finally:
304
- host._is_rendering = False
381
+ app_element = _render_app(host)
382
+ provider_element = Provider(_NavigationContext, host._nav_handle, app_element)
383
+
384
+ host._is_rendering = True
385
+ try:
386
+ host._root_native_view = host._reconciler.mount(provider_element)
387
+ host._attach_root(host._root_native_view)
388
+ _drain_renders(host)
389
+ finally:
390
+ host._is_rendering = False
391
+ except Exception as exc:
392
+ if not diagnostics.is_dev():
393
+ raise
394
+ _show_redbox(host, exc, phase="mount")
305
395
 
306
396
 
307
397
  def _request_render(host: Any) -> None:
@@ -333,13 +423,18 @@ def _re_render(host: Any) -> None:
333
423
  hot reload.
334
424
  """
335
425
  _log_pn("_re_render: starting local render pass")
336
- host._is_rendering = True
337
426
  try:
338
- host._render_queued = False
339
- _commit_dirty(host)
340
- _drain_renders(host)
341
- finally:
342
- host._is_rendering = False
427
+ host._is_rendering = True
428
+ try:
429
+ host._render_queued = False
430
+ _commit_dirty(host)
431
+ _drain_renders(host)
432
+ finally:
433
+ host._is_rendering = False
434
+ except Exception as exc:
435
+ if not diagnostics.is_dev():
436
+ raise
437
+ _show_redbox(host, exc, phase="render")
343
438
  _log_pn("_re_render: done")
344
439
 
345
440
 
@@ -367,6 +462,130 @@ def _drain_renders(host: Any) -> None:
367
462
  _commit_dirty(host)
368
463
 
369
464
 
465
+ # ======================================================================
466
+ # RedBox (dev-mode error overlay)
467
+ # ======================================================================
468
+ #
469
+ # In dev mode (``pn preview``, ``pn run`` with hot reload, ``PN_DEV=1``)
470
+ # an uncaught error from a mount, render, effect, or event handler
471
+ # replaces the screen with a full-screen traceback instead of crashing
472
+ # the process or dying silently in the log. The overlay is a normal
473
+ # PythonNative element tree mounted by a *dedicated* reconciler so a
474
+ # broken app tree can't take the error display down with it. Fixing the
475
+ # code and saving clears it via hot reload; the Dismiss button restores
476
+ # the previous native root (which is still mounted underneath).
477
+
478
+
479
+ def _redbox_element(exc: BaseException, phase: str, on_dismiss: Any) -> Any:
480
+ """Build the RedBox element tree for ``exc``."""
481
+ from .components import Button, Column, ScrollView, Text
482
+
483
+ trace = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
484
+ message = str(exc) or "(no message)"
485
+ return Column(
486
+ Column(
487
+ Text(
488
+ f"{type(exc).__name__} in {phase}",
489
+ style={"color": "#FFD3DA", "font_size": 13, "bold": True},
490
+ ),
491
+ Text(message, style={"color": "#FFFFFF", "font_size": 17, "bold": True}),
492
+ style={
493
+ "background_color": "#C4283C",
494
+ "padding": 16,
495
+ "padding_top": 56,
496
+ "spacing": 6,
497
+ },
498
+ ),
499
+ ScrollView(
500
+ Text(trace, style={"color": "#FF9AA8", "font_size": 12}),
501
+ style={"flex": 1, "padding": 12},
502
+ ),
503
+ Column(
504
+ Button("Dismiss", on_press=on_dismiss, style={"color": "#FFFFFF"}),
505
+ Text(
506
+ "Fix the error and save to reload.",
507
+ style={"color": "#8E8E93", "font_size": 12, "text_align": "center"},
508
+ ),
509
+ style={"padding": 12, "padding_bottom": 32, "spacing": 4},
510
+ ),
511
+ style={"flex": 1, "background_color": "#1C1C1E"},
512
+ )
513
+
514
+
515
+ def _show_redbox(host: Any, exc: BaseException, phase: str = "render") -> None:
516
+ """Mount the dev error overlay over the host's screen.
517
+
518
+ Safe to call repeatedly (a newer error replaces the current
519
+ overlay) and from any thread (the mount hops to the main thread).
520
+ Failures inside the RedBox itself fall back to printing both
521
+ tracebacks so the original error is never lost.
522
+ """
523
+ _log_pn(f"_show_redbox: {type(exc).__name__} during {phase}")
524
+ try:
525
+ print(f"[PN] {phase} error:", file=sys.stderr)
526
+ traceback.print_exception(type(exc), exc, exc.__traceback__)
527
+ except Exception:
528
+ pass
529
+
530
+ def _mount() -> None:
531
+ try:
532
+ _clear_redbox(host, reattach=False)
533
+ from .native_views import get_registry
534
+ from .reconciler import Reconciler
535
+
536
+ redbox = Reconciler(get_registry())
537
+ redbox._screen_re_render = redbox.flush_dirty
538
+ element = _redbox_element(exc, phase, lambda: _clear_redbox(host))
539
+ root = redbox.mount(element)
540
+ width, height = (0.0, 0.0)
541
+ if host._reconciler is not None:
542
+ width, height = host._reconciler._viewport_size
543
+ if width <= 0 or height <= 0:
544
+ from . import platform_metrics
545
+
546
+ dims = platform_metrics.get_window_dimensions()
547
+ width, height = dims.width, dims.height
548
+ if width > 0 and height > 0:
549
+ redbox.set_viewport_size(width, height)
550
+ host._redbox_reconciler = redbox
551
+ host._redbox_root = root
552
+ if host._root_native_view is not None:
553
+ host._detach_root(host._root_native_view)
554
+ host._attach_root(root)
555
+ except Exception:
556
+ print("[PN] RedBox failed to mount:", file=sys.stderr)
557
+ traceback.print_exc()
558
+
559
+ from .runtime import call_on_main_thread
560
+
561
+ call_on_main_thread(_mount)
562
+
563
+
564
+ def _clear_redbox(host: Any, reattach: bool = True) -> None:
565
+ """Tear down the RedBox overlay and optionally restore the app root."""
566
+ redbox = getattr(host, "_redbox_reconciler", None)
567
+ if redbox is None:
568
+ return
569
+ host._redbox_reconciler = None
570
+ host._redbox_root = None
571
+ try:
572
+ redbox.unmount()
573
+ except Exception:
574
+ pass
575
+ if reattach and host._root_native_view is not None:
576
+ try:
577
+ host._attach_root(host._root_native_view)
578
+ except Exception:
579
+ pass
580
+
581
+
582
+ def _register_redbox_reporter(host: Any) -> None:
583
+ """Route runtime errors (events, async) for this host to the RedBox."""
584
+ if not diagnostics.is_dev():
585
+ return
586
+ diagnostics.set_error_reporter(host, lambda exc, phase: _show_redbox(host, exc, phase))
587
+
588
+
370
589
  def _set_args(host: Any, args: Any) -> None:
371
590
  if isinstance(args, str):
372
591
  try:
@@ -380,6 +599,10 @@ def _set_args(host: Any, args: Any) -> None:
380
599
  def _enable_hot_reload(host: Any, manifest_path: str) -> None:
381
600
  host._hot_reload_manifest_path = manifest_path
382
601
  host._hot_reload_last_version = None
602
+ # Hot reload only runs on debug builds, so treat it as the on-device
603
+ # dev-mode switch (validation warnings, hook-order checks, RedBox).
604
+ diagnostics.set_dev_mode(True)
605
+ _register_redbox_reporter(host)
383
606
 
384
607
 
385
608
  def _hot_reload_tick(host: Any) -> bool:
@@ -473,8 +696,13 @@ def _reload_host(host: Any, changed_modules: Optional[Sequence[str]] = None) ->
473
696
 
474
697
  try:
475
698
  new_component = _import_component(host._component_path)
476
- except Exception as e:
477
- _log_pn(f"_reload_host: re-import failed: {e!r}; aborting reload")
699
+ except Exception as exc:
700
+ _log_pn(f"_reload_host: re-import failed: {exc!r}; aborting reload")
701
+ if diagnostics.is_dev():
702
+ # A syntax error (or import-time crash) in the edited file:
703
+ # show it in the RedBox so the developer sees it on device
704
+ # instead of the app silently keeping the old code.
705
+ _show_redbox(host, exc, phase="hot reload import")
478
706
  return
479
707
  host._component = new_component
480
708
 
@@ -482,11 +710,22 @@ def _reload_host(host: Any, changed_modules: Optional[Sequence[str]] = None) ->
482
710
  _log_pn(f"_reload_host: host=0x{id(host):x} reconciler=None; skipping refresh")
483
711
  return
484
712
 
713
+ # A reload always replaces whatever error state was on screen.
714
+ # Re-attach the current root now: Fast Refresh patches that tree in
715
+ # place (attach must happen regardless of whether it succeeds), and
716
+ # a full remount swaps in its own fresh root anyway.
717
+ _clear_redbox(host)
718
+
485
719
  if _try_fast_refresh(host, reloaded):
486
720
  print(f"[hot-reload] Fast Refresh: {', '.join(requested) or ', '.join(reloaded)}", file=sys.stderr)
487
721
  return
488
722
 
489
- _full_remount(host, reloaded)
723
+ try:
724
+ _full_remount(host, reloaded)
725
+ except Exception as exc:
726
+ if not diagnostics.is_dev():
727
+ raise
728
+ _show_redbox(host, exc, phase="hot reload")
490
729
 
491
730
 
492
731
  def _try_fast_refresh(host: Any, reloaded_modules: Sequence[str]) -> bool:
@@ -819,6 +1058,15 @@ if IS_ANDROID:
819
1058
  set_android_context(native_instance)
820
1059
  _init_host_common(self, component_path, component_func)
821
1060
 
1061
+ def _initial_viewport_size(self) -> Optional[Tuple[float, float]]:
1062
+ """Estimate the viewport from display metrics (pre-attach)."""
1063
+ try:
1064
+ metrics = self.native_instance.getResources().getDisplayMetrics()
1065
+ density = float(metrics.density) or 1.0
1066
+ return (metrics.widthPixels / density, metrics.heightPixels / density)
1067
+ except Exception:
1068
+ return None
1069
+
822
1070
  def on_create(self) -> None:
823
1071
  _android_publish_color_scheme(self.native_instance)
824
1072
  _on_create(self)
@@ -844,7 +1092,10 @@ if IS_ANDROID:
844
1092
  pass
845
1093
 
846
1094
  def on_destroy(self) -> None:
847
- pass
1095
+ _destroy_host(self)
1096
+
1097
+ def on_back_pressed(self) -> bool:
1098
+ return _host_back_pressed(self)
848
1099
 
849
1100
  def enable_hot_reload(self, manifest_path: str, source_root: Optional[str] = None) -> None:
850
1101
  _enable_hot_reload(self, manifest_path)
@@ -938,11 +1189,18 @@ if IS_ANDROID:
938
1189
  _android_push_initial_viewport(self, container)
939
1190
 
940
1191
  def _detach_root(self, native_view: Any) -> None:
1192
+ # Remove this host's specific root view from whatever parent
1193
+ # holds it. Never clear the shared fragment container: when a
1194
+ # fragment is popped, its ``onDestroy`` (and therefore this
1195
+ # detach) runs *after* the screen below has already re-attached
1196
+ # its own root to the container, so a ``removeAllViews()`` here
1197
+ # would blank the restored screen.
1198
+ if native_view is None:
1199
+ return
941
1200
  try:
942
- from .utils import get_android_fragment_container
943
-
944
- container = get_android_fragment_container()
945
- container.removeAllViews()
1201
+ parent = native_view.getParent()
1202
+ if parent is not None:
1203
+ parent.removeView(native_view)
946
1204
  except Exception:
947
1205
  pass
948
1206
 
@@ -1005,6 +1263,17 @@ elif IS_DESKTOP:
1005
1263
  self.native_instance = native_instance
1006
1264
  _init_host_common(self, component_path, component_func)
1007
1265
 
1266
+ def _initial_viewport_size(self) -> Optional[Tuple[float, float]]:
1267
+ """Read the stage size from the DesktopApp controller (pre-attach)."""
1268
+ app = self.native_instance
1269
+ if app is None or not hasattr(app, "viewport_size"):
1270
+ return None
1271
+ try:
1272
+ width, height = app.viewport_size()
1273
+ return (float(width), float(height))
1274
+ except Exception:
1275
+ return None
1276
+
1008
1277
  def on_create(self) -> None:
1009
1278
  _on_create(self)
1010
1279
 
@@ -1024,7 +1293,10 @@ elif IS_DESKTOP:
1024
1293
  pass
1025
1294
 
1026
1295
  def on_destroy(self) -> None:
1027
- pass
1296
+ _destroy_host(self)
1297
+
1298
+ def on_back_pressed(self) -> bool:
1299
+ return _host_back_pressed(self)
1028
1300
 
1029
1301
  def enable_hot_reload(self, manifest_path: str, source_root: Optional[str] = None) -> None:
1030
1302
  _enable_hot_reload(self, manifest_path)
@@ -1352,6 +1624,24 @@ else:
1352
1624
  if self.native_instance is not None:
1353
1625
  _ios_register_screen(self.native_instance, self)
1354
1626
 
1627
+ def _initial_viewport_size(self) -> Optional[Tuple[float, float]]:
1628
+ """Estimate the viewport from the VC's view bounds (pre-attach)."""
1629
+ try:
1630
+ if self.native_instance is not None:
1631
+ bounds = self.native_instance.view.bounds
1632
+ width = float(bounds.size.width)
1633
+ height = float(bounds.size.height)
1634
+ if width > 0 and height > 0:
1635
+ return (width, height)
1636
+ except Exception:
1637
+ pass
1638
+ try:
1639
+ UIScreen = ObjCClass("UIScreen")
1640
+ screen_bounds = UIScreen.mainScreen.bounds
1641
+ return (float(screen_bounds.size.width), float(screen_bounds.size.height))
1642
+ except Exception:
1643
+ return None
1644
+
1355
1645
  def on_create(self) -> None:
1356
1646
  _ios_publish_color_scheme()
1357
1647
  _on_create(self)
@@ -1368,6 +1658,10 @@ else:
1368
1658
  def on_destroy(self) -> None:
1369
1659
  if self.native_instance is not None:
1370
1660
  _ios_unregister_screen(self.native_instance)
1661
+ _destroy_host(self)
1662
+
1663
+ def on_back_pressed(self) -> bool:
1664
+ return _host_back_pressed(self)
1371
1665
 
1372
1666
  def enable_hot_reload(self, manifest_path: str, source_root: Optional[str] = None) -> None:
1373
1667
  _enable_hot_reload(self, manifest_path)
@@ -1631,7 +1925,10 @@ else:
1631
1925
  pass
1632
1926
 
1633
1927
  def on_destroy(self) -> None:
1634
- pass
1928
+ _destroy_host(self)
1929
+
1930
+ def on_back_pressed(self) -> bool:
1931
+ return _host_back_pressed(self)
1635
1932
 
1636
1933
  def enable_hot_reload(self, manifest_path: str, source_root: Optional[str] = None) -> None:
1637
1934
  _enable_hot_reload(self, manifest_path)
pythonnative/storage.py CHANGED
@@ -375,7 +375,7 @@ def use_persisted_state(
375
375
  theme, set_theme = pn.use_persisted_state("settings.theme", "light")
376
376
  return pn.Button(
377
377
  f"Theme: {theme}",
378
- on_click=lambda: set_theme("dark" if theme == "light" else "light"),
378
+ on_press=lambda: set_theme("dark" if theme == "light" else "light"),
379
379
  )
380
380
  ```
381
381
  """
@@ -389,14 +389,14 @@ def use_persisted_state(
389
389
  stored = await AsyncStorage.get_json(key)
390
390
  if stored is not None:
391
391
  set_state(stored)
392
- loaded["current"] = True
392
+ loaded.current = True
393
393
 
394
394
  use_async_effect(_load, [key])
395
395
 
396
396
  def setter(value_or_updater: Any) -> None:
397
397
  def _reducer(current: Any) -> Any:
398
398
  new_value = value_or_updater(current) if callable(value_or_updater) else value_or_updater
399
- if loaded["current"] is True:
399
+ if loaded.current is True:
400
400
  run_async(AsyncStorage.set_json(key, new_value))
401
401
  return new_value
402
402
 
pythonnative/style.py CHANGED
@@ -34,8 +34,10 @@ Example:
34
34
  ```
35
35
  """
36
36
 
37
+ import difflib
37
38
  from typing import Any, Dict, List, Literal, Optional, Tuple, TypedDict, Union
38
39
 
40
+ from . import diagnostics
39
41
  from .hooks import Context, create_context, use_color_scheme, use_context
40
42
 
41
43
  # ======================================================================
@@ -74,6 +76,30 @@ EdgeValue = Union[Dimension, EdgeInsets]
74
76
  [`EdgeInsets`][pythonnative.EdgeInsets] dict."""
75
77
 
76
78
 
79
+ class AccessibilityState(TypedDict, total=False):
80
+ """Current widget state exposed to assistive technology.
81
+
82
+ Passed via the ``accessibility_state=`` prop on interactive
83
+ components. All keys are optional; omitted keys are treated as
84
+ unset (not ``False``).
85
+
86
+ Attributes:
87
+ disabled: The widget is visible but not interactive.
88
+ selected: The widget is currently selected (e.g. the active
89
+ tab).
90
+ checked: Toggle/checkbox state. ``"mixed"`` represents a
91
+ tri-state checkbox.
92
+ busy: The widget is loading or otherwise temporarily busy.
93
+ expanded: A disclosure/accordion is currently expanded.
94
+ """
95
+
96
+ disabled: bool
97
+ selected: bool
98
+ checked: Union[bool, Literal["mixed"]]
99
+ busy: bool
100
+ expanded: bool
101
+
102
+
77
103
  class ShadowOffset(TypedDict):
78
104
  """Shadow displacement in points."""
79
105
 
@@ -136,6 +162,17 @@ TransformSpec = Union[TransformEntry, List[TransformEntry]]
136
162
  # ----------------------------------------------------------------------
137
163
 
138
164
  FlexDirection = Literal["row", "column", "row_reverse", "column_reverse"]
165
+ FlexWrap = Literal["nowrap", "wrap", "wrap_reverse"]
166
+ AlignContent = Literal[
167
+ "flex_start",
168
+ "center",
169
+ "flex_end",
170
+ "stretch",
171
+ "space_between",
172
+ "space_around",
173
+ "space_evenly",
174
+ ]
175
+ LayoutDirection = Literal["ltr", "rtl"]
139
176
  JustifyContent = Literal[
140
177
  "flex_start",
141
178
  "center",
@@ -241,9 +278,12 @@ class Style(TypedDict, total=False):
241
278
  flex_shrink: float
242
279
  flex_basis: Dimension
243
280
  flex_direction: FlexDirection
281
+ flex_wrap: FlexWrap
244
282
  justify_content: JustifyContent
245
283
  align_items: AlignItems
246
284
  align_self: AlignSelf
285
+ align_content: AlignContent
286
+ direction: LayoutDirection
247
287
 
248
288
  # --- Layout: position ---
249
289
  position: Position
@@ -251,6 +291,8 @@ class Style(TypedDict, total=False):
251
291
  right: Dimension
252
292
  bottom: Dimension
253
293
  left: Dimension
294
+ start: Dimension
295
+ end: Dimension
254
296
 
255
297
  # --- Layout: spacing ---
256
298
  padding: EdgeValue
@@ -258,6 +300,8 @@ class Style(TypedDict, total=False):
258
300
  padding_bottom: Dimension
259
301
  padding_left: Dimension
260
302
  padding_right: Dimension
303
+ padding_start: Dimension
304
+ padding_end: Dimension
261
305
  padding_horizontal: Dimension
262
306
  padding_vertical: Dimension
263
307
  margin: EdgeValue
@@ -265,10 +309,14 @@ class Style(TypedDict, total=False):
265
309
  margin_bottom: Dimension
266
310
  margin_left: Dimension
267
311
  margin_right: Dimension
312
+ margin_start: Dimension
313
+ margin_end: Dimension
268
314
  margin_horizontal: Dimension
269
315
  margin_vertical: Dimension
270
316
  spacing: float
271
317
  gap: float
318
+ row_gap: float
319
+ column_gap: float
272
320
 
273
321
  # --- Visual: clipping & overflow ---
274
322
  overflow: Overflow
@@ -378,6 +426,40 @@ def resolve_style(value: StyleProp) -> Dict[str, Any]:
378
426
  return result
379
427
 
380
428
 
429
+ _KNOWN_STYLE_KEYS = frozenset(Style.__annotations__)
430
+ """Every key declared on the [`Style`][pythonnative.Style] TypedDict."""
431
+
432
+
433
+ def validate_style_keys(style_dict: Dict[str, Any], owner: str = "") -> None:
434
+ """Warn (once per key) about style keys no built-in handler reads.
435
+
436
+ Dev-mode only; production skips the scan entirely. Typos get a
437
+ "did you mean" suggestion via fuzzy matching against the declared
438
+ [`Style`][pythonnative.Style] keys. Unknown keys still flow through
439
+ to handlers untouched, so custom components that read extra keys
440
+ keep working (at the cost of one dev warning).
441
+
442
+ Args:
443
+ style_dict: The flattened style dict about to be merged into an
444
+ element's props.
445
+ owner: Element type name for the warning message (e.g.
446
+ ``"Text"``).
447
+ """
448
+ if not diagnostics.is_dev() or not style_dict:
449
+ return
450
+ for key in style_dict:
451
+ if key in _KNOWN_STYLE_KEYS:
452
+ continue
453
+ matches = difflib.get_close_matches(str(key), _KNOWN_STYLE_KEYS, n=1)
454
+ hint = f" Did you mean {matches[0]!r}?" if matches else ""
455
+ diagnostics.warn_once(
456
+ f"Unknown style key {key!r}"
457
+ + (f" on {owner}" if owner else "")
458
+ + f".{hint} Built-in components ignore keys they don't recognize.",
459
+ key=f"style:{owner}:{key}",
460
+ )
461
+
462
+
381
463
  # ======================================================================
382
464
  # StyleSheet
383
465
  # ======================================================================
@@ -604,4 +686,5 @@ __all__ = [
604
686
  "resolve_style",
605
687
  "style",
606
688
  "use_theme",
689
+ "validate_style_keys",
607
690
  ]