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.
- pythonnative/__init__.py +43 -50
- pythonnative/animated.py +1 -1
- pythonnative/appearance.py +124 -0
- pythonnative/cli/pn.py +3 -3
- pythonnative/components.py +873 -466
- pythonnative/diagnostics.py +214 -0
- pythonnative/element.py +5 -2
- pythonnative/events.py +13 -8
- pythonnative/hooks.py +454 -49
- pythonnative/hot_reload.py +9 -1
- pythonnative/images.py +196 -0
- pythonnative/mutations.py +3 -12
- pythonnative/native_views/__init__.py +1 -7
- pythonnative/native_views/android.py +651 -46
- pythonnative/native_views/desktop.py +116 -20
- pythonnative/native_views/ios.py +974 -52
- pythonnative/navigation.py +41 -17
- pythonnative/preview.py +74 -7
- pythonnative/project/config.py +1 -9
- pythonnative/reconciler.py +863 -441
- pythonnative/screen.py +409 -27
- pythonnative/storage.py +3 -3
- pythonnative/style.py +148 -6
- pythonnative/templates/android_template/app/src/main/AndroidManifest.xml +2 -1
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNAccessibilityDelegate.kt +47 -0
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNBorderDrawable.kt +67 -0
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNVirtualListView.java +172 -0
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/ScreenFragment.kt +22 -0
- pythonnative/virtual_rows.py +137 -0
- {pythonnative-0.22.1.dist-info → pythonnative-0.24.0.dist-info}/METADATA +5 -4
- {pythonnative-0.22.1.dist-info → pythonnative-0.24.0.dist-info}/RECORD +35 -28
- {pythonnative-0.22.1.dist-info → pythonnative-0.24.0.dist-info}/WHEEL +1 -1
- {pythonnative-0.22.1.dist-info → pythonnative-0.24.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.22.1.dist-info → pythonnative-0.24.0.dist-info}/licenses/LICENSE +0 -0
- {pythonnative-0.22.1.dist-info → pythonnative-0.24.0.dist-info}/top_level.txt +0 -0
pythonnative/hot_reload.py
CHANGED
|
@@ -576,7 +576,10 @@ class ModuleReloader:
|
|
|
576
576
|
if getattr(vnode, "element", None) is not None:
|
|
577
577
|
rewrite_element_tree(vnode.element)
|
|
578
578
|
rendered = getattr(vnode, "_rendered", None)
|
|
579
|
-
if rendered
|
|
579
|
+
if isinstance(rendered, (list, tuple)):
|
|
580
|
+
for rendered_el in rendered:
|
|
581
|
+
rewrite_element_tree(rendered_el)
|
|
582
|
+
elif rendered is not None:
|
|
580
583
|
rewrite_element_tree(rendered)
|
|
581
584
|
for child in getattr(vnode, "children", []) or []:
|
|
582
585
|
visit(child)
|
|
@@ -598,6 +601,11 @@ class ModuleReloader:
|
|
|
598
601
|
if not replacement_map:
|
|
599
602
|
return False
|
|
600
603
|
rewrites = ModuleReloader.swap_components_in_tree(reconciler, replacement_map)
|
|
604
|
+
if rewrites > 0 and hasattr(reconciler, "reset_hook_signatures"):
|
|
605
|
+
# New component bodies may legitimately call a different
|
|
606
|
+
# hook sequence; forget the recorded signatures so the
|
|
607
|
+
# dev-mode order guard doesn't flag the refresh itself.
|
|
608
|
+
reconciler.reset_hook_signatures()
|
|
601
609
|
return rewrites > 0
|
|
602
610
|
|
|
603
611
|
@staticmethod
|
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
|
|
26
|
-
|
|
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,
|
|
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,
|
|
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:
|