pythonnative 0.13.0__py3-none-any.whl → 0.14.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 +1 -1
- pythonnative/hot_reload.py +153 -3
- pythonnative/native_views/__init__.py +72 -0
- pythonnative/native_views/android.py +32 -1
- pythonnative/native_views/ios.py +75 -6
- pythonnative/navigation.py +17 -4
- pythonnative/screen.py +47 -12
- {pythonnative-0.13.0.dist-info → pythonnative-0.14.0.dist-info}/METADATA +1 -1
- {pythonnative-0.13.0.dist-info → pythonnative-0.14.0.dist-info}/RECORD +13 -13
- {pythonnative-0.13.0.dist-info → pythonnative-0.14.0.dist-info}/WHEEL +0 -0
- {pythonnative-0.13.0.dist-info → pythonnative-0.14.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.13.0.dist-info → pythonnative-0.14.0.dist-info}/licenses/LICENSE +0 -0
- {pythonnative-0.13.0.dist-info → pythonnative-0.14.0.dist-info}/top_level.txt +0 -0
pythonnative/__init__.py
CHANGED
pythonnative/hot_reload.py
CHANGED
|
@@ -196,10 +196,19 @@ class ModuleReloader:
|
|
|
196
196
|
"""Reload changed Python modules on device and trigger a re-render.
|
|
197
197
|
|
|
198
198
|
Designed to be invoked from device-side glue when a hot-reload
|
|
199
|
-
push completes.
|
|
200
|
-
|
|
199
|
+
push completes. All public methods are static; the class holds a
|
|
200
|
+
single piece of process-wide state — the manifest version that
|
|
201
|
+
has most recently been applied to ``sys.modules`` — so that
|
|
202
|
+
multiple screen hosts polling the same manifest do not each
|
|
203
|
+
re-execute the user-app modules. The first host to see a new
|
|
204
|
+
version pays the ``reload_modules`` cost; subsequent hosts on the
|
|
205
|
+
same version refresh only their own reconciler tree against the
|
|
206
|
+
already-fresh modules.
|
|
201
207
|
"""
|
|
202
208
|
|
|
209
|
+
_last_reloaded_version: Optional[str] = None
|
|
210
|
+
_reload_lock = threading.Lock()
|
|
211
|
+
|
|
203
212
|
@staticmethod
|
|
204
213
|
def reload_module(module_name: str) -> bool:
|
|
205
214
|
"""Reload a single module by its dotted name.
|
|
@@ -254,6 +263,139 @@ class ModuleReloader:
|
|
|
254
263
|
reloaded.append(module_name)
|
|
255
264
|
return reloaded
|
|
256
265
|
|
|
266
|
+
@staticmethod
|
|
267
|
+
def reload_modules_for_version(
|
|
268
|
+
module_names: Sequence[str],
|
|
269
|
+
version: Optional[str],
|
|
270
|
+
) -> List[str]:
|
|
271
|
+
"""Reload ``module_names`` for ``version``, deduping across hosts.
|
|
272
|
+
|
|
273
|
+
Each native screen host on iOS / Android runs its own poll
|
|
274
|
+
loop and would otherwise call
|
|
275
|
+
[`reload_modules`][pythonnative.hot_reload.ModuleReloader.reload_modules]
|
|
276
|
+
independently for the same manifest version. That re-executes
|
|
277
|
+
every user-app module N times (once per host) per file change,
|
|
278
|
+
producing N different generations of the same function objects
|
|
279
|
+
in ``sys.modules`` and leaving each host's reconciler tree
|
|
280
|
+
pointing at a different generation. Beyond the wasted work,
|
|
281
|
+
the inconsistent state has been observed to crash UIKit on iOS
|
|
282
|
+
with ``CALayerInvalidGeometry`` (NaN values fed into ``setFrame_:``
|
|
283
|
+
during the interleaved renders).
|
|
284
|
+
|
|
285
|
+
This helper serializes on
|
|
286
|
+
[`_reload_lock`][pythonnative.hot_reload.ModuleReloader] and uses
|
|
287
|
+
[`_last_reloaded_version`][pythonnative.hot_reload.ModuleReloader]
|
|
288
|
+
to ensure only the *first* host to see a given ``version``
|
|
289
|
+
actually re-executes the modules. Subsequent hosts on the same
|
|
290
|
+
version get back the already-fresh entries from ``sys.modules``
|
|
291
|
+
so their own
|
|
292
|
+
[`refresh_in_place`][pythonnative.hot_reload.ModuleReloader.refresh_in_place]
|
|
293
|
+
pass can still rewrite their tree against the same generation.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
module_names: Dotted module names to reload.
|
|
297
|
+
version: Manifest version this reload is processing. When
|
|
298
|
+
``None`` (e.g. tests calling reload directly) the call
|
|
299
|
+
falls back to the unconditional
|
|
300
|
+
[`reload_modules`][pythonnative.hot_reload.ModuleReloader.reload_modules]
|
|
301
|
+
behavior.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
The list of module names that are currently fresh in
|
|
305
|
+
``sys.modules`` — either freshly reloaded by this call, or
|
|
306
|
+
already reloaded by an earlier host for the same version.
|
|
307
|
+
"""
|
|
308
|
+
with ModuleReloader._reload_lock:
|
|
309
|
+
if version is not None and version == ModuleReloader._last_reloaded_version:
|
|
310
|
+
return [name for name in module_names if name in sys.modules]
|
|
311
|
+
reloaded = ModuleReloader.reload_modules(module_names)
|
|
312
|
+
if reloaded and version is not None:
|
|
313
|
+
ModuleReloader._last_reloaded_version = version
|
|
314
|
+
return reloaded
|
|
315
|
+
|
|
316
|
+
@staticmethod
|
|
317
|
+
def expand_reload_targets(changed_modules: Sequence[str], component_path: str) -> List[str]:
|
|
318
|
+
"""Expand a manifest of changed modules into the full reload order.
|
|
319
|
+
|
|
320
|
+
When a user edits ``app/screens/home.py``, only that file is in
|
|
321
|
+
the manifest. But the entry-point module ``app.main`` has
|
|
322
|
+
bindings like ``from app.screens.home import HomeScreen`` that
|
|
323
|
+
need to be re-evaluated against the freshly-loaded
|
|
324
|
+
``app.screens.home``; likewise other user-app modules may carry
|
|
325
|
+
transitive bindings (e.g. through a shared ``app/theme.py``)
|
|
326
|
+
that go stale if only the changed file is reloaded.
|
|
327
|
+
|
|
328
|
+
This helper computes the full ordered reload list:
|
|
329
|
+
|
|
330
|
+
1. Explicitly changed modules first (in the order given), so
|
|
331
|
+
their fresh source replaces the cached version in
|
|
332
|
+
``sys.modules`` before any dependent modules re-execute.
|
|
333
|
+
2. All other currently-imported modules under the entry-point's
|
|
334
|
+
top-level package, deepest first. The depth heuristic biases
|
|
335
|
+
toward leaves so re-executing a screen file picks up the
|
|
336
|
+
newest shared utilities before the file that imports it does.
|
|
337
|
+
3. The entry-point module itself, last, so its
|
|
338
|
+
``from ... import`` bindings rebind against everything that
|
|
339
|
+
was refreshed in steps 1 and 2.
|
|
340
|
+
|
|
341
|
+
Modules outside the entry-point's top-level package
|
|
342
|
+
(``pythonnative.*``, stdlib, third-party) are never included;
|
|
343
|
+
framework code is not reloaded.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
changed_modules: Modules reported as changed by the host
|
|
347
|
+
file-watcher (already in dotted form).
|
|
348
|
+
component_path: The host's entry-point identifier, either a
|
|
349
|
+
module path (``"app.main"``) or a dotted attribute path
|
|
350
|
+
(``"app.main.RootScreen"``).
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
The ordered list of modules to feed to
|
|
354
|
+
[`reload_modules`][pythonnative.hot_reload.ModuleReloader.reload_modules].
|
|
355
|
+
"""
|
|
356
|
+
entry_module: Optional[str] = None
|
|
357
|
+
if component_path in sys.modules:
|
|
358
|
+
entry_module = component_path
|
|
359
|
+
elif "." in component_path:
|
|
360
|
+
parent = component_path.rsplit(".", 1)[0]
|
|
361
|
+
if parent in sys.modules:
|
|
362
|
+
entry_module = parent
|
|
363
|
+
|
|
364
|
+
app_prefix: Optional[str] = None
|
|
365
|
+
if entry_module:
|
|
366
|
+
app_prefix = entry_module.split(".")[0]
|
|
367
|
+
else:
|
|
368
|
+
for m in changed_modules:
|
|
369
|
+
if m:
|
|
370
|
+
app_prefix = m.split(".")[0]
|
|
371
|
+
break
|
|
372
|
+
|
|
373
|
+
app_modules: Set[str] = set()
|
|
374
|
+
if app_prefix:
|
|
375
|
+
for name in list(sys.modules):
|
|
376
|
+
if name == app_prefix or name.startswith(app_prefix + "."):
|
|
377
|
+
app_modules.add(name)
|
|
378
|
+
|
|
379
|
+
ordered: List[str] = []
|
|
380
|
+
seen: Set[str] = set()
|
|
381
|
+
for m in changed_modules:
|
|
382
|
+
if m and m not in seen:
|
|
383
|
+
ordered.append(m)
|
|
384
|
+
seen.add(m)
|
|
385
|
+
|
|
386
|
+
others = [m for m in app_modules if m not in seen and m != entry_module]
|
|
387
|
+
others.sort(key=lambda m: (-m.count("."), m))
|
|
388
|
+
for m in others:
|
|
389
|
+
ordered.append(m)
|
|
390
|
+
seen.add(m)
|
|
391
|
+
|
|
392
|
+
if entry_module:
|
|
393
|
+
if entry_module in seen:
|
|
394
|
+
ordered.remove(entry_module)
|
|
395
|
+
ordered.append(entry_module)
|
|
396
|
+
|
|
397
|
+
return ordered
|
|
398
|
+
|
|
257
399
|
@staticmethod
|
|
258
400
|
def file_to_module(file_path: str, base_dir: str = "") -> Optional[str]:
|
|
259
401
|
"""Convert a file path to a dotted module name.
|
|
@@ -491,5 +633,13 @@ class ModuleReloader:
|
|
|
491
633
|
files = manifest.get("files", [])
|
|
492
634
|
modules = ModuleReloader.modules_from_files(files if isinstance(files, list) else [])
|
|
493
635
|
|
|
494
|
-
|
|
636
|
+
# Stash the version on the host so `_reload_host` can dedupe
|
|
637
|
+
# `reload_modules` across multiple hosts polling the same
|
|
638
|
+
# manifest. See `reload_modules_for_version`.
|
|
639
|
+
previous_pending = getattr(screen_instance, "_hot_reload_pending_version", None)
|
|
640
|
+
try:
|
|
641
|
+
screen_instance._hot_reload_pending_version = version
|
|
642
|
+
ModuleReloader.reload_screen(screen_instance, [str(module) for module in modules])
|
|
643
|
+
finally:
|
|
644
|
+
screen_instance._hot_reload_pending_version = previous_pending
|
|
495
645
|
return version
|
|
@@ -23,10 +23,67 @@ A mock registry can be installed via
|
|
|
23
23
|
reconciler with no real native views.
|
|
24
24
|
"""
|
|
25
25
|
|
|
26
|
+
import math
|
|
27
|
+
import sys
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
26
30
|
from typing import Any, Dict, Optional, Tuple
|
|
27
31
|
|
|
28
32
|
from .base import ViewHandler
|
|
29
33
|
|
|
34
|
+
# ======================================================================
|
|
35
|
+
# Tripwire log rate limiter
|
|
36
|
+
# ======================================================================
|
|
37
|
+
#
|
|
38
|
+
# Defensive NaN/Inf guards in ``set_frame`` and ``_apply_transform`` log
|
|
39
|
+
# a single line per occurrence. That's fine for one-off events, but
|
|
40
|
+
# ``Animated.View`` drives transforms at ~60 Hz; once an
|
|
41
|
+
# ``Animated.Value`` enters a stuck NaN state (e.g., a spring tick
|
|
42
|
+
# corrupted across a Fast Refresh), the tripwire would otherwise emit
|
|
43
|
+
# thousands of identical lines per second and drown the dev console.
|
|
44
|
+
#
|
|
45
|
+
# We instead log the first occurrence immediately, then suppress
|
|
46
|
+
# further messages with the same ``label`` for
|
|
47
|
+
# ``_TRIPWIRE_RATE_LIMIT_S`` seconds, and append a
|
|
48
|
+
# ``(+N similar in last Xs)`` suffix to the next message that escapes
|
|
49
|
+
# the window. The first sample plus a count is enough to diagnose; the
|
|
50
|
+
# bounded log keeps the dev console usable.
|
|
51
|
+
|
|
52
|
+
_TRIPWIRE_RATE_LIMIT_S: float = 1.0
|
|
53
|
+
_TRIPWIRE_LOG_LOCK = threading.Lock()
|
|
54
|
+
_TRIPWIRE_LAST_LOG_TIME: Dict[str, float] = {}
|
|
55
|
+
_TRIPWIRE_SUPPRESSED_COUNT: Dict[str, int] = {}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _tripwire_log(label: str, message: str) -> None:
|
|
59
|
+
"""Emit ``message`` to stderr, rate-limited per ``label``.
|
|
60
|
+
|
|
61
|
+
The first call for a given ``label`` always emits. Calls within
|
|
62
|
+
``_TRIPWIRE_RATE_LIMIT_S`` seconds are silently counted. The next
|
|
63
|
+
call after the window appends ``(+N similar in last Xs)`` and
|
|
64
|
+
resets the counter.
|
|
65
|
+
"""
|
|
66
|
+
now = time.monotonic()
|
|
67
|
+
write = False
|
|
68
|
+
suppressed = 0
|
|
69
|
+
with _TRIPWIRE_LOG_LOCK:
|
|
70
|
+
last = _TRIPWIRE_LAST_LOG_TIME.get(label)
|
|
71
|
+
if last is None or now - last >= _TRIPWIRE_RATE_LIMIT_S:
|
|
72
|
+
write = True
|
|
73
|
+
suppressed = _TRIPWIRE_SUPPRESSED_COUNT.get(label, 0)
|
|
74
|
+
_TRIPWIRE_SUPPRESSED_COUNT[label] = 0
|
|
75
|
+
_TRIPWIRE_LAST_LOG_TIME[label] = now
|
|
76
|
+
else:
|
|
77
|
+
_TRIPWIRE_SUPPRESSED_COUNT[label] = _TRIPWIRE_SUPPRESSED_COUNT.get(label, 0) + 1
|
|
78
|
+
if not write:
|
|
79
|
+
return
|
|
80
|
+
if suppressed > 0:
|
|
81
|
+
message = f"{message} (+{suppressed} similar in last {_TRIPWIRE_RATE_LIMIT_S:g}s)"
|
|
82
|
+
try:
|
|
83
|
+
print(message, file=sys.stderr, flush=True)
|
|
84
|
+
except Exception:
|
|
85
|
+
pass
|
|
86
|
+
|
|
30
87
|
|
|
31
88
|
class NativeViewRegistry:
|
|
32
89
|
"""Map element type names to platform-specific view handlers.
|
|
@@ -136,6 +193,21 @@ class NativeViewRegistry:
|
|
|
136
193
|
coordinates computed by ``pythonnative.layout`` in points
|
|
137
194
|
relative to the parent's content origin.
|
|
138
195
|
"""
|
|
196
|
+
# Tripwire: log non-finite layout values so we can diagnose
|
|
197
|
+
# crashes like iOS `CALayerInvalidGeometry` without losing the
|
|
198
|
+
# repro. Handlers are responsible for clamping before applying.
|
|
199
|
+
# Rate-limited via ``_tripwire_log`` to avoid 60 Hz floods when
|
|
200
|
+
# an animated value is stuck at NaN.
|
|
201
|
+
try:
|
|
202
|
+
finite = math.isfinite(x) and math.isfinite(y) and math.isfinite(width) and math.isfinite(height)
|
|
203
|
+
except (TypeError, ValueError):
|
|
204
|
+
finite = False
|
|
205
|
+
if not finite:
|
|
206
|
+
_tripwire_log(
|
|
207
|
+
"set_frame:nan",
|
|
208
|
+
f"[set_frame:nan] type={type_name!r} " f"x={x!r} y={y!r} w={width!r} h={height!r}",
|
|
209
|
+
)
|
|
210
|
+
|
|
139
211
|
handler = self._handlers.get(type_name)
|
|
140
212
|
if handler is not None:
|
|
141
213
|
handler.set_frame(native_view, x, y, width, height)
|
|
@@ -1116,10 +1116,41 @@ class TabBarHandler(AndroidViewHandler):
|
|
|
1116
1116
|
menu.clear()
|
|
1117
1117
|
for i, item in enumerate(items):
|
|
1118
1118
|
title = item.get("title", item.get("name", ""))
|
|
1119
|
-
menu.add(0, i, i, str(title))
|
|
1119
|
+
menu_item = menu.add(0, i, i, str(title))
|
|
1120
|
+
res_id = self._resolve_icon(item.get("icon"))
|
|
1121
|
+
if res_id:
|
|
1122
|
+
try:
|
|
1123
|
+
menu_item.setIcon(res_id)
|
|
1124
|
+
except Exception:
|
|
1125
|
+
pass
|
|
1120
1126
|
except Exception:
|
|
1121
1127
|
pass
|
|
1122
1128
|
|
|
1129
|
+
def _resolve_icon(self, icon: Any) -> int:
|
|
1130
|
+
"""Resolve a tab icon spec to an `android.R.drawable.*` res id.
|
|
1131
|
+
|
|
1132
|
+
Accepts a bare string (treated as the drawable's field name on
|
|
1133
|
+
``android.R.drawable``) or a dict of the form
|
|
1134
|
+
``{"ios": "...", "android": "ic_menu_home"}``. Returns ``0``
|
|
1135
|
+
when the icon can't be resolved, which the caller treats as
|
|
1136
|
+
"no icon".
|
|
1137
|
+
"""
|
|
1138
|
+
if icon is None:
|
|
1139
|
+
return 0
|
|
1140
|
+
name: Any = None
|
|
1141
|
+
if isinstance(icon, str):
|
|
1142
|
+
name = icon
|
|
1143
|
+
elif isinstance(icon, dict):
|
|
1144
|
+
name = icon.get("android")
|
|
1145
|
+
if not name:
|
|
1146
|
+
return 0
|
|
1147
|
+
try:
|
|
1148
|
+
RDrawable = jclass("android.R$drawable")
|
|
1149
|
+
res_id = getattr(RDrawable, str(name), 0)
|
|
1150
|
+
return int(res_id) if res_id else 0
|
|
1151
|
+
except Exception:
|
|
1152
|
+
return 0
|
|
1153
|
+
|
|
1123
1154
|
def _set_active(self, bnv: Any, active: Any, items: list) -> None:
|
|
1124
1155
|
if active and items:
|
|
1125
1156
|
for i, item in enumerate(items):
|
pythonnative/native_views/ios.py
CHANGED
|
@@ -28,8 +28,28 @@ from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
|
28
28
|
|
|
29
29
|
from rubicon.objc import SEL, ObjCClass, objc_method
|
|
30
30
|
|
|
31
|
+
from . import _tripwire_log
|
|
31
32
|
from .base import ViewHandler, _safe_max, parse_color_int
|
|
32
33
|
|
|
34
|
+
|
|
35
|
+
def _safe_finite(value: Any, default: float = 0.0) -> float:
|
|
36
|
+
"""Coerce ``value`` to a finite float, falling back to ``default``.
|
|
37
|
+
|
|
38
|
+
Used as a defensive guard around every call into UIKit that takes a
|
|
39
|
+
geometry value. Without this, a single NaN or inf produced upstream
|
|
40
|
+
(layout edge case, stale prop during a reload, etc.) crashes the
|
|
41
|
+
process via `CALayerInvalidGeometry`. Clamping to ``default``
|
|
42
|
+
converts that into a recoverable visual glitch and lets the
|
|
43
|
+
`[set_frame:nan]` / `[set_transform:nan]` tripwire logs surface
|
|
44
|
+
where the bad value came from.
|
|
45
|
+
"""
|
|
46
|
+
try:
|
|
47
|
+
f = float(value)
|
|
48
|
+
except (TypeError, ValueError):
|
|
49
|
+
return default
|
|
50
|
+
return f if math.isfinite(f) else default
|
|
51
|
+
|
|
52
|
+
|
|
33
53
|
NSObject = ObjCClass("NSObject")
|
|
34
54
|
UIColor = ObjCClass("UIColor")
|
|
35
55
|
UIFont = ObjCClass("UIFont")
|
|
@@ -350,8 +370,32 @@ def _apply_transform(view: Any, props: Dict[str, Any]) -> None:
|
|
|
350
370
|
return
|
|
351
371
|
try:
|
|
352
372
|
transform = _make_transform(spec)
|
|
373
|
+
a = float(transform.a)
|
|
374
|
+
b = float(transform.b)
|
|
375
|
+
c = float(transform.c)
|
|
376
|
+
d = float(transform.d)
|
|
377
|
+
tx = float(transform.tx)
|
|
378
|
+
ty = float(transform.ty)
|
|
379
|
+
if not (
|
|
380
|
+
math.isfinite(a)
|
|
381
|
+
and math.isfinite(b)
|
|
382
|
+
and math.isfinite(c)
|
|
383
|
+
and math.isfinite(d)
|
|
384
|
+
and math.isfinite(tx)
|
|
385
|
+
and math.isfinite(ty)
|
|
386
|
+
):
|
|
387
|
+
# Tripwire: a NaN/inf transform crashes UIKit. Log
|
|
388
|
+
# (rate-limited to avoid 60 Hz spam from stuck Animated
|
|
389
|
+
# values) and fall back to identity so the app keeps
|
|
390
|
+
# running.
|
|
391
|
+
_tripwire_log(
|
|
392
|
+
"set_transform:nan",
|
|
393
|
+
f"[set_transform:nan] spec={spec!r} -> " f"(a={a!r}, b={b!r}, c={c!r}, d={d!r}, tx={tx!r}, ty={ty!r})",
|
|
394
|
+
)
|
|
395
|
+
view.setTransform_((1.0, 0.0, 0.0, 1.0, 0.0, 0.0))
|
|
396
|
+
return
|
|
353
397
|
# rubicon-objc accepts the C struct as a tuple of its fields.
|
|
354
|
-
view.setTransform_((
|
|
398
|
+
view.setTransform_((a, b, c, d, tx, ty))
|
|
355
399
|
except Exception:
|
|
356
400
|
pass
|
|
357
401
|
|
|
@@ -450,10 +494,10 @@ class IOSViewHandler(ViewHandler):
|
|
|
450
494
|
if native_view is None:
|
|
451
495
|
return
|
|
452
496
|
try:
|
|
453
|
-
frame_x =
|
|
454
|
-
frame_y =
|
|
455
|
-
frame_w =
|
|
456
|
-
frame_h =
|
|
497
|
+
frame_x = _safe_finite(x, 0.0)
|
|
498
|
+
frame_y = _safe_finite(y, 0.0)
|
|
499
|
+
frame_w = max(0.0, _safe_finite(width, 0.0))
|
|
500
|
+
frame_h = max(0.0, _safe_finite(height, 0.0))
|
|
457
501
|
native_view.setTranslatesAutoresizingMaskIntoConstraints_(True)
|
|
458
502
|
native_view.setFrame_(((frame_x, frame_y), (frame_w, frame_h)))
|
|
459
503
|
_clamp_view_corner_radius(native_view, frame_w, frame_h)
|
|
@@ -2301,13 +2345,38 @@ class TabBarHandler(IOSViewHandler):
|
|
|
2301
2345
|
|
|
2302
2346
|
def _set_bar_items(self, tab_bar: Any, items: list) -> None:
|
|
2303
2347
|
UITabBarItem = ObjCClass("UITabBarItem")
|
|
2348
|
+
UIImage = ObjCClass("UIImage")
|
|
2304
2349
|
bar_items = []
|
|
2305
2350
|
for i, item in enumerate(items):
|
|
2306
2351
|
title = item.get("title", item.get("name", ""))
|
|
2307
|
-
|
|
2352
|
+
image = self._resolve_icon(UIImage, item.get("icon"))
|
|
2353
|
+
bar_item = UITabBarItem.alloc().initWithTitle_image_tag_(str(title), image, i)
|
|
2308
2354
|
bar_items.append(bar_item)
|
|
2309
2355
|
tab_bar.setItems_animated_(bar_items, False)
|
|
2310
2356
|
|
|
2357
|
+
def _resolve_icon(self, UIImage: Any, icon: Any) -> Any:
|
|
2358
|
+
"""Resolve a tab icon spec to a UIImage, or return None.
|
|
2359
|
+
|
|
2360
|
+
Accepts a bare string (treated as an SF Symbol name) or a dict
|
|
2361
|
+
of the form ``{"ios": "house.fill", "android": "..."}``. SF
|
|
2362
|
+
Symbols are looked up via ``UIImage.systemImageNamed:``; names
|
|
2363
|
+
that don't resolve produce a text-only tab.
|
|
2364
|
+
"""
|
|
2365
|
+
if icon is None:
|
|
2366
|
+
return None
|
|
2367
|
+
name: Any = None
|
|
2368
|
+
if isinstance(icon, str):
|
|
2369
|
+
name = icon
|
|
2370
|
+
elif isinstance(icon, dict):
|
|
2371
|
+
name = icon.get("ios")
|
|
2372
|
+
if not name:
|
|
2373
|
+
return None
|
|
2374
|
+
try:
|
|
2375
|
+
image = UIImage.systemImageNamed_(str(name))
|
|
2376
|
+
return image if image else None
|
|
2377
|
+
except Exception:
|
|
2378
|
+
return None
|
|
2379
|
+
|
|
2311
2380
|
def _set_active(self, tab_bar: Any, active: Any, items: list) -> None:
|
|
2312
2381
|
if not active or not items:
|
|
2313
2382
|
return
|
pythonnative/navigation.py
CHANGED
|
@@ -570,10 +570,14 @@ def _tab_navigator_impl(screens: Any = None, initial_route: Optional[str] = None
|
|
|
570
570
|
if screen_def is None:
|
|
571
571
|
screen_def = screen_map[screen_list[0].name]
|
|
572
572
|
|
|
573
|
-
tab_items: List[Dict[str,
|
|
573
|
+
tab_items: List[Dict[str, Any]] = []
|
|
574
574
|
for s in screen_list:
|
|
575
575
|
if isinstance(s, _ScreenDef):
|
|
576
|
-
|
|
576
|
+
item: Dict[str, Any] = {"name": s.name, "title": s.options.get("title", s.name)}
|
|
577
|
+
icon = s.options.get("tab_bar_icon")
|
|
578
|
+
if icon is not None:
|
|
579
|
+
item["icon"] = icon
|
|
580
|
+
tab_items.append(item)
|
|
577
581
|
|
|
578
582
|
def on_tab_select(name: str) -> None:
|
|
579
583
|
switch_tab(name)
|
|
@@ -639,8 +643,17 @@ def create_tab_navigator() -> Any:
|
|
|
639
643
|
name: Route name and default tab title.
|
|
640
644
|
component: A `@component` function rendered when this
|
|
641
645
|
tab is active.
|
|
642
|
-
options: Optional per-screen settings
|
|
643
|
-
|
|
646
|
+
options: Optional per-screen settings. Recognized keys:
|
|
647
|
+
|
|
648
|
+
- `title` (str): Tab label.
|
|
649
|
+
- `tab_bar_icon` (str | dict): Native system icon
|
|
650
|
+
identifier. A string is used on every platform; a
|
|
651
|
+
dict like `{"ios": "house.fill", "android":
|
|
652
|
+
"ic_menu_home"}` selects per platform. iOS values
|
|
653
|
+
are resolved via SF Symbols
|
|
654
|
+
(`UIImage.systemImageNamed_`); Android values are
|
|
655
|
+
resolved against `android.R.drawable.<name>`.
|
|
656
|
+
Names that don't resolve fall back to text-only.
|
|
644
657
|
|
|
645
658
|
Returns:
|
|
646
659
|
A `_ScreenDef` consumed by `Navigator(...)`.
|
pythonnative/screen.py
CHANGED
|
@@ -341,6 +341,25 @@ def _hot_reload_tick(host: Any) -> bool:
|
|
|
341
341
|
if not manifest_exists and last is None:
|
|
342
342
|
return False
|
|
343
343
|
|
|
344
|
+
# The iOS template polls every 0.5s per UIViewController, so this
|
|
345
|
+
# tick fires several times per second per host. The per-tick log is
|
|
346
|
+
# gated behind ``PYTHONNATIVE_DEBUG`` to keep normal output quiet
|
|
347
|
+
# while preserving the breadcrumb when investigating reload races.
|
|
348
|
+
if _debug_enabled():
|
|
349
|
+
manifest_version: Optional[str] = None
|
|
350
|
+
if manifest_exists:
|
|
351
|
+
try:
|
|
352
|
+
with open(manifest_path, encoding="utf-8") as f:
|
|
353
|
+
raw_version = json.load(f).get("version", "")
|
|
354
|
+
manifest_version = str(raw_version) if raw_version else None
|
|
355
|
+
except Exception:
|
|
356
|
+
manifest_version = None
|
|
357
|
+
action = "reload" if (manifest_version is not None and manifest_version != last) else "skip"
|
|
358
|
+
_log_pn(
|
|
359
|
+
f"_hot_reload_tick: host=0x{id(host):x} component={host._component_path} "
|
|
360
|
+
f"last={last!r} manifest={manifest_version!r} action={action}"
|
|
361
|
+
)
|
|
362
|
+
|
|
344
363
|
next_version = ModuleReloader.reload_from_manifest(
|
|
345
364
|
host,
|
|
346
365
|
manifest_path,
|
|
@@ -348,10 +367,6 @@ def _hot_reload_tick(host: Any) -> bool:
|
|
|
348
367
|
)
|
|
349
368
|
if next_version == last:
|
|
350
369
|
return False
|
|
351
|
-
_log_pn(
|
|
352
|
-
f"_hot_reload_tick: triggered reload "
|
|
353
|
-
f"(manifest_exists={manifest_exists}, last={last!r}, next={next_version!r})"
|
|
354
|
-
)
|
|
355
370
|
host._hot_reload_last_version = next_version
|
|
356
371
|
return True
|
|
357
372
|
|
|
@@ -365,6 +380,19 @@ def _reload_host(host: Any, changed_modules: Optional[Sequence[str]] = None) ->
|
|
|
365
380
|
next render then runs the new bodies through the existing hook
|
|
366
381
|
slots, so component state survives.
|
|
367
382
|
|
|
383
|
+
The reload set is **expanded** to include every currently-imported
|
|
384
|
+
module under the entry-point's top-level package (see
|
|
385
|
+
[`expand_reload_targets`][pythonnative.hot_reload.ModuleReloader.expand_reload_targets]).
|
|
386
|
+
This catches transitive ``from ... import`` bindings that would
|
|
387
|
+
otherwise remain stale: if ``app/main.py`` does
|
|
388
|
+
``from app.screens.home import HomeScreen`` and the user edits
|
|
389
|
+
``home.py``, reloading just ``app.screens.home`` leaves
|
|
390
|
+
``app.main.HomeScreen`` pointing at the pre-edit function, so the
|
|
391
|
+
new render emits stale element types and the reconciler is forced
|
|
392
|
+
to unmount and remount the screen (losing state and showing old
|
|
393
|
+
code). Reloading every user-app module in dependency-friendly
|
|
394
|
+
order, with the entry-point last, keeps every binding fresh.
|
|
395
|
+
|
|
368
396
|
If Fast Refresh fails (the new module raised at import time, no
|
|
369
397
|
replacements could be located, or the next render itself
|
|
370
398
|
threw), the host falls back to a full remount: a brand-new
|
|
@@ -374,14 +402,20 @@ def _reload_host(host: Any, changed_modules: Optional[Sequence[str]] = None) ->
|
|
|
374
402
|
"""
|
|
375
403
|
from .hot_reload import ModuleReloader
|
|
376
404
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
405
|
+
requested = list(changed_modules or [])
|
|
406
|
+
targets = ModuleReloader.expand_reload_targets(requested, host._component_path)
|
|
407
|
+
|
|
408
|
+
pending_version = getattr(host, "_hot_reload_pending_version", None)
|
|
409
|
+
already_loaded = pending_version is not None and pending_version == ModuleReloader._last_reloaded_version
|
|
410
|
+
_log_pn(
|
|
411
|
+
f"_reload_host: host=0x{id(host):x} component={host._component_path} "
|
|
412
|
+
f"requested={requested!r} targets={len(targets)} version={pending_version!r} "
|
|
413
|
+
f"action={'reuse_modules' if already_loaded else 'reload_modules'}"
|
|
414
|
+
)
|
|
381
415
|
|
|
382
|
-
reloaded = ModuleReloader.
|
|
416
|
+
reloaded = ModuleReloader.reload_modules_for_version(targets, pending_version)
|
|
383
417
|
if not reloaded:
|
|
384
|
-
_log_pn(f"_reload_host: no modules could be reloaded from {
|
|
418
|
+
_log_pn(f"_reload_host: no modules could be reloaded from {targets!r}; aborting")
|
|
385
419
|
return
|
|
386
420
|
|
|
387
421
|
try:
|
|
@@ -392,10 +426,11 @@ def _reload_host(host: Any, changed_modules: Optional[Sequence[str]] = None) ->
|
|
|
392
426
|
host._component = new_component
|
|
393
427
|
|
|
394
428
|
if host._reconciler is None:
|
|
429
|
+
_log_pn(f"_reload_host: host=0x{id(host):x} reconciler=None; skipping refresh")
|
|
395
430
|
return
|
|
396
431
|
|
|
397
432
|
if _try_fast_refresh(host, reloaded):
|
|
398
|
-
print(f"[hot-reload] Fast Refresh: {', '.join(reloaded)}", file=sys.stderr)
|
|
433
|
+
print(f"[hot-reload] Fast Refresh: {', '.join(requested) or ', '.join(reloaded)}", file=sys.stderr)
|
|
399
434
|
return
|
|
400
435
|
|
|
401
436
|
_full_remount(host, reloaded)
|
|
@@ -1080,7 +1115,7 @@ else:
|
|
|
1080
1115
|
nav = getattr(self.native_instance, "navigationController", None)
|
|
1081
1116
|
if nav is None:
|
|
1082
1117
|
raise RuntimeError(
|
|
1083
|
-
"No UINavigationController available;
|
|
1118
|
+
"No UINavigationController available; ensure template embeds root in navigation controller"
|
|
1084
1119
|
)
|
|
1085
1120
|
nav.pushViewController_animated_(next_vc, True)
|
|
1086
1121
|
|
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
pythonnative/__init__.py,sha256=
|
|
1
|
+
pythonnative/__init__.py,sha256=UWwcP6GyJyrIY7M3dd9zlYGLuFRql5MAEz1UB7lljvU,4121
|
|
2
2
|
pythonnative/_ios_log.py,sha256=Oi7V28VxcVoZyrpAirvLeEmUW18McqnU87V4d37Zzlw,2582
|
|
3
3
|
pythonnative/alerts.py,sha256=uU_b1rHGyxm1_hUzXhmTiB85oe7PZ0pWcWSzL9NVKYY,3588
|
|
4
4
|
pythonnative/animated.py,sha256=0H3y7ZAtS4Lu9hqvCz1LTCORep57kb6S9ebyRoxO6-A,22051
|
|
5
5
|
pythonnative/components.py,sha256=Pzyuu3qlqYb139-yeOLa290DkckgQZUVwsoGi1L8Gxk,44228
|
|
6
6
|
pythonnative/element.py,sha256=W9varJj0Cl9HpckL8BcsC1u4ryUQOPVMrvetro4ilAE,2725
|
|
7
7
|
pythonnative/hooks.py,sha256=j2FX7-66a-TK9ZlUsgoRyY8A484UgBzUJavGxaupgaI,27451
|
|
8
|
-
pythonnative/hot_reload.py,sha256=
|
|
8
|
+
pythonnative/hot_reload.py,sha256=j7z2c7o2Hdoyd-p4nQY15LTW7CBH_1z0TSAzLCer-aA,25036
|
|
9
9
|
pythonnative/layout.py,sha256=-Wrvj4eHtQXqa9kn26ktKLAZVH4VMc_WuoCxV67UnQw,34994
|
|
10
|
-
pythonnative/navigation.py,sha256=
|
|
10
|
+
pythonnative/navigation.py,sha256=BtmdAKHocAF3ub7PewkGJYn8Rxy3GdV3serYnU0TMWk,33282
|
|
11
11
|
pythonnative/platform.py,sha256=jEya1KTDc3WfwpmrQkk3DIFyt7CWO4Vc3pej_wDiSR8,4629
|
|
12
12
|
pythonnative/platform_metrics.py,sha256=m2u8M8x52n5THNsYdspcaI9mlWWMbfSJWai1svjD0NM,8976
|
|
13
13
|
pythonnative/reconciler.py,sha256=4z-55fYkQJlCqtxfDGlrI8MY2KxXgZxsJSB20d42acw,37898
|
|
14
|
-
pythonnative/screen.py,sha256
|
|
14
|
+
pythonnative/screen.py,sha256=-8m_L5QQxpMrX80j4XR2IjW2UtSKf9UCeggOHOK-RjY,55123
|
|
15
15
|
pythonnative/style.py,sha256=8Bl1Ge8OrR-D9x2nAAGaW5VhbPCNQDWKbDEGJMWbGeQ,5758
|
|
16
16
|
pythonnative/utils.py,sha256=pQSxa3QW06_Y9JzBSnK0g0eMV1VhXww8Qym6HPOGzgM,6064
|
|
17
17
|
pythonnative/cli/__init__.py,sha256=NM1psvKe8jT0vzp8Ak4MMoygZz4P_msk5g-YEsY8xLk,232
|
|
@@ -21,10 +21,10 @@ pythonnative/native_modules/camera.py,sha256=EYSFcZSJbxRZz044ODzKHZ73CY6I3O7mQhU
|
|
|
21
21
|
pythonnative/native_modules/file_system.py,sha256=NOaz1pM0XL3Ptu0Agg4v2XcsO8SLuOrgJGWpYKQu5_E,7505
|
|
22
22
|
pythonnative/native_modules/location.py,sha256=Z4LBPphh20gwY88gxXkBfiJXXqFrFfiXQjDpjUNSTgE,8397
|
|
23
23
|
pythonnative/native_modules/notifications.py,sha256=OIKleiiXXKscWTuobG9DTx18rxpIMLIFcTceSYgAgFY,6639
|
|
24
|
-
pythonnative/native_views/__init__.py,sha256=
|
|
25
|
-
pythonnative/native_views/android.py,sha256=
|
|
24
|
+
pythonnative/native_views/__init__.py,sha256=N4XS-spTLWuWeyPcGwY7ZHIP2jhqXYTHZIfsni8I_Eg,9989
|
|
25
|
+
pythonnative/native_views/android.py,sha256=jedaq4PzmUw2Fyqy8H9l-xmiT7thxTGm2da8J2NEy-0,64235
|
|
26
26
|
pythonnative/native_views/base.py,sha256=84YLJDouGcEvAZlfwBicv-DesRDnfkw1Go1t7udw6jM,8965
|
|
27
|
-
pythonnative/native_views/ios.py,sha256=
|
|
27
|
+
pythonnative/native_views/ios.py,sha256=nyQ61pZ6ATfePxvTOrNAqTBA1ZHObKg_IHcWK3zWWzY,96412
|
|
28
28
|
pythonnative/templates/android_template/build.gradle,sha256=4gE6CRS6RuBu9kp-_e_uYYU9mBgHVZrqQg9caSxgyuc,352
|
|
29
29
|
pythonnative/templates/android_template/gradle.properties,sha256=REPaKLRfQiiVfIV8wYmgwzPWvF1f3bhh_kAMV9p4HME,1358
|
|
30
30
|
pythonnative/templates/android_template/gradlew,sha256=YxNShxF6Hm0SyEWA8fScYdG6AiGOzShmBgXpf5dufWU,5766
|
|
@@ -77,9 +77,9 @@ pythonnative/templates/ios_template/ios_template.xcodeproj/project.xcworkspace/x
|
|
|
77
77
|
pythonnative/templates/ios_template/ios_templateTests/ios_templateTests.swift,sha256=YnwzZx7yXB13xKAXEGNgz17VuhWeqkHTRTtBJ2Vu3_E,1238
|
|
78
78
|
pythonnative/templates/ios_template/ios_templateUITests/ios_templateUITests.swift,sha256=l2Pwa50F_rv-qPu2go6e4bQernM6PTQJeNPFl_c4ivY,1387
|
|
79
79
|
pythonnative/templates/ios_template/ios_templateUITests/ios_templateUITestsLaunchTests.swift,sha256=f5JrG0uVtLMeJQy26Yyz7Om-JUkT220osqcbeIVkj2g,815
|
|
80
|
-
pythonnative-0.
|
|
81
|
-
pythonnative-0.
|
|
82
|
-
pythonnative-0.
|
|
83
|
-
pythonnative-0.
|
|
84
|
-
pythonnative-0.
|
|
85
|
-
pythonnative-0.
|
|
80
|
+
pythonnative-0.14.0.dist-info/licenses/LICENSE,sha256=A69iG7TIAe6KkGQf6xoVHkc5JSZtOr5eRSvC5iuivnI,1067
|
|
81
|
+
pythonnative-0.14.0.dist-info/METADATA,sha256=OF1hl45kye50u38UrjPwmqDwWF2SatVWqucH4XlasWg,7453
|
|
82
|
+
pythonnative-0.14.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
83
|
+
pythonnative-0.14.0.dist-info/entry_points.txt,sha256=iUtDawWSAJAEyWTycpZxDuYz73ol31butpzDIEAgPO0,48
|
|
84
|
+
pythonnative-0.14.0.dist-info/top_level.txt,sha256=kT4SEATY2ywzrZ2Pgea6_zxyym44Q_PbOsUoOYjJLFE,13
|
|
85
|
+
pythonnative-0.14.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|