pythonnative 0.22.1__py3-none-any.whl → 0.23.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 (25) hide show
  1. pythonnative/__init__.py +16 -2
  2. pythonnative/appearance.py +124 -0
  3. pythonnative/components.py +708 -38
  4. pythonnative/hooks.py +41 -0
  5. pythonnative/images.py +196 -0
  6. pythonnative/mutations.py +3 -12
  7. pythonnative/native_views/__init__.py +1 -7
  8. pythonnative/native_views/android.py +573 -45
  9. pythonnative/native_views/desktop.py +78 -19
  10. pythonnative/native_views/ios.py +739 -51
  11. pythonnative/preview.py +31 -0
  12. pythonnative/project/config.py +1 -9
  13. pythonnative/screen.py +85 -0
  14. pythonnative/style.py +65 -6
  15. pythonnative/templates/android_template/app/src/main/AndroidManifest.xml +2 -1
  16. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNAccessibilityDelegate.kt +47 -0
  17. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNBorderDrawable.kt +67 -0
  18. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNVirtualListView.java +172 -0
  19. pythonnative/virtual_rows.py +137 -0
  20. {pythonnative-0.22.1.dist-info → pythonnative-0.23.0.dist-info}/METADATA +1 -1
  21. {pythonnative-0.22.1.dist-info → pythonnative-0.23.0.dist-info}/RECORD +25 -19
  22. {pythonnative-0.22.1.dist-info → pythonnative-0.23.0.dist-info}/WHEEL +1 -1
  23. {pythonnative-0.22.1.dist-info → pythonnative-0.23.0.dist-info}/entry_points.txt +0 -0
  24. {pythonnative-0.22.1.dist-info → pythonnative-0.23.0.dist-info}/licenses/LICENSE +0 -0
  25. {pythonnative-0.22.1.dist-info → pythonnative-0.23.0.dist-info}/top_level.txt +0 -0
pythonnative/hooks.py CHANGED
@@ -899,6 +899,47 @@ def use_keyboard_height() -> float:
899
899
  return platform_metrics.get_keyboard_height()
900
900
 
901
901
 
902
+ def use_color_scheme() -> str:
903
+ """Return the effective color scheme and re-render when it changes.
904
+
905
+ Equivalent to React Native's ``useColorScheme``. The system value
906
+ is published by the screen host; an app-level override set through
907
+ [`appearance.set_color_scheme`][pythonnative.appearance.set_color_scheme]
908
+ takes precedence.
909
+
910
+ Returns:
911
+ ``"light"`` or ``"dark"``.
912
+
913
+ Raises:
914
+ RuntimeError: If called outside a `@component` function.
915
+
916
+ Example:
917
+ ```python
918
+ import pythonnative as pn
919
+
920
+ @pn.component
921
+ def Banner():
922
+ scheme = pn.use_color_scheme()
923
+ bg = "#000000" if scheme == "dark" else "#FFFFFF"
924
+ return pn.View(style=pn.style(background_color=bg))
925
+ ```
926
+ """
927
+ from . import appearance
928
+
929
+ ctx = _get_hook_state()
930
+ if ctx is None:
931
+ raise RuntimeError("use_color_scheme must be called inside a @component function")
932
+
933
+ _, set_tick = use_state(0)
934
+
935
+ def subscribe() -> Callable[[], None]:
936
+ return appearance.subscribe(lambda: set_tick(lambda n: n + 1))
937
+
938
+ use_effect(subscribe, [])
939
+
940
+ return appearance.get_color_scheme()
941
+
942
+
902
943
  # ======================================================================
903
944
  # Context
904
945
  # ======================================================================
pythonnative/images.py ADDED
@@ -0,0 +1,196 @@
1
+ """Shared image pipeline: async fetch with memory and disk caching.
2
+
3
+ Every platform's ``Image`` handler routes remote sources through this
4
+ module. The pipeline downloads on a daemon thread (never blocking the
5
+ UI), stores the raw bytes in a platform-appropriate disk cache keyed
6
+ by URL hash, deduplicates concurrent requests for the same URL, and
7
+ keeps a small in-memory LRU of recently fetched byte payloads so
8
+ scrolling back to an image doesn't touch the filesystem again.
9
+
10
+ Decoding stays platform-native (``BitmapFactory`` / ``UIImage`` /
11
+ ``PhotoImage``): callbacks receive a *local file path*, which each
12
+ handler decodes with its platform's downsampling facilities.
13
+
14
+ Callbacks are delivered on the platform main thread via
15
+ [`call_on_main_thread`][pythonnative.runtime.call_on_main_thread], so
16
+ handlers can touch native views directly.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import os
23
+ import tempfile
24
+ import threading
25
+ import urllib.request
26
+ from collections import OrderedDict
27
+ from typing import Callable, Dict, List, Optional, Tuple
28
+
29
+ _DOWNLOAD_TIMEOUT_S = 30.0
30
+ _MEMORY_CACHE_MAX_BYTES = 16 * 1024 * 1024
31
+
32
+
33
+ def _default_cache_dir() -> str:
34
+ """Resolve the on-disk cache directory for the current platform.
35
+
36
+ Android: the app's ``Context.getCacheDir()`` (purged by the OS
37
+ under storage pressure). iOS: ``~/Library/Caches`` (excluded from
38
+ backups). Desktop: a per-user directory under the system temp dir.
39
+ """
40
+ try:
41
+ from .utils import IS_ANDROID
42
+
43
+ if IS_ANDROID:
44
+ from .utils import get_android_context
45
+
46
+ base = str(get_android_context().getCacheDir().getAbsolutePath())
47
+ return os.path.join(base, "pn_images")
48
+ except Exception:
49
+ pass
50
+ home = os.path.expanduser("~")
51
+ caches = os.path.join(home, "Library", "Caches")
52
+ if os.path.isdir(caches): # iOS / macOS
53
+ return os.path.join(caches, "pn_images")
54
+ return os.path.join(tempfile.gettempdir(), "pn_images")
55
+
56
+
57
+ class _ByteLru:
58
+ """Tiny thread-safe LRU for raw image bytes."""
59
+
60
+ def __init__(self, max_bytes: int) -> None:
61
+ self._max = max_bytes
62
+ self._size = 0
63
+ self._items: "OrderedDict[str, bytes]" = OrderedDict()
64
+ self._lock = threading.Lock()
65
+
66
+ def get(self, key: str) -> Optional[bytes]:
67
+ with self._lock:
68
+ data = self._items.get(key)
69
+ if data is not None:
70
+ self._items.move_to_end(key)
71
+ return data
72
+
73
+ def put(self, key: str, data: bytes) -> None:
74
+ if len(data) > self._max:
75
+ return
76
+ with self._lock:
77
+ old = self._items.pop(key, None)
78
+ if old is not None:
79
+ self._size -= len(old)
80
+ self._items[key] = data
81
+ self._size += len(data)
82
+ while self._size > self._max and self._items:
83
+ _, evicted = self._items.popitem(last=False)
84
+ self._size -= len(evicted)
85
+
86
+ def clear(self) -> None:
87
+ with self._lock:
88
+ self._items.clear()
89
+ self._size = 0
90
+
91
+
92
+ _memory_cache = _ByteLru(_MEMORY_CACHE_MAX_BYTES)
93
+ _cache_dir: Optional[str] = None
94
+ # URL -> callbacks waiting on an in-flight download.
95
+ _in_flight: Dict[str, List[Tuple[Callable[[str], None], Callable[[str], None]]]] = {}
96
+ _in_flight_lock = threading.Lock()
97
+
98
+
99
+ def _get_cache_dir() -> str:
100
+ global _cache_dir
101
+ if _cache_dir is None:
102
+ _cache_dir = _default_cache_dir()
103
+ os.makedirs(_cache_dir, exist_ok=True)
104
+ return _cache_dir
105
+
106
+
107
+ def _cache_path(url: str) -> str:
108
+ digest = hashlib.sha256(url.encode("utf-8")).hexdigest()
109
+ ext = os.path.splitext(url.split("?", 1)[0])[1]
110
+ if len(ext) > 8 or "/" in ext:
111
+ ext = ""
112
+ return os.path.join(_get_cache_dir(), digest + ext)
113
+
114
+
115
+ def _dispatch_main(fn: Callable[[], None]) -> None:
116
+ try:
117
+ from .runtime import call_on_main_thread
118
+
119
+ call_on_main_thread(fn)
120
+ except Exception:
121
+ try:
122
+ fn()
123
+ except Exception:
124
+ pass
125
+
126
+
127
+ def fetch(
128
+ url: str,
129
+ on_ready: Callable[[str], None],
130
+ on_error: Optional[Callable[[str], None]] = None,
131
+ ) -> None:
132
+ """Fetch ``url`` into the cache and deliver a local file path.
133
+
134
+ Cache hits (memory or disk) still deliver asynchronously-consistent
135
+ behavior but resolve without a network round trip. Concurrent
136
+ requests for the same URL share one download. Callbacks run on the
137
+ platform main thread.
138
+
139
+ Args:
140
+ url: The ``http(s)`` image URL.
141
+ on_ready: Called with the local file path once available.
142
+ on_error: Called with an error message if the download fails.
143
+ """
144
+ path = _cache_path(url)
145
+ if _memory_cache.get(url) is not None or os.path.isfile(path):
146
+ _dispatch_main(lambda: on_ready(path))
147
+ return
148
+
149
+ err = on_error or (lambda _msg: None)
150
+ with _in_flight_lock:
151
+ waiters = _in_flight.get(url)
152
+ if waiters is not None:
153
+ waiters.append((on_ready, err))
154
+ return
155
+ _in_flight[url] = [(on_ready, err)]
156
+
157
+ def _worker() -> None:
158
+ error_msg: Optional[str] = None
159
+ try:
160
+ req = urllib.request.Request(url, headers={"User-Agent": "PythonNative"})
161
+ with urllib.request.urlopen(req, timeout=_DOWNLOAD_TIMEOUT_S) as resp:
162
+ data = resp.read()
163
+ _memory_cache.put(url, data)
164
+ tmp = path + ".part"
165
+ with open(tmp, "wb") as fh:
166
+ fh.write(data)
167
+ os.replace(tmp, path)
168
+ except Exception as exc:
169
+ error_msg = str(exc)
170
+ with _in_flight_lock:
171
+ callbacks = _in_flight.pop(url, [])
172
+
173
+ def _notify(ready_cb: Callable[[str], None], error_cb: Callable[[str], None], msg: Optional[str]) -> None:
174
+ if msg is None:
175
+ _dispatch_main(lambda: ready_cb(path))
176
+ else:
177
+ _dispatch_main(lambda: error_cb(msg))
178
+
179
+ for ready_cb, error_cb in callbacks:
180
+ _notify(ready_cb, error_cb, error_msg)
181
+
182
+ threading.Thread(target=_worker, name=f"pn-image-{path[-12:]}", daemon=True).start()
183
+
184
+
185
+ def clear_cache() -> None:
186
+ """Empty the memory cache and delete all cached image files."""
187
+ _memory_cache.clear()
188
+ try:
189
+ directory = _get_cache_dir()
190
+ for name in os.listdir(directory):
191
+ try:
192
+ os.remove(os.path.join(directory, name))
193
+ except OSError:
194
+ pass
195
+ except OSError:
196
+ pass
pythonnative/mutations.py CHANGED
@@ -22,8 +22,8 @@ Op ordering rules (the reconciler guarantees these):
22
22
 
23
23
  1. A `CreateOp` for a tag precedes any other op referencing that tag.
24
24
  2. `InsertOp` ops appear after both the parent and child exist.
25
- 3. `DestroyOp` ops appear after the corresponding `RemoveOp` (if the
26
- node was attached) and are emitted children-first.
25
+ 3. `DestroyOp` ops are emitted children-first; handlers detach the view
26
+ from its parent as part of destruction.
27
27
  4. `SetFrameOp` ops are only emitted for frames that actually changed
28
28
  since the last layout pass (frame diffing).
29
29
  """
@@ -35,7 +35,6 @@ __all__ = [
35
35
  "CreateOp",
36
36
  "UpdateOp",
37
37
  "InsertOp",
38
- "RemoveOp",
39
38
  "DestroyOp",
40
39
  "SetFrameOp",
41
40
  "Mutation",
@@ -86,14 +85,6 @@ class InsertOp:
86
85
  index: int
87
86
 
88
87
 
89
- @dataclass(frozen=True)
90
- class RemoveOp:
91
- """Detach the child view from the parent view (without destroying it)."""
92
-
93
- parent_tag: int
94
- child_tag: int
95
-
96
-
97
88
  @dataclass(frozen=True)
98
89
  class DestroyOp:
99
90
  """Release the native view registered under ``tag``.
@@ -126,5 +117,5 @@ class SetFrameOp:
126
117
  return (self.x, self.y, self.width, self.height)
127
118
 
128
119
 
129
- Mutation = Union[CreateOp, UpdateOp, InsertOp, RemoveOp, DestroyOp, SetFrameOp]
120
+ Mutation = Union[CreateOp, UpdateOp, InsertOp, DestroyOp, SetFrameOp]
130
121
  """Union of every op type carried by a commit transaction."""
@@ -37,7 +37,7 @@ import threading
37
37
  import time
38
38
  from typing import Any, Dict, Optional, Sequence, Tuple
39
39
 
40
- from ..mutations import CreateOp, DestroyOp, InsertOp, Mutation, RemoveOp, SetFrameOp, UpdateOp
40
+ from ..mutations import CreateOp, DestroyOp, InsertOp, Mutation, SetFrameOp, UpdateOp
41
41
  from .base import ViewHandler
42
42
 
43
43
  # ======================================================================
@@ -192,12 +192,6 @@ class NativeViewRegistry:
192
192
  if parent is not None and child is not None:
193
193
  parent.handler.insert_child(parent.view, child.view, op.index)
194
194
  return
195
- if isinstance(op, RemoveOp):
196
- parent = self._records.get(op.parent_tag)
197
- child = self._records.get(op.child_tag)
198
- if parent is not None and child is not None:
199
- parent.handler.remove_child(parent.view, child.view)
200
- return
201
195
  if isinstance(op, DestroyOp):
202
196
  record = self._records.pop(op.tag, None)
203
197
  if record is not None: