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
@@ -45,11 +45,12 @@ Example:
45
45
  ```
46
46
  """
47
47
 
48
- from typing import Any, Callable, Dict, List, Optional
48
+ from typing import Any, Callable, Dict, List, Optional, TypedDict, Union
49
49
 
50
50
  from .element import Element
51
51
  from .hooks import (
52
52
  Provider,
53
+ Ref,
53
54
  _NavigationContext,
54
55
  component,
55
56
  create_context,
@@ -60,6 +61,29 @@ from .hooks import (
60
61
  use_state,
61
62
  )
62
63
 
64
+
65
+ class ScreenOptions(TypedDict, total=False):
66
+ """Typed per-screen options accepted by ``Screen(..., options=...)``.
67
+
68
+ All keys are optional. Navigators ignore keys they don't use.
69
+
70
+ Attributes:
71
+ title: Human-readable screen title. Stack navigators forward it
72
+ to the host's native navigation bar; tab and drawer
73
+ navigators use it as the item label.
74
+ tab_bar_icon: Native system icon identifier for tab items. A
75
+ string is used on every platform; a dict like
76
+ ``{"ios": "house.fill", "android": "ic_menu_home"}``
77
+ selects per platform. iOS values are resolved via
78
+ SF Symbols (``UIImage.systemImageNamed_``); Android values
79
+ are resolved against ``android.R.drawable.<name>``. Names
80
+ that don't resolve fall back to text-only.
81
+ """
82
+
83
+ title: str
84
+ tab_bar_icon: Union[str, Dict[str, str]]
85
+
86
+
63
87
  # ======================================================================
64
88
  # Focus context
65
89
  # ======================================================================
@@ -85,14 +109,14 @@ class _ScreenDef:
85
109
  name: Route name used by `navigate(name, ...)`.
86
110
  component: A `@component` function rendered when this screen is
87
111
  active.
88
- options: Free-form per-screen options (e.g., `title` for the
89
- tab bar, `headerShown`, etc.). Interpretation is up to the
90
- specific navigator implementation.
112
+ options: Per-screen [`ScreenOptions`][pythonnative.ScreenOptions]
113
+ (e.g., `title`, `tab_bar_icon`). Interpretation is up to
114
+ the specific navigator implementation.
91
115
  """
92
116
 
93
117
  __slots__ = ("name", "component", "options")
94
118
 
95
- def __init__(self, name: str, component_fn: Any, options: Optional[Dict[str, Any]] = None) -> None:
119
+ def __init__(self, name: str, component_fn: Any, options: Optional[ScreenOptions] = None) -> None:
96
120
  self.name = name
97
121
  self.component = component_fn
98
122
  self.options = options or {}
@@ -450,10 +474,10 @@ def _stack_navigator_impl(screens: Any = None, initial_route: Optional[str] = No
450
474
  stack, set_stack = use_state(lambda: [_RouteEntry(first_route, first_params)])
451
475
 
452
476
  stack_ref = use_ref(None)
453
- stack_ref["current"] = stack
477
+ stack_ref.current = stack
454
478
 
455
479
  handle = use_memo(
456
- lambda: _DeclarativeNavHandle(screen_map, lambda: stack_ref["current"], set_stack, parent=parent_nav), []
480
+ lambda: _DeclarativeNavHandle(screen_map, lambda: stack_ref.current, set_stack, parent=parent_nav), []
457
481
  )
458
482
  handle._screen_map = screen_map
459
483
  handle._parent = parent_nav
@@ -505,7 +529,7 @@ def create_stack_navigator() -> Any:
505
529
 
506
530
  class _StackNavigator:
507
531
  @staticmethod
508
- def Screen(name: str, *, component: Any, options: Optional[Dict[str, Any]] = None) -> "_ScreenDef":
532
+ def Screen(name: str, *, component: Any, options: Optional[ScreenOptions] = None) -> "_ScreenDef":
509
533
  """Define a screen within this stack navigator.
510
534
 
511
535
  Args:
@@ -556,8 +580,8 @@ def _tab_navigator_impl(screens: Any = None, initial_route: Optional[str] = None
556
580
  active_tab, set_active_tab = use_state(first_route)
557
581
  tab_params, set_tab_params = use_state(lambda: {first_route: {}})
558
582
 
559
- params_ref = use_ref(None)
560
- params_ref["current"] = tab_params
583
+ params_ref: Ref[Dict[str, Dict[str, Any]]] = use_ref(None)
584
+ params_ref.current = tab_params
561
585
 
562
586
  def switch_tab(name: str, params: Optional[Dict[str, Any]] = None) -> None:
563
587
  set_active_tab(name)
@@ -565,7 +589,7 @@ def _tab_navigator_impl(screens: Any = None, initial_route: Optional[str] = None
565
589
  set_tab_params(lambda p: {**p, name: params})
566
590
 
567
591
  def get_stack() -> List[_RouteEntry]:
568
- p = params_ref["current"] or {}
592
+ p = params_ref.current or {}
569
593
  return [_RouteEntry(active_tab, p.get(active_tab, {}))]
570
594
 
571
595
  handle = use_memo(lambda: _TabNavHandle(screen_map, get_stack, lambda _: None, switch_tab, parent=parent_nav), [])
@@ -643,7 +667,7 @@ def create_tab_navigator() -> Any:
643
667
 
644
668
  class _TabNavigator:
645
669
  @staticmethod
646
- def Screen(name: str, *, component: Any, options: Optional[Dict[str, Any]] = None) -> "_ScreenDef":
670
+ def Screen(name: str, *, component: Any, options: Optional[ScreenOptions] = None) -> "_ScreenDef":
647
671
  """Define a screen within this tab navigator.
648
672
 
649
673
  Args:
@@ -704,8 +728,8 @@ def _drawer_navigator_impl(screens: Any = None, initial_route: Optional[str] = N
704
728
  drawer_open, set_drawer_open = use_state(False)
705
729
  screen_params, set_screen_params = use_state(lambda: {first_route: {}})
706
730
 
707
- params_ref = use_ref(None)
708
- params_ref["current"] = screen_params
731
+ params_ref: Ref[Dict[str, Dict[str, Any]]] = use_ref(None)
732
+ params_ref.current = screen_params
709
733
 
710
734
  def switch_screen(name: str, params: Optional[Dict[str, Any]] = None) -> None:
711
735
  set_active_screen(name)
@@ -713,7 +737,7 @@ def _drawer_navigator_impl(screens: Any = None, initial_route: Optional[str] = N
713
737
  set_screen_params(lambda p: {**p, name: params})
714
738
 
715
739
  def get_stack() -> List[_RouteEntry]:
716
- p = params_ref["current"] or {}
740
+ p = params_ref.current or {}
717
741
  return [_RouteEntry(active_screen, p.get(active_screen, {}))]
718
742
 
719
743
  handle = use_memo(
@@ -763,7 +787,7 @@ def _drawer_navigator_impl(screens: Any = None, initial_route: Optional[str] = N
763
787
  return _select
764
788
 
765
789
  menu_items.append(
766
- Element("Button", {"title": label, "on_click": make_select(item_name)}, [], key=f"__drawer_{item_name}")
790
+ Element("Button", {"title": label, "on_press": make_select(item_name)}, [], key=f"__drawer_{item_name}")
767
791
  )
768
792
 
769
793
  drawer_panel = Element(
@@ -813,7 +837,7 @@ def create_drawer_navigator() -> Any:
813
837
 
814
838
  class _DrawerNavigator:
815
839
  @staticmethod
816
- def Screen(name: str, *, component: Any, options: Optional[Dict[str, Any]] = None) -> "_ScreenDef":
840
+ def Screen(name: str, *, component: Any, options: Optional[ScreenOptions] = None) -> "_ScreenDef":
817
841
  """Define a screen within this drawer navigator.
818
842
 
819
843
  Args:
pythonnative/preview.py CHANGED
@@ -43,6 +43,36 @@ _POLL_INTERVAL_MS = 16
43
43
  _WATCH_INTERVAL_S = 0.4
44
44
 
45
45
 
46
+ def _publish_desktop_color_scheme() -> None:
47
+ """Publish the host OS appearance so `use_color_scheme` works in preview.
48
+
49
+ ``PN_COLOR_SCHEME=light|dark`` forces a value (handy for testing
50
+ both appearances); otherwise macOS is asked via ``defaults read``
51
+ (the key only exists when dark mode is on). Other platforms default
52
+ to light.
53
+ """
54
+ from . import appearance
55
+
56
+ forced = os.environ.get("PN_COLOR_SCHEME")
57
+ if forced in ("light", "dark"):
58
+ appearance.set_system_color_scheme(forced)
59
+ return
60
+ if sys.platform == "darwin":
61
+ import subprocess
62
+
63
+ try:
64
+ result = subprocess.run(
65
+ ["defaults", "read", "-g", "AppleInterfaceStyle"],
66
+ capture_output=True,
67
+ text=True,
68
+ timeout=2,
69
+ )
70
+ is_dark = result.returncode == 0 and result.stdout.strip() == "Dark"
71
+ appearance.set_system_color_scheme("dark" if is_dark else "light")
72
+ except Exception:
73
+ pass
74
+
75
+
46
76
  class DesktopApp:
47
77
  """Navigation-stack controller for the desktop preview window.
48
78
 
@@ -201,17 +231,13 @@ class DesktopApp:
201
231
  host.on_pause()
202
232
  except Exception:
203
233
  pass
234
+ # ``on_destroy`` unmounts the host's reconciler: effect cleanups
235
+ # run, native widgets are destroyed, and event registrations are
236
+ # released (see ``screen._destroy_host``).
204
237
  try:
205
238
  host.on_destroy()
206
239
  except Exception:
207
240
  pass
208
- reconciler = getattr(host, "_reconciler", None)
209
- tree = getattr(reconciler, "_tree", None) if reconciler is not None else None
210
- if reconciler is not None and tree is not None:
211
- try:
212
- reconciler._destroy_tree(tree)
213
- except Exception:
214
- pass
215
241
  container = getattr(host, "_pn_container", None)
216
242
  if container is not None:
217
243
  try:
@@ -219,6 +245,29 @@ class DesktopApp:
219
245
  except Exception:
220
246
  pass
221
247
 
248
+ def teardown_all(self) -> None:
249
+ """Destroy every screen on the stack (preview window closing)."""
250
+ while self._stack:
251
+ self._teardown(self._stack.pop())
252
+
253
+ def back_pressed(self) -> None:
254
+ """Route a desktop back gesture (Escape) like a hardware back press.
255
+
256
+ ``use_back_handler`` subscribers on the active screen get the
257
+ first chance to consume the event; otherwise the stack pops
258
+ (matching Android's default back behavior). At the root the
259
+ event is ignored.
260
+ """
261
+ host = self.active_host()
262
+ if host is None:
263
+ return
264
+ try:
265
+ if host.on_back_pressed():
266
+ return
267
+ except Exception:
268
+ pass
269
+ self.pop_screen()
270
+
222
271
  # -- viewport / resize --------------------------------------------
223
272
 
224
273
  def resize(self, width: float, height: float) -> None:
@@ -359,6 +408,13 @@ def run_preview(
359
408
  )
360
409
 
361
410
  root_dir, watched = _resolve_paths(component_path, project_root, watch_dir)
411
+ _publish_desktop_color_scheme()
412
+
413
+ # The preview is inherently a development surface: turn on dev
414
+ # diagnostics (validation warnings, hook-order checks, RedBox).
415
+ from . import diagnostics
416
+
417
+ diagnostics.set_dev_mode(True)
362
418
 
363
419
  root = tk.Tk()
364
420
  root.title(title)
@@ -383,6 +439,11 @@ def run_preview(
383
439
 
384
440
  stage.bind("<Configure>", _on_configure)
385
441
 
442
+ # Escape acts as the desktop stand-in for the hardware back button:
443
+ # ``use_back_handler`` subscribers can intercept it, and otherwise
444
+ # the navigation stack pops.
445
+ root.bind("<Escape>", lambda _event: app.back_pressed())
446
+
386
447
  watcher = _build_watcher(watched, root_dir, app, main_queue) if hot_reload else None
387
448
  if watcher is not None:
388
449
  watcher.start()
@@ -415,6 +476,12 @@ def run_preview(
415
476
  watcher.stop()
416
477
  except Exception:
417
478
  pass
479
+ # Unmount every screen first so effect cleanups (timers, tasks,
480
+ # subscriptions) run before the Tk interpreter goes away.
481
+ try:
482
+ app.teardown_all()
483
+ except Exception:
484
+ pass
418
485
  runtime_module.set_desktop_main_dispatch(None)
419
486
  desktop_backend.clear_root_container()
420
487
  try:
@@ -321,15 +321,7 @@ class AppConfig:
321
321
  root = Path(project_root) if project_root is not None else Path.cwd()
322
322
  config_path = root / CONFIG_FILENAME
323
323
  if not config_path.is_file():
324
- legacy = root / "pythonnative.json"
325
- hint = ""
326
- if legacy.is_file():
327
- hint = (
328
- "\nFound a legacy 'pythonnative.json'. PythonNative now uses "
329
- "'pythonnative.toml'.\nRun 'pn init --force' to scaffold one, then "
330
- "port your settings over."
331
- )
332
- raise ConfigError(f"No {CONFIG_FILENAME} found in {root}.{hint}")
324
+ raise ConfigError(f"No {CONFIG_FILENAME} found in {root}.")
333
325
  try:
334
326
  with open(config_path, "rb") as handle:
335
327
  data = _toml.load(handle)