ren-ui-python 0.1.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.
- ren_ui/__init__.py +11 -0
- ren_ui/__main__.py +3 -0
- ren_ui/_native.py +70 -0
- ren_ui/app.py +152 -0
- ren_ui/build.py +256 -0
- ren_ui/cli.py +155 -0
- ren_ui/native/bridge.cpp +764 -0
- ren_ui/native/icon.cpp +79 -0
- ren_ui/native/launcher.cpp +64 -0
- ren_ui/project.py +209 -0
- ren_ui/py.typed +0 -0
- ren_ui/resources.py +30 -0
- ren_ui/system.py +70 -0
- ren_ui/widgets.py +380 -0
- ren_ui_python-0.1.0.dist-info/METADATA +83 -0
- ren_ui_python-0.1.0.dist-info/RECORD +20 -0
- ren_ui_python-0.1.0.dist-info/WHEEL +5 -0
- ren_ui_python-0.1.0.dist-info/entry_points.txt +3 -0
- ren_ui_python-0.1.0.dist-info/licenses/LICENSE +21 -0
- ren_ui_python-0.1.0.dist-info/top_level.txt +1 -0
ren_ui/native/icon.cpp
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Build-time PNG -> native 16/32-pixel app icons; no Python imaging dependency.
|
|
3
|
+
#include <Application.h>
|
|
4
|
+
#include <AppFileInfo.h>
|
|
5
|
+
#include <Bitmap.h>
|
|
6
|
+
#include <File.h>
|
|
7
|
+
#include <IconUtils.h>
|
|
8
|
+
#include <TranslationUtils.h>
|
|
9
|
+
#include <View.h>
|
|
10
|
+
#include <algorithm>
|
|
11
|
+
#include <cmath>
|
|
12
|
+
#include <cstdio>
|
|
13
|
+
#include <cstring>
|
|
14
|
+
#include <memory>
|
|
15
|
+
#include <stdexcept>
|
|
16
|
+
#include <string>
|
|
17
|
+
|
|
18
|
+
void check(status_t status, const char* operation)
|
|
19
|
+
{
|
|
20
|
+
if (status != B_OK)
|
|
21
|
+
throw std::runtime_error(std::string(operation) + ": " + strerror(status));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
void setIcon(BAppFileInfo& info, BBitmap* source, icon_size size)
|
|
25
|
+
{
|
|
26
|
+
BRect bounds(0, 0, size - 1, size - 1);
|
|
27
|
+
BBitmap rgba(bounds, B_BITMAP_ACCEPTS_VIEWS, B_RGBA32);
|
|
28
|
+
BBitmap indexed(bounds, B_CMAP8);
|
|
29
|
+
check(rgba.InitCheck(), "Create RGBA icon");
|
|
30
|
+
check(indexed.InitCheck(), "Create indexed icon");
|
|
31
|
+
memset(rgba.Bits(), 0, rgba.BitsLength());
|
|
32
|
+
auto* view = new BView(bounds, "icon", B_FOLLOW_NONE, 0);
|
|
33
|
+
rgba.AddChild(view);
|
|
34
|
+
if (!rgba.Lock()) throw std::runtime_error("Cannot lock icon bitmap");
|
|
35
|
+
float width = source->Bounds().Width() + 1;
|
|
36
|
+
float height = source->Bounds().Height() + 1;
|
|
37
|
+
float scale = std::min(float(size) / width, float(size) / height);
|
|
38
|
+
float w = std::max(1.f, std::floor(width * scale));
|
|
39
|
+
float h = std::max(1.f, std::floor(height * scale));
|
|
40
|
+
float x = std::floor((size - w) / 2), y = std::floor((size - h) / 2);
|
|
41
|
+
view->SetDrawingMode(B_OP_COPY);
|
|
42
|
+
view->SetHighColor(0, 0, 0, 0);
|
|
43
|
+
view->FillRect(bounds);
|
|
44
|
+
view->SetDrawingMode(B_OP_ALPHA);
|
|
45
|
+
view->SetBlendingMode(B_PIXEL_ALPHA, B_ALPHA_COMPOSITE);
|
|
46
|
+
view->DrawBitmap(source, source->Bounds(), BRect(x, y, x + w - 1, y + h - 1),
|
|
47
|
+
B_FILTER_BITMAP_BILINEAR);
|
|
48
|
+
view->Sync();
|
|
49
|
+
rgba.Unlock();
|
|
50
|
+
check(BIconUtils::ConvertToCMAP8(&rgba, &indexed), "Convert icon palette");
|
|
51
|
+
check(info.SetIcon(&indexed, size, false), "Write app icon");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
int main(int argc, char** argv)
|
|
55
|
+
{
|
|
56
|
+
if (argc != 3) return 2;
|
|
57
|
+
try {
|
|
58
|
+
BApplication app("application/x-vnd.ren-icon-builder");
|
|
59
|
+
check(app.InitCheck(), "Connect to Haiku app_server");
|
|
60
|
+
std::unique_ptr<BBitmap> source(BTranslationUtils::GetBitmapFile(argv[1]));
|
|
61
|
+
if (!source) throw std::runtime_error("Cannot decode PNG; check Haiku PNG Translator");
|
|
62
|
+
check(source->InitCheck(), "Decode PNG");
|
|
63
|
+
if (source->Bounds().Width() >= 4096 || source->Bounds().Height() >= 4096
|
|
64
|
+
|| source->BitsLength() > 64 * 1024 * 1024)
|
|
65
|
+
throw std::runtime_error("PNG icon exceeds 4096 pixels or 64 MiB");
|
|
66
|
+
BFile executable(argv[2], B_READ_WRITE);
|
|
67
|
+
check(executable.InitCheck(), "Open launcher");
|
|
68
|
+
BAppFileInfo info(&executable);
|
|
69
|
+
check(info.InitCheck(), "Open app metadata");
|
|
70
|
+
info.SetInfoLocation(B_USE_BOTH_LOCATIONS);
|
|
71
|
+
setIcon(info, source.get(), B_LARGE_ICON);
|
|
72
|
+
setIcon(info, source.get(), B_MINI_ICON);
|
|
73
|
+
puts("Generated PNG app icons: 32x32 and 16x16, resources and attributes");
|
|
74
|
+
return 0;
|
|
75
|
+
} catch (const std::exception& error) {
|
|
76
|
+
fprintf(stderr, "ren-icon: %s\n", error.what());
|
|
77
|
+
return 1;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Compiled per application. Resolve the actual image, including Deskbar symlinks.
|
|
3
|
+
#include <OS.h>
|
|
4
|
+
#include <image.h>
|
|
5
|
+
#include <Path.h>
|
|
6
|
+
#include <cstdio>
|
|
7
|
+
#include <cstdlib>
|
|
8
|
+
#include <dlfcn.h>
|
|
9
|
+
#include <string>
|
|
10
|
+
#include <vector>
|
|
11
|
+
#include <unistd.h>
|
|
12
|
+
|
|
13
|
+
#ifndef REN_APP_NAME
|
|
14
|
+
#error REN_APP_NAME must be supplied by the build tool
|
|
15
|
+
#endif
|
|
16
|
+
|
|
17
|
+
int main(int argc, char** argv)
|
|
18
|
+
{
|
|
19
|
+
image_info image;
|
|
20
|
+
int32 cookie = 0;
|
|
21
|
+
std::string executable;
|
|
22
|
+
while (get_next_image_info(B_CURRENT_TEAM, &cookie, &image) == B_OK) {
|
|
23
|
+
if (image.type == B_APP_IMAGE) {
|
|
24
|
+
executable = image.name;
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
BPath path(executable.c_str());
|
|
29
|
+
BPath apps;
|
|
30
|
+
BPath prefix;
|
|
31
|
+
if (executable.empty() || path.GetParent(&apps) != B_OK || apps.GetParent(&prefix) != B_OK) {
|
|
32
|
+
fprintf(stderr, "ren-ui: cannot locate application image\n");
|
|
33
|
+
return 1;
|
|
34
|
+
}
|
|
35
|
+
std::string root = std::string(prefix.Path()) + "/data/" REN_APP_NAME;
|
|
36
|
+
std::string pythonPath = root + "/runtime:" + root + "/app";
|
|
37
|
+
std::string entry = root + "/app/" REN_ENTRY;
|
|
38
|
+
if (setenv("PYTHONPATH", pythonPath.c_str(), 1) != 0
|
|
39
|
+
|| setenv("REN_UI_ASSET_ROOT", (root + "/app").c_str(), 1) != 0 || chdir((root + "/app").c_str()) != 0) {
|
|
40
|
+
perror("ren-ui: application directory");
|
|
41
|
+
return 1;
|
|
42
|
+
}
|
|
43
|
+
std::vector<char*> arguments;
|
|
44
|
+
arguments.push_back(const_cast<char*>(REN_PYTHON));
|
|
45
|
+
arguments.push_back(const_cast<char*>("-s"));
|
|
46
|
+
arguments.push_back(const_cast<char*>("-B"));
|
|
47
|
+
arguments.push_back(const_cast<char*>(entry.c_str()));
|
|
48
|
+
for (int i = 1; i < argc; ++i) arguments.push_back(argv[i]);
|
|
49
|
+
arguments.push_back(nullptr);
|
|
50
|
+
// Keep this executable as the application's image so Tracker/BRoster see
|
|
51
|
+
// its own signature and resources, instead of the generic Python binary.
|
|
52
|
+
void* python = dlopen(REN_PYTHON_LIBRARY, RTLD_NOW | RTLD_GLOBAL);
|
|
53
|
+
if (!python) {
|
|
54
|
+
fprintf(stderr, "ren-ui: cannot load %s: %s\n", REN_PYTHON_LIBRARY, dlerror());
|
|
55
|
+
return 1;
|
|
56
|
+
}
|
|
57
|
+
using PythonMain = int (*)(int, char**);
|
|
58
|
+
auto run = reinterpret_cast<PythonMain>(dlsym(python, "Py_BytesMain"));
|
|
59
|
+
if (!run) {
|
|
60
|
+
fprintf(stderr, "ren-ui: Python runtime has no Py_BytesMain\n");
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
63
|
+
return run(static_cast<int>(arguments.size()) - 1, arguments.data());
|
|
64
|
+
}
|
ren_ui/project.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Validated, explicit application manifests; no shell interpolation."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path, PurePosixPath
|
|
6
|
+
import re
|
|
7
|
+
import struct
|
|
8
|
+
import zlib
|
|
9
|
+
import tomllib
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ProjectError(ValueError):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _string(table, key, default=None):
|
|
17
|
+
value = table.get(key, default)
|
|
18
|
+
if not isinstance(value, str) or not value.strip() or any(ord(c) < 32 for c in value):
|
|
19
|
+
raise ProjectError(f"{key} must be a nonempty, single-line string")
|
|
20
|
+
return value
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _relative(value):
|
|
24
|
+
if not isinstance(value, str) or not value or "\\" in value or any(ord(c) < 32 for c in value):
|
|
25
|
+
raise ProjectError("Source paths must be nonempty relative POSIX paths")
|
|
26
|
+
path = PurePosixPath(value)
|
|
27
|
+
if path.is_absolute() or ".." in path.parts or str(path) == ".":
|
|
28
|
+
raise ProjectError(f"Path must stay within the project: {value}")
|
|
29
|
+
return path
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Project:
|
|
34
|
+
root: Path
|
|
35
|
+
name: str
|
|
36
|
+
version: str
|
|
37
|
+
revision: int
|
|
38
|
+
title: str
|
|
39
|
+
signature: str
|
|
40
|
+
summary: str
|
|
41
|
+
description: str
|
|
42
|
+
author: str
|
|
43
|
+
license: str
|
|
44
|
+
license_file: str
|
|
45
|
+
entry: str
|
|
46
|
+
include: tuple[str, ...]
|
|
47
|
+
requires: tuple[str, ...]
|
|
48
|
+
icon: str | None = None
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def package_version(self):
|
|
52
|
+
return f"{self.version}-{self.revision}"
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def load(cls, root: Path):
|
|
56
|
+
root = root.resolve()
|
|
57
|
+
try:
|
|
58
|
+
data = tomllib.loads((root / "ren.toml").read_text(encoding="utf-8"))
|
|
59
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
60
|
+
raise ProjectError(f"Cannot read {root / 'ren.toml'}: {exc}") from exc
|
|
61
|
+
unknown = set(data) - {"app", "build"}
|
|
62
|
+
if unknown:
|
|
63
|
+
raise ProjectError(f"Unknown manifest tables: {', '.join(sorted(unknown))}")
|
|
64
|
+
app, build = data.get("app", {}), data.get("build", {})
|
|
65
|
+
if not isinstance(app, dict) or not isinstance(build, dict):
|
|
66
|
+
raise ProjectError("app and build must be TOML tables")
|
|
67
|
+
unknown = set(app) - {"name", "version", "revision", "title", "signature", "summary", "description", "author", "license", "license-file", "entry", "icon"}
|
|
68
|
+
unknown |= set(build) - {"include", "requires"}
|
|
69
|
+
if unknown:
|
|
70
|
+
raise ProjectError(f"Unknown manifest keys: {', '.join(sorted(unknown))}")
|
|
71
|
+
name = _string(app, "name")
|
|
72
|
+
if not re.fullmatch(r"[a-z][a-z0-9_]*", name):
|
|
73
|
+
raise ProjectError("name must start with a lowercase letter and contain only a-z, 0-9, _")
|
|
74
|
+
version = _string(app, "version", "0.1.0")
|
|
75
|
+
if not re.fullmatch(r"[0-9]+(?:\.[0-9]+){0,3}", version):
|
|
76
|
+
raise ProjectError("version must contain 1–4 numeric components, e.g. 1.2.0")
|
|
77
|
+
revision = app.get("revision", 1)
|
|
78
|
+
if type(revision) is not int or revision < 1:
|
|
79
|
+
raise ProjectError("revision must be a positive integer")
|
|
80
|
+
signature = _string(app, "signature", f"application/x-vnd.ren-{name}")
|
|
81
|
+
if not re.fullmatch(r"application/[A-Za-z0-9][A-Za-z0-9.+_-]*", signature):
|
|
82
|
+
raise ProjectError("signature must be an application MIME type")
|
|
83
|
+
title = _string(app, "title", name)
|
|
84
|
+
if title in (".", "..") or "/" in title or "\\" in title:
|
|
85
|
+
raise ProjectError("title must be a single Deskbar filename without slashes")
|
|
86
|
+
license_name = _string(app, "license", "MIT")
|
|
87
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.+-]*", license_name):
|
|
88
|
+
raise ProjectError("license must be a license identifier such as MIT")
|
|
89
|
+
entry = str(_relative(_string(app, "entry", "main.py")))
|
|
90
|
+
if not entry.endswith(".py"):
|
|
91
|
+
raise ProjectError("entry must be a Python file")
|
|
92
|
+
license_file = str(_relative(_string(app, "license-file", "LICENSE")))
|
|
93
|
+
include = build.get("include", [entry])
|
|
94
|
+
requires = build.get("requires", [])
|
|
95
|
+
if not isinstance(include, list) or not include:
|
|
96
|
+
raise ProjectError("build.include must be a nonempty array of source paths")
|
|
97
|
+
include = tuple(str(_relative(path)) for path in include)
|
|
98
|
+
if not isinstance(requires, list) or not all(isinstance(item, str) and re.fullmatch(
|
|
99
|
+
r"[a-zA-Z0-9_:+.\-]+(?:\s+(?:>=|<=|==|!=|>|<|=)\s+[a-zA-Z0-9_.+~\-]+)?", item
|
|
100
|
+
) for item in requires):
|
|
101
|
+
raise ProjectError("build.requires must contain Haiku dependency expressions")
|
|
102
|
+
project = cls(root, name, version, revision, title, signature,
|
|
103
|
+
_string(app, "summary", f"{name} application"),
|
|
104
|
+
_string(app, "description", app.get("summary", f"{name} application")),
|
|
105
|
+
_string(app, "author", "Unknown author"), license_name, license_file,
|
|
106
|
+
entry, include, tuple(requires),
|
|
107
|
+
str(_relative(_string(app, "icon"))) if "icon" in app else None)
|
|
108
|
+
if project.icon is not None:
|
|
109
|
+
project.icon_bytes()
|
|
110
|
+
files = project.source_files()
|
|
111
|
+
if entry not in files:
|
|
112
|
+
raise ProjectError("entry must be included by build.include")
|
|
113
|
+
if not project.safe_path(license_file).is_file():
|
|
114
|
+
raise ProjectError(f"License file is missing: {license_file}")
|
|
115
|
+
return project
|
|
116
|
+
|
|
117
|
+
def icon_bytes(self) -> bytes:
|
|
118
|
+
if self.icon is None:
|
|
119
|
+
return b""
|
|
120
|
+
path = self.safe_path(self.icon)
|
|
121
|
+
suffix = path.suffix.lower()
|
|
122
|
+
if suffix not in {".hvif", ".png"} or not path.is_file():
|
|
123
|
+
raise ProjectError("app.icon must name an existing .hvif or .png file")
|
|
124
|
+
limit = 64 * 1024 if suffix == ".hvif" else 16 * 1024 * 1024
|
|
125
|
+
with path.open("rb") as file:
|
|
126
|
+
data = file.read(limit + 1)
|
|
127
|
+
if suffix == ".hvif":
|
|
128
|
+
if not 7 <= len(data) <= limit or not data.startswith(b"ncif"):
|
|
129
|
+
raise ProjectError("app.icon must be an HVIF file of at most 64 KiB")
|
|
130
|
+
else:
|
|
131
|
+
if (len(data) < 33 or len(data) > limit
|
|
132
|
+
or data[:8] != b"\x89PNG\r\n\x1a\n"
|
|
133
|
+
or data[8:16] != b"\x00\x00\x00\rIHDR"
|
|
134
|
+
or zlib.crc32(data[12:29]) != struct.unpack(">I", data[29:33])[0]):
|
|
135
|
+
raise ProjectError("app.icon must be a PNG file with a valid IHDR, at most 16 MiB")
|
|
136
|
+
width, height = struct.unpack(">II", data[16:24])
|
|
137
|
+
if not 1 <= width <= 4096 or not 1 <= height <= 4096:
|
|
138
|
+
raise ProjectError("PNG app.icon dimensions must be between 1 and 4096")
|
|
139
|
+
offset, pixels, ended = 8, False, False
|
|
140
|
+
while offset < len(data):
|
|
141
|
+
if offset + 12 > len(data):
|
|
142
|
+
raise ProjectError("PNG app.icon has a truncated chunk")
|
|
143
|
+
length = struct.unpack(">I", data[offset:offset + 4])[0]
|
|
144
|
+
end = offset + length + 12
|
|
145
|
+
kind = data[offset + 4:offset + 8]
|
|
146
|
+
if end > len(data) or zlib.crc32(data[offset + 4:end - 4]) != struct.unpack(">I", data[end - 4:end])[0]:
|
|
147
|
+
raise ProjectError("PNG app.icon has a truncated chunk or invalid CRC")
|
|
148
|
+
if kind == b"IHDR" and offset != 8:
|
|
149
|
+
raise ProjectError("PNG app.icon has duplicate IHDR chunks")
|
|
150
|
+
pixels |= kind == b"IDAT" and length > 0
|
|
151
|
+
offset = end
|
|
152
|
+
if kind == b"IEND":
|
|
153
|
+
ended = length == 0 and offset == len(data)
|
|
154
|
+
break
|
|
155
|
+
if not pixels or not ended:
|
|
156
|
+
raise ProjectError("PNG app.icon needs image data and a final IEND chunk")
|
|
157
|
+
|
|
158
|
+
return data
|
|
159
|
+
|
|
160
|
+
def safe_path(self, relative: str) -> Path:
|
|
161
|
+
relative = _relative(relative)
|
|
162
|
+
current = self.root
|
|
163
|
+
for part in relative.parts:
|
|
164
|
+
current /= part
|
|
165
|
+
if current.is_symlink():
|
|
166
|
+
raise ProjectError(f"Symlinked sources are not supported: {relative}")
|
|
167
|
+
if not current.resolve().is_relative_to(self.root):
|
|
168
|
+
raise ProjectError(f"Source escapes project: {relative}")
|
|
169
|
+
return current
|
|
170
|
+
|
|
171
|
+
def source_files(self) -> dict[str, Path]:
|
|
172
|
+
files = {}
|
|
173
|
+
excluded = {".git", ".venv", "__pycache__", ".ren-build", "dist", "build"}
|
|
174
|
+
def collect(relative):
|
|
175
|
+
path = self.safe_path(relative)
|
|
176
|
+
if path.is_dir():
|
|
177
|
+
for child in sorted(path.iterdir()):
|
|
178
|
+
if child.name not in excluded and not child.name.endswith((".pyc", ".pyo")):
|
|
179
|
+
collect(child.relative_to(self.root).as_posix())
|
|
180
|
+
elif path.is_file():
|
|
181
|
+
files[relative] = path
|
|
182
|
+
else:
|
|
183
|
+
raise ProjectError(f"Source is missing or not a regular file: {relative}")
|
|
184
|
+
for relative in self.include:
|
|
185
|
+
collect(relative)
|
|
186
|
+
if self.icon is not None:
|
|
187
|
+
collect(self.icon)
|
|
188
|
+
return files
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def quote(value: str) -> str:
|
|
192
|
+
"""Haiku PackageInfo and RDEF quoted string (not shell quoting)."""
|
|
193
|
+
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def package_info(project: Project, architecture: str, python_package: str, secondary: str = "") -> str:
|
|
197
|
+
suffix = f"_{secondary}" if secondary else ""
|
|
198
|
+
fields = {
|
|
199
|
+
"name": project.name, "version": project.package_version,
|
|
200
|
+
"architecture": architecture, "summary": quote(project.summary),
|
|
201
|
+
"description": quote(project.description), "packager": quote(project.author),
|
|
202
|
+
"vendor": quote(project.author), "copyrights": quote(project.author),
|
|
203
|
+
"licenses": quote(f"{project.name}_{project.license}"),
|
|
204
|
+
}
|
|
205
|
+
lines = [f"{key} {value}" for key, value in fields.items()]
|
|
206
|
+
lines += ["provides {", f" {project.name} = {project.package_version}", "}", "requires {",
|
|
207
|
+
f" haiku{suffix}", f" lib:libstdc++{suffix}", f" lib:libgcc_s{suffix}", f" {python_package}"]
|
|
208
|
+
lines += [f" {item}" for item in project.requires]
|
|
209
|
+
return "\n".join(lines + ["}", ""])
|
ren_ui/py.typed
ADDED
|
File without changes
|
ren_ui/resources.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Locate assets identically in a source project and an installed HPKG."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def asset_path(name: str | Path) -> Path:
|
|
10
|
+
"""Resolve a project-relative resource; ren run and the launcher set the root."""
|
|
11
|
+
path = Path(name)
|
|
12
|
+
if path.is_absolute() or '..' in path.parts:
|
|
13
|
+
raise ValueError("Asset name must be relative to the application directory")
|
|
14
|
+
root = os.environ.get("REN_UI_ASSET_ROOT")
|
|
15
|
+
if root is None:
|
|
16
|
+
entry = getattr(sys.modules.get("__main__"), "__file__", None)
|
|
17
|
+
if not entry:
|
|
18
|
+
raise RuntimeError("Use ren run or set REN_UI_ASSET_ROOT to the project directory")
|
|
19
|
+
root = str(Path(entry).resolve().parent)
|
|
20
|
+
return Path(root).resolve() / path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def image_path(source: str | Path) -> Path:
|
|
24
|
+
if not isinstance(source, (str, Path)) or not str(source) or '\0' in str(source):
|
|
25
|
+
raise ValueError("Image source must be a nonempty file path without NUL characters")
|
|
26
|
+
path = Path(source)
|
|
27
|
+
path = path if path.is_absolute() else asset_path(path)
|
|
28
|
+
if not path.is_file():
|
|
29
|
+
raise ValueError(f"Image file not found: {path}")
|
|
30
|
+
return path.resolve()
|
ren_ui/system.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Selected Application Kit and Storage Kit services (require an active app)."""
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .app import Application
|
|
6
|
+
from ._native import utf8
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _native():
|
|
10
|
+
app = Application._current
|
|
11
|
+
if app is None or app._closed:
|
|
12
|
+
raise RuntimeError("Show a window before using Haiku services")
|
|
13
|
+
app._check_ui_thread()
|
|
14
|
+
return app._native
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def alert(text: str, *, title="Message"):
|
|
18
|
+
_native().call("alert", utf8(title), utf8(text))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def confirm(text: str, *, title="Confirm", accept="OK", cancel="Cancel") -> bool:
|
|
22
|
+
"""Show a native two-button dialog; Escape/cancel returns False."""
|
|
23
|
+
return _native().call("confirm", utf8(title), utf8(text), utf8(accept), utf8(cancel)) == 1
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _file_panel(save: bool, title: str | None, directory: str | Path | None, name: str = "") -> Path | None:
|
|
27
|
+
start = ""
|
|
28
|
+
if directory is not None:
|
|
29
|
+
if not Path(directory).is_dir():
|
|
30
|
+
raise ValueError("directory must be an existing directory")
|
|
31
|
+
start = str(Path(directory).resolve())
|
|
32
|
+
if "/" in name:
|
|
33
|
+
raise ValueError("name must be a file name without a directory")
|
|
34
|
+
path = _native().call("file_panel", int(save), utf8(title or ""), utf8(start), utf8(name))
|
|
35
|
+
return Path(os.fsdecode(path)) if path else None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def open_file(*, title: str | None = None, directory: str | Path | None = None) -> Path | None:
|
|
39
|
+
"""Let the user pick an existing file in a native panel; None if cancelled."""
|
|
40
|
+
return _file_panel(False, title, directory)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def save_file(*, title: str | None = None, directory: str | Path | None = None, name: str = "") -> Path | None:
|
|
44
|
+
"""Let the user choose where to save; None if cancelled. Writes nothing."""
|
|
45
|
+
return _file_panel(True, title, directory, name)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def set_clipboard(text: str):
|
|
49
|
+
_native().call("clipboard_set", utf8(text))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_clipboard() -> str:
|
|
53
|
+
return _native().call("clipboard_get").decode("utf-8")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def open_url(url: str):
|
|
57
|
+
"""Ask BRoster to open an http, https or mailto URL."""
|
|
58
|
+
if url.split(":", 1)[0].lower() not in ("http", "https", "mailto"):
|
|
59
|
+
raise ValueError("Supported URL schemes: http, https, mailto")
|
|
60
|
+
_native().call("launch", utf8(url))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def settings_directory(app_name: str, *, create=True) -> Path:
|
|
64
|
+
if not app_name or app_name in (".", "..") or "/" in app_name or "\\" in app_name:
|
|
65
|
+
raise ValueError("app_name must be a single directory name")
|
|
66
|
+
utf8(app_name)
|
|
67
|
+
directory = Path(_native().call("directory", 0).decode("utf-8")) / app_name
|
|
68
|
+
if create:
|
|
69
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
return directory
|