pythonnative 0.20.0__py3-none-any.whl → 0.22.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 +14 -3
- pythonnative/animated.py +420 -135
- pythonnative/cli/pn.py +450 -956
- pythonnative/components.py +519 -235
- pythonnative/events.py +210 -0
- pythonnative/gestures.py +875 -0
- pythonnative/layout.py +463 -149
- pythonnative/mutations.py +130 -0
- pythonnative/native_views/__init__.py +161 -97
- pythonnative/native_views/android.py +1050 -1124
- pythonnative/native_views/base.py +108 -18
- pythonnative/native_views/desktop.py +460 -417
- pythonnative/native_views/ios.py +1918 -1916
- pythonnative/project/__init__.py +68 -0
- pythonnative/project/android.py +504 -0
- pythonnative/project/builder.py +555 -0
- pythonnative/project/config.py +642 -0
- pythonnative/project/doctor.py +233 -0
- pythonnative/project/icons.py +247 -0
- pythonnative/project/ios.py +344 -0
- pythonnative/project/permissions.py +343 -0
- pythonnative/project/runtime_assets.py +272 -0
- pythonnative/reconciler.py +540 -470
- pythonnative/screen.py +5 -2
- pythonnative/sdk/_components.py +2 -2
- pythonnative/templates/android_template/app/build.gradle +2 -0
- {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/METADATA +10 -2
- {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/RECORD +32 -21
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNVirtualListView.java +0 -129
- {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/WHEEL +0 -0
- {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/licenses/LICENSE +0 -0
- {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Environment diagnostics for ``pn doctor``.
|
|
2
|
+
|
|
3
|
+
Inspects the local toolchain and the project's ``pythonnative.toml`` and
|
|
4
|
+
reports what's ready and what's missing for building on each platform —
|
|
5
|
+
analogous to ``flutter doctor`` / ``npx react-native doctor``. The checks
|
|
6
|
+
are deliberately read-only and fast; they shell out only to ask tools for
|
|
7
|
+
their versions.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import List, Optional
|
|
18
|
+
|
|
19
|
+
from . import icons
|
|
20
|
+
from .config import IOS_SUPPORTED_PYTHON_VERSION, SUPPORTED_PYTHON_VERSIONS, AppConfig, ConfigError
|
|
21
|
+
|
|
22
|
+
OK = "ok"
|
|
23
|
+
WARN = "warn"
|
|
24
|
+
ERROR = "error"
|
|
25
|
+
INFO = "info"
|
|
26
|
+
|
|
27
|
+
_SYMBOLS = {OK: "[ok]", WARN: "[!]", ERROR: "[x]", INFO: "[i]"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class CheckResult:
|
|
32
|
+
"""The outcome of a single diagnostic check.
|
|
33
|
+
|
|
34
|
+
Attributes:
|
|
35
|
+
name: Short label for the thing checked.
|
|
36
|
+
level: One of ``"ok"``, ``"warn"``, ``"error"``, ``"info"``.
|
|
37
|
+
detail: Human-readable detail / remediation hint.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
name: str
|
|
41
|
+
level: str
|
|
42
|
+
detail: str = ""
|
|
43
|
+
|
|
44
|
+
def format(self) -> str:
|
|
45
|
+
"""Return a single aligned line for terminal output."""
|
|
46
|
+
symbol = _SYMBOLS.get(self.level, "[?]")
|
|
47
|
+
suffix = f" — {self.detail}" if self.detail else ""
|
|
48
|
+
return f" {symbol} {self.name}{suffix}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _which_version(tool: str, version_args: List[str]) -> Optional[str]:
|
|
52
|
+
path = shutil.which(tool)
|
|
53
|
+
if not path:
|
|
54
|
+
return None
|
|
55
|
+
try:
|
|
56
|
+
out = subprocess.run([tool, *version_args], capture_output=True, text=True, timeout=20)
|
|
57
|
+
except Exception:
|
|
58
|
+
return path
|
|
59
|
+
text = (out.stdout or out.stderr or "").strip().splitlines()
|
|
60
|
+
return text[0] if text else path
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def check_common() -> List[CheckResult]:
|
|
64
|
+
"""Run platform-agnostic checks (interpreter, Pillow).
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Check results for the host Python and optional dependencies.
|
|
68
|
+
"""
|
|
69
|
+
results: List[CheckResult] = []
|
|
70
|
+
py_version = f"{sys.version_info.major}.{sys.version_info.minor}"
|
|
71
|
+
if py_version in SUPPORTED_PYTHON_VERSIONS:
|
|
72
|
+
results.append(CheckResult("Host Python", OK, f"{sys.version.split()[0]}"))
|
|
73
|
+
else:
|
|
74
|
+
results.append(
|
|
75
|
+
CheckResult(
|
|
76
|
+
"Host Python",
|
|
77
|
+
WARN,
|
|
78
|
+
f"{py_version} (PythonNative targets {', '.join(SUPPORTED_PYTHON_VERSIONS)})",
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
if icons.pillow_available():
|
|
82
|
+
results.append(CheckResult("Pillow (icon/splash generation)", OK))
|
|
83
|
+
else:
|
|
84
|
+
results.append(
|
|
85
|
+
CheckResult(
|
|
86
|
+
"Pillow (icon/splash generation)",
|
|
87
|
+
WARN,
|
|
88
|
+
"not installed; run: pip install 'pythonnative[build]'",
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
return results
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def check_android(config: Optional[AppConfig]) -> List[CheckResult]:
|
|
95
|
+
"""Run Android toolchain and signing checks.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
config: The loaded app config, or ``None`` if unavailable.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Android-specific check results.
|
|
102
|
+
"""
|
|
103
|
+
results: List[CheckResult] = []
|
|
104
|
+
adb = _which_version("adb", ["--version"])
|
|
105
|
+
results.append(CheckResult("adb (Android platform-tools)", OK if adb else WARN, adb or "not found on PATH"))
|
|
106
|
+
|
|
107
|
+
java_home = shutil.which("java")
|
|
108
|
+
import os
|
|
109
|
+
|
|
110
|
+
if os.environ.get("JAVA_HOME") or java_home:
|
|
111
|
+
results.append(CheckResult("Java (JDK 17 recommended)", OK, os.environ.get("JAVA_HOME") or java_home or ""))
|
|
112
|
+
else:
|
|
113
|
+
results.append(CheckResult("Java (JDK 17 recommended)", WARN, "JAVA_HOME not set and 'java' not on PATH"))
|
|
114
|
+
|
|
115
|
+
if config is not None:
|
|
116
|
+
signing = config.android.signing
|
|
117
|
+
if signing.is_configured:
|
|
118
|
+
keystore = config.resolve_path(signing.keystore) if signing.keystore else None
|
|
119
|
+
if keystore and keystore.is_file():
|
|
120
|
+
results.append(CheckResult("Android release keystore", OK, str(keystore)))
|
|
121
|
+
else:
|
|
122
|
+
results.append(CheckResult("Android release keystore", ERROR, f"not found: {keystore}"))
|
|
123
|
+
missing = [env for env in (signing.store_password_env, signing.key_password_env) if not os.environ.get(env)]
|
|
124
|
+
if missing:
|
|
125
|
+
results.append(CheckResult("Android signing passwords", WARN, f"unset env: {', '.join(missing)}"))
|
|
126
|
+
else:
|
|
127
|
+
results.append(
|
|
128
|
+
CheckResult(
|
|
129
|
+
"Android release signing",
|
|
130
|
+
INFO,
|
|
131
|
+
"not configured; release builds will be unsigned (set [android.signing])",
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
return results
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def check_ios(config: Optional[AppConfig]) -> List[CheckResult]:
|
|
138
|
+
"""Run iOS toolchain and signing checks.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
config: The loaded app config, or ``None`` if unavailable.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
iOS-specific check results.
|
|
145
|
+
"""
|
|
146
|
+
results: List[CheckResult] = []
|
|
147
|
+
if sys.platform != "darwin":
|
|
148
|
+
results.append(CheckResult("macOS (required for iOS)", ERROR, f"this is {sys.platform}; iOS builds need macOS"))
|
|
149
|
+
return results
|
|
150
|
+
|
|
151
|
+
xcodebuild = _which_version("xcodebuild", ["-version"])
|
|
152
|
+
results.append(CheckResult("Xcode (xcodebuild)", OK if xcodebuild else ERROR, xcodebuild or "not found on PATH"))
|
|
153
|
+
simctl = shutil.which("xcrun")
|
|
154
|
+
results.append(CheckResult("xcrun simctl (Simulators)", OK if simctl else WARN, simctl or "not found on PATH"))
|
|
155
|
+
|
|
156
|
+
if config is not None:
|
|
157
|
+
if config.python_version != IOS_SUPPORTED_PYTHON_VERSION:
|
|
158
|
+
results.append(
|
|
159
|
+
CheckResult(
|
|
160
|
+
"iOS embedded Python",
|
|
161
|
+
WARN,
|
|
162
|
+
f"app.python_version={config.python_version}; only "
|
|
163
|
+
f"{IOS_SUPPORTED_PYTHON_VERSION} has a pinned iOS build",
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
if config.ios.development_team:
|
|
167
|
+
results.append(CheckResult("iOS development team", OK, config.ios.development_team))
|
|
168
|
+
else:
|
|
169
|
+
results.append(
|
|
170
|
+
CheckResult(
|
|
171
|
+
"iOS development team",
|
|
172
|
+
INFO,
|
|
173
|
+
"not set; required for device builds (set [ios].development_team)",
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
return results
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def check_config(project_root: Path) -> tuple[Optional[AppConfig], List[CheckResult]]:
|
|
180
|
+
"""Load and validate the project config, returning it with a result.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
project_root: Directory expected to contain ``pythonnative.toml``.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
A tuple of the loaded config (or ``None``) and the check results.
|
|
187
|
+
"""
|
|
188
|
+
results: List[CheckResult] = []
|
|
189
|
+
try:
|
|
190
|
+
config = AppConfig.load(project_root)
|
|
191
|
+
except ConfigError as exc:
|
|
192
|
+
results.append(CheckResult("pythonnative.toml", ERROR, str(exc).splitlines()[0]))
|
|
193
|
+
return None, results
|
|
194
|
+
results.append(CheckResult("pythonnative.toml", OK, f"{config.app_id} (v{config.version})"))
|
|
195
|
+
return config, results
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def run_doctor(project_root: Path, *, platform: Optional[str] = None) -> List[CheckResult]:
|
|
199
|
+
"""Run all diagnostics for ``project_root``.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
project_root: The project directory.
|
|
203
|
+
platform: Restrict checks to ``"android"`` or ``"ios"``; ``None``
|
|
204
|
+
checks both.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
All check results in display order.
|
|
208
|
+
"""
|
|
209
|
+
config, results = check_config(project_root)
|
|
210
|
+
results.extend(check_common())
|
|
211
|
+
if platform in (None, "android"):
|
|
212
|
+
results.extend(check_android(config))
|
|
213
|
+
if platform in (None, "ios"):
|
|
214
|
+
results.extend(check_ios(config))
|
|
215
|
+
return results
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def worst_level(results: List[CheckResult]) -> str:
|
|
219
|
+
"""Return the most severe level among ``results``.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
results: The diagnostic results.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
``"error"`` if any error, else ``"warn"`` if any warning, else
|
|
226
|
+
``"ok"``.
|
|
227
|
+
"""
|
|
228
|
+
levels = {result.level for result in results}
|
|
229
|
+
if ERROR in levels:
|
|
230
|
+
return ERROR
|
|
231
|
+
if WARN in levels:
|
|
232
|
+
return WARN
|
|
233
|
+
return OK
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""App icon and splash image generation.
|
|
2
|
+
|
|
3
|
+
Given a single high-resolution source image per asset (a 1024x1024 icon
|
|
4
|
+
and an optional splash image), this module renders the per-platform,
|
|
5
|
+
per-density variants each toolchain expects:
|
|
6
|
+
|
|
7
|
+
- iOS: a single-size ``AppIcon.appiconset`` (Xcode resizes at build time)
|
|
8
|
+
and a ``Splash`` image set referenced by the generated launch screen.
|
|
9
|
+
- Android: ``mipmap-*`` launcher PNGs at every density (mdpi…xxxhdpi),
|
|
10
|
+
a circular round-icon variant, and a centered splash icon used by the
|
|
11
|
+
Android 12+ splash screen.
|
|
12
|
+
|
|
13
|
+
Image resizing uses [Pillow](https://python-pillow.org/), declared as the
|
|
14
|
+
``[build]`` optional dependency. When Pillow isn't installed every
|
|
15
|
+
function degrades gracefully: it returns ``False`` (and the caller keeps
|
|
16
|
+
the template's default assets) so a missing optional dependency never
|
|
17
|
+
breaks a build. ``pn doctor`` reports whether Pillow is available.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import shutil
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Dict, List, Optional, Tuple
|
|
27
|
+
|
|
28
|
+
# Android launcher icon sizes (px) per density bucket.
|
|
29
|
+
ANDROID_LAUNCHER_DENSITIES: Dict[str, int] = {
|
|
30
|
+
"mdpi": 48,
|
|
31
|
+
"hdpi": 72,
|
|
32
|
+
"xhdpi": 96,
|
|
33
|
+
"xxhdpi": 144,
|
|
34
|
+
"xxxhdpi": 192,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def pillow_available() -> bool:
|
|
39
|
+
"""Return whether Pillow can be imported.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
``True`` if ``PIL.Image`` imports, else ``False``.
|
|
43
|
+
"""
|
|
44
|
+
try:
|
|
45
|
+
import PIL.Image # noqa: F401
|
|
46
|
+
except Exception:
|
|
47
|
+
return False
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _open_rgba(source: Path) -> "object":
|
|
52
|
+
from PIL import Image
|
|
53
|
+
|
|
54
|
+
img = Image.open(source).convert("RGBA")
|
|
55
|
+
return img
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _resized(img: "object", size: int) -> "object":
|
|
59
|
+
from PIL import Image
|
|
60
|
+
|
|
61
|
+
return img.resize((size, size), Image.LANCZOS)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _circular(img: "object") -> "object":
|
|
65
|
+
"""Return a copy of a square image masked to a circle."""
|
|
66
|
+
from PIL import Image, ImageDraw
|
|
67
|
+
|
|
68
|
+
size = img.size[0]
|
|
69
|
+
mask = Image.new("L", (size, size), 0)
|
|
70
|
+
draw = ImageDraw.Draw(mask)
|
|
71
|
+
draw.ellipse((0, 0, size, size), fill=255)
|
|
72
|
+
out = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
|
73
|
+
out.paste(img, (0, 0), mask) # type: ignore[arg-type]
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def generate_ios_icons(source: Path, appiconset_dir: Path) -> bool:
|
|
78
|
+
"""Generate a single-size iOS ``AppIcon.appiconset``.
|
|
79
|
+
|
|
80
|
+
Writes ``icon-1024.png`` (a flattened, opaque 1024x1024 image — the
|
|
81
|
+
App Store rejects icons with alpha) and a ``Contents.json`` that
|
|
82
|
+
declares it as the universal iOS app icon. Xcode derives every other
|
|
83
|
+
size at build time.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
source: Path to the source icon image.
|
|
87
|
+
appiconset_dir: The ``AppIcon.appiconset`` directory to populate.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
``True`` if icons were written, ``False`` if Pillow is missing.
|
|
91
|
+
"""
|
|
92
|
+
if not pillow_available():
|
|
93
|
+
return False
|
|
94
|
+
from PIL import Image
|
|
95
|
+
|
|
96
|
+
appiconset_dir.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
img = _open_rgba(source)
|
|
98
|
+
icon = _resized(img, 1024)
|
|
99
|
+
flattened = Image.new("RGB", (1024, 1024), (255, 255, 255))
|
|
100
|
+
flattened.paste(icon, (0, 0), icon) # type: ignore[arg-type]
|
|
101
|
+
flattened.save(appiconset_dir / "icon-1024.png", format="PNG")
|
|
102
|
+
|
|
103
|
+
contents = {
|
|
104
|
+
"images": [
|
|
105
|
+
{
|
|
106
|
+
"idiom": "universal",
|
|
107
|
+
"platform": "ios",
|
|
108
|
+
"size": "1024x1024",
|
|
109
|
+
"filename": "icon-1024.png",
|
|
110
|
+
}
|
|
111
|
+
],
|
|
112
|
+
"info": {"author": "pythonnative", "version": 1},
|
|
113
|
+
}
|
|
114
|
+
(appiconset_dir / "Contents.json").write_text(json.dumps(contents, indent=2) + "\n", encoding="utf-8")
|
|
115
|
+
return True
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def generate_android_icons(source: Path, res_dir: Path) -> bool:
|
|
119
|
+
"""Generate Android launcher icons at every density.
|
|
120
|
+
|
|
121
|
+
Writes ``mipmap-<density>/ic_launcher.png`` and a circular
|
|
122
|
+
``ic_launcher_round.png`` for each density bucket, and removes the
|
|
123
|
+
adaptive ``mipmap-anydpi-v26`` definitions so the generated PNGs are
|
|
124
|
+
used directly (otherwise the template's vector adaptive icon would
|
|
125
|
+
win on API 26+).
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
source: Path to the source icon image.
|
|
129
|
+
res_dir: The Android ``res`` directory.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
``True`` if icons were written, ``False`` if Pillow is missing.
|
|
133
|
+
"""
|
|
134
|
+
if not pillow_available():
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
img = _open_rgba(source)
|
|
138
|
+
for density, size in ANDROID_LAUNCHER_DENSITIES.items():
|
|
139
|
+
mip_dir = res_dir / f"mipmap-{density}"
|
|
140
|
+
mip_dir.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
square = _resized(img, size)
|
|
142
|
+
square.save(mip_dir / "ic_launcher.png", format="PNG")
|
|
143
|
+
round_icon = _circular(square)
|
|
144
|
+
round_icon.save(mip_dir / "ic_launcher_round.png", format="PNG")
|
|
145
|
+
|
|
146
|
+
# Drop adaptive XML icons so the raster mipmaps above are authoritative.
|
|
147
|
+
anydpi = res_dir / "mipmap-anydpi-v26"
|
|
148
|
+
if anydpi.is_dir():
|
|
149
|
+
shutil.rmtree(anydpi, ignore_errors=True)
|
|
150
|
+
return True
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def generate_ios_splash(source: Path, imageset_dir: Path) -> bool:
|
|
154
|
+
"""Generate an iOS ``Splash`` image set from a source image.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
source: Path to the splash image.
|
|
158
|
+
imageset_dir: The ``Splash.imageset`` directory to populate.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
``True`` if the image set was written, ``False`` if Pillow is
|
|
162
|
+
missing.
|
|
163
|
+
"""
|
|
164
|
+
if not pillow_available():
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
imageset_dir.mkdir(parents=True, exist_ok=True)
|
|
168
|
+
img = _open_rgba(source)
|
|
169
|
+
img.save(imageset_dir / "splash.png", format="PNG")
|
|
170
|
+
contents = {
|
|
171
|
+
"images": [{"idiom": "universal", "filename": "splash.png"}],
|
|
172
|
+
"info": {"author": "pythonnative", "version": 1},
|
|
173
|
+
}
|
|
174
|
+
(imageset_dir / "Contents.json").write_text(json.dumps(contents, indent=2) + "\n", encoding="utf-8")
|
|
175
|
+
return True
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def generate_android_splash_icon(source: Path, dest: Path, size: int = 288) -> bool:
|
|
179
|
+
"""Render the centered icon used by the Android 12+ splash screen.
|
|
180
|
+
|
|
181
|
+
The Android splash screen draws this image centered on the splash
|
|
182
|
+
background color. A transparent square is recommended; the default
|
|
183
|
+
size (288 dp at xxxhdpi → ~864 px) matches Google's guidance.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
source: Path to the source splash (or icon) image.
|
|
187
|
+
dest: Destination PNG path.
|
|
188
|
+
size: Output edge length in pixels.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
``True`` if the image was written, ``False`` if Pillow is missing.
|
|
192
|
+
"""
|
|
193
|
+
if not pillow_available():
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
197
|
+
img = _open_rgba(source)
|
|
198
|
+
_resized(img, size).save(dest, format="PNG")
|
|
199
|
+
return True
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def dominant_background_color(source: Path) -> Optional[str]:
|
|
203
|
+
"""Best-effort estimate of a splash background color from an image.
|
|
204
|
+
|
|
205
|
+
Samples the image's corner pixels and returns the most common one as
|
|
206
|
+
a ``#RRGGBB`` hex string. Used as a default splash background when the
|
|
207
|
+
config doesn't specify one.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
source: Path to the splash image.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
A hex color string, or ``None`` if Pillow is missing or the
|
|
214
|
+
sampling fails.
|
|
215
|
+
"""
|
|
216
|
+
if not pillow_available():
|
|
217
|
+
return None
|
|
218
|
+
try:
|
|
219
|
+
img = _open_rgba(source)
|
|
220
|
+
width, height = img.size
|
|
221
|
+
corners: List[Tuple[int, int]] = [
|
|
222
|
+
(0, 0),
|
|
223
|
+
(width - 1, 0),
|
|
224
|
+
(0, height - 1),
|
|
225
|
+
(width - 1, height - 1),
|
|
226
|
+
]
|
|
227
|
+
counts: Dict[Tuple[int, int, int], int] = {}
|
|
228
|
+
for x, y in corners:
|
|
229
|
+
r, g, b, _a = img.getpixel((x, y))
|
|
230
|
+
key = (r, g, b)
|
|
231
|
+
counts[key] = counts.get(key, 0) + 1
|
|
232
|
+
r, g, b = max(counts, key=lambda k: counts[k])
|
|
233
|
+
return f"#{r:02X}{g:02X}{b:02X}"
|
|
234
|
+
except Exception:
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def has_source(path: Optional[Path]) -> bool:
|
|
239
|
+
"""Return whether ``path`` is a readable existing file.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
path: A candidate asset path, or ``None``.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
``True`` when the path is a file that exists on disk.
|
|
246
|
+
"""
|
|
247
|
+
return bool(path and os.path.isfile(path))
|