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/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Native Haiku UI. Importing and composing widgets works on any host."""
|
|
2
|
+
|
|
3
|
+
from .app import Application, Event
|
|
4
|
+
from .resources import asset_path
|
|
5
|
+
from .widgets import Button, CheckBox, Column, Image, Label, Row, Slider, TextArea, TextInput, Window
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Application", "Event", "Image", "asset_path", "Window", "Column", "Row", "Label", "Button",
|
|
10
|
+
"TextInput", "TextArea", "CheckBox", "Slider",
|
|
11
|
+
]
|
ren_ui/__main__.py
ADDED
ren_ui/_native.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Versioned ctypes boundary. CDLL releases the GIL before looper locks."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import ctypes as C
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class NativeError(RuntimeError):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
CALLBACK = C.CFUNCTYPE(None, C.c_int64, C.c_int64, C.c_char_p, C.c_char_p, C.c_int)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def utf8(value: str) -> bytes:
|
|
18
|
+
if not isinstance(value, str) or "\0" in value:
|
|
19
|
+
raise ValueError("Expected a string without NUL characters")
|
|
20
|
+
return value.encode("utf-8")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Native:
|
|
24
|
+
def __init__(self):
|
|
25
|
+
if not sys.platform.startswith("haiku"):
|
|
26
|
+
raise NativeError("Native UI requires Haiku OS. Use 'ren doctor' to check your environment.")
|
|
27
|
+
location = os.environ.get("REN_UI_LIBRARY")
|
|
28
|
+
path = Path(location) if location else Path(__file__).with_name("libren_ui.so")
|
|
29
|
+
if not path.is_file():
|
|
30
|
+
raise NativeError("Native bridge not found. Run 'ren native' or launch with 'ren run'.")
|
|
31
|
+
try:
|
|
32
|
+
self.lib = C.CDLL(str(path.resolve()))
|
|
33
|
+
except OSError as exc:
|
|
34
|
+
raise NativeError(f"Cannot load {path}: {exc}") from exc
|
|
35
|
+
self.lib.ren_abi.restype = C.c_int
|
|
36
|
+
self.lib.ren_abi.argtypes = []
|
|
37
|
+
if self.lib.ren_abi() != 5:
|
|
38
|
+
raise NativeError("Native bridge ABI mismatch; rebuild with 'ren native'.")
|
|
39
|
+
i, q, p, s, f = C.c_int, C.c_int64, C.c_void_p, C.c_char_p, C.c_float
|
|
40
|
+
signatures = {
|
|
41
|
+
"abi": (i, []), "error": (s, []),
|
|
42
|
+
"app_create": (p, [s, CALLBACK]), "app_run": (i, [p]),
|
|
43
|
+
"app_quit": (i, [p]), "app_destroy": (i, [p]),
|
|
44
|
+
"window_create": (q, [p, s, f, f, i]),
|
|
45
|
+
"window_show": (i, [p, q]), "window_close": (i, [p, q]),
|
|
46
|
+
"window_close_reply": (i, [p, q, i]),
|
|
47
|
+
"window_title": (i, [p, q, s]),
|
|
48
|
+
"view_create": (q, [p, q, q, s, s, s, f, f]),
|
|
49
|
+
"image_source": (i, [p, q, q, s]),
|
|
50
|
+
"button_icon": (i, [p, q, q, s, i]),
|
|
51
|
+
"view_set_text": (i, [p, q, q, s]),
|
|
52
|
+
"view_text": (s, [p, q, q]),
|
|
53
|
+
"view_set_value": (i, [p, q, q, i]), "view_value": (i, [p, q, q, C.POINTER(i)]),
|
|
54
|
+
"view_enabled": (i, [p, q, q, i]),
|
|
55
|
+
"view_invoke": (i, [p, q, q]),
|
|
56
|
+
"alert": (i, [s, s]), "clipboard_set": (i, [s]),
|
|
57
|
+
"confirm": (i, [s, s, s, s]),
|
|
58
|
+
"clipboard_get": (s, []), "launch": (i, [s]),
|
|
59
|
+
"directory": (s, [i]), "file_panel": (s, [i, s, s, s]),
|
|
60
|
+
}
|
|
61
|
+
for name, (result, arguments) in signatures.items():
|
|
62
|
+
function = getattr(self.lib, "ren_" + name)
|
|
63
|
+
function.restype, function.argtypes = result, arguments
|
|
64
|
+
|
|
65
|
+
def call(self, name, *args):
|
|
66
|
+
result = getattr(self.lib, "ren_" + name)(*args)
|
|
67
|
+
error = self.lib.ren_error()
|
|
68
|
+
if error:
|
|
69
|
+
raise NativeError(error.decode("utf-8", errors="replace"))
|
|
70
|
+
return result
|
ren_ui/app.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import queue
|
|
5
|
+
import re
|
|
6
|
+
import threading
|
|
7
|
+
import traceback
|
|
8
|
+
from typing import Callable
|
|
9
|
+
|
|
10
|
+
from ._native import CALLBACK, Native, utf8
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Event:
|
|
15
|
+
"""A snapshot of a native control event; callbacks receive one Event."""
|
|
16
|
+
source: object
|
|
17
|
+
kind: str
|
|
18
|
+
text: str = ""
|
|
19
|
+
value: int = 0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Application:
|
|
23
|
+
"""One BApplication per process, created and run on the main thread.
|
|
24
|
+
|
|
25
|
+
User callbacks execute serially on a Python dispatcher thread. Native
|
|
26
|
+
callbacks only enqueue snapshots, never run user code under a looper lock.
|
|
27
|
+
"""
|
|
28
|
+
_current: Application | None = None
|
|
29
|
+
|
|
30
|
+
def __init__(self, signature: str):
|
|
31
|
+
if not re.fullmatch(r"application/[A-Za-z0-9][A-Za-z0-9.+_-]*", signature):
|
|
32
|
+
raise ValueError("signature must be an application MIME type")
|
|
33
|
+
self.signature = signature
|
|
34
|
+
self._native = None
|
|
35
|
+
self._handle = None
|
|
36
|
+
self._windows = {}
|
|
37
|
+
self._queue = queue.Queue()
|
|
38
|
+
self._running = False
|
|
39
|
+
self._closed = False
|
|
40
|
+
self._dispatcher = None
|
|
41
|
+
|
|
42
|
+
def _check_ui_thread(self):
|
|
43
|
+
expected = self._dispatcher if self._running else threading.main_thread()
|
|
44
|
+
if threading.current_thread() is not expected:
|
|
45
|
+
raise RuntimeError("Use app.call_soon() to access UI from another thread")
|
|
46
|
+
|
|
47
|
+
def _ensure_native(self):
|
|
48
|
+
self._check_ui_thread()
|
|
49
|
+
if self._closed:
|
|
50
|
+
raise RuntimeError("Application has already closed")
|
|
51
|
+
if self._native is None:
|
|
52
|
+
if threading.current_thread() is not threading.main_thread():
|
|
53
|
+
raise RuntimeError("Create the first window on the main thread")
|
|
54
|
+
if Application._current is not None:
|
|
55
|
+
raise RuntimeError("Only one Application may be active")
|
|
56
|
+
native = Native()
|
|
57
|
+
self._callback = CALLBACK(self._enqueue)
|
|
58
|
+
self._handle = native.call("app_create", utf8(self.signature), self._callback)
|
|
59
|
+
self._native = native
|
|
60
|
+
Application._current = self
|
|
61
|
+
return self._native
|
|
62
|
+
|
|
63
|
+
def _enqueue(self, window, view, kind, text, value):
|
|
64
|
+
self._queue.put((window, view, kind.decode(), text.decode("utf-8", "replace"), value))
|
|
65
|
+
|
|
66
|
+
def _dispatch(self):
|
|
67
|
+
while True:
|
|
68
|
+
item = self._queue.get()
|
|
69
|
+
if item is None:
|
|
70
|
+
return
|
|
71
|
+
try:
|
|
72
|
+
if callable(item):
|
|
73
|
+
item()
|
|
74
|
+
continue
|
|
75
|
+
window_id, view_id, kind, text, value = item
|
|
76
|
+
if kind == "quit_requested":
|
|
77
|
+
for window in list(self._windows.values()):
|
|
78
|
+
window.close()
|
|
79
|
+
continue
|
|
80
|
+
window = self._windows.get(window_id)
|
|
81
|
+
if window is None:
|
|
82
|
+
continue
|
|
83
|
+
if kind == "closed":
|
|
84
|
+
if window._closed:
|
|
85
|
+
continue
|
|
86
|
+
window._closed = True
|
|
87
|
+
if window.on_close:
|
|
88
|
+
window.on_close(Event(window, kind))
|
|
89
|
+
continue
|
|
90
|
+
if window._closed or window._closing:
|
|
91
|
+
continue
|
|
92
|
+
if kind == "close_requested":
|
|
93
|
+
window._decide_close(Event(window, kind))
|
|
94
|
+
continue
|
|
95
|
+
widget = window._widgets.get(view_id)
|
|
96
|
+
if widget is not None and widget.on_change:
|
|
97
|
+
widget.on_change(Event(widget, kind, text, value))
|
|
98
|
+
except Exception:
|
|
99
|
+
# A failing handler must not kill the dispatcher or the app.
|
|
100
|
+
traceback.print_exc()
|
|
101
|
+
|
|
102
|
+
def call_soon(self, callback: Callable[[], None]):
|
|
103
|
+
if self._closed:
|
|
104
|
+
raise RuntimeError("Application has already closed")
|
|
105
|
+
if not callable(callback):
|
|
106
|
+
raise TypeError("callback must be callable")
|
|
107
|
+
self._queue.put(callback)
|
|
108
|
+
|
|
109
|
+
def run(self):
|
|
110
|
+
if threading.current_thread() is not threading.main_thread():
|
|
111
|
+
raise RuntimeError("Application.run() must run on the main thread")
|
|
112
|
+
if self._running:
|
|
113
|
+
raise RuntimeError("Application is already running")
|
|
114
|
+
native = self._ensure_native()
|
|
115
|
+
if not self._windows:
|
|
116
|
+
raise RuntimeError("Show at least one window before run()")
|
|
117
|
+
worker = threading.Thread(target=self._dispatch, name="ren-ui-events")
|
|
118
|
+
self._dispatcher = worker
|
|
119
|
+
self._running = True
|
|
120
|
+
worker.start()
|
|
121
|
+
try:
|
|
122
|
+
native.call("app_run", self._handle)
|
|
123
|
+
finally:
|
|
124
|
+
self._queue.put(None)
|
|
125
|
+
worker.join()
|
|
126
|
+
self._running = False
|
|
127
|
+
self.close()
|
|
128
|
+
|
|
129
|
+
def quit(self):
|
|
130
|
+
self._check_ui_thread()
|
|
131
|
+
if self._native and not self._closed:
|
|
132
|
+
self._native.call("app_quit", self._handle)
|
|
133
|
+
|
|
134
|
+
def close(self):
|
|
135
|
+
"""Release native resources after run, or after a setup failure."""
|
|
136
|
+
if self._running:
|
|
137
|
+
raise RuntimeError("Use quit() while the application is running")
|
|
138
|
+
if threading.current_thread() is not threading.main_thread():
|
|
139
|
+
raise RuntimeError("Application.close() must run on the main thread")
|
|
140
|
+
if self._native and not self._closed:
|
|
141
|
+
self._native.call("app_destroy", self._handle)
|
|
142
|
+
self._handle = None
|
|
143
|
+
Application._current = None
|
|
144
|
+
self._closed = True
|
|
145
|
+
for window in self._windows.values():
|
|
146
|
+
window._closed = True
|
|
147
|
+
|
|
148
|
+
def __enter__(self):
|
|
149
|
+
return self
|
|
150
|
+
|
|
151
|
+
def __exit__(self, *args):
|
|
152
|
+
self.close()
|
ren_ui/build.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import platform
|
|
6
|
+
import shlex
|
|
7
|
+
import shutil
|
|
8
|
+
import struct
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import sysconfig
|
|
12
|
+
import tempfile
|
|
13
|
+
|
|
14
|
+
from .project import Project, ProjectError, package_info, quote
|
|
15
|
+
|
|
16
|
+
NATIVE = Path(__file__).with_name("native")
|
|
17
|
+
ARCHITECTURES = {3: "x86", 62: "x86_64", 183: "arm64", 243: "riscv64"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def require_haiku():
|
|
21
|
+
if not sys.platform.startswith("haiku"):
|
|
22
|
+
raise ProjectError("This command requires Haiku OS and its development tools; use 'ren stage' to inspect sources on this host.")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def command(arguments, **kwargs):
|
|
26
|
+
try:
|
|
27
|
+
subprocess.run([str(arg) for arg in arguments], check=True, **kwargs)
|
|
28
|
+
except FileNotFoundError as exc:
|
|
29
|
+
raise ProjectError(f"Required tool not found: {arguments[0]}. Run 'ren doctor'.") from exc
|
|
30
|
+
except subprocess.CalledProcessError as exc:
|
|
31
|
+
raise ProjectError(f"Command failed ({exc.returncode}): {shlex.join(map(str, arguments))}") from exc
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def secondary_architecture() -> str:
|
|
35
|
+
"""'x86' when this Python belongs to a secondary architecture (32-bit hybrid Haiku), else ''."""
|
|
36
|
+
libdir = Path(sysconfig.get_config_var("LIBDIR") or "")
|
|
37
|
+
return libdir.name if libdir.parent.name == "lib" else ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def primary_architecture() -> str:
|
|
41
|
+
"""The architecture pkgman installs for, e.g. x86_gcc2 on a 32-bit hybrid system."""
|
|
42
|
+
try:
|
|
43
|
+
result = subprocess.run(["getarch", "-p"], check=True, capture_output=True, text=True)
|
|
44
|
+
except (OSError, subprocess.CalledProcessError) as exc:
|
|
45
|
+
raise ProjectError("Cannot determine the primary architecture with 'getarch -p'") from exc
|
|
46
|
+
return result.stdout.strip()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def compiler():
|
|
50
|
+
# A hybrid system's default c++ is GCC 2; the bridge must match this Python's architecture.
|
|
51
|
+
secondary = secondary_architecture()
|
|
52
|
+
arguments = shlex.split(os.environ.get("CXX", f"c++-{secondary}" if secondary else "c++"))
|
|
53
|
+
if not arguments:
|
|
54
|
+
raise ProjectError("CXX must name a C++17 compiler")
|
|
55
|
+
return arguments
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def native_link_flags():
|
|
59
|
+
# AArch64 linkers can default to 64 KiB pages. Haiku's runtime_loader
|
|
60
|
+
# rejects the resulting segment gaps with "Could not map image: Bad data".
|
|
61
|
+
if platform.machine().lower() in {"arm64", "aarch64"}:
|
|
62
|
+
return ["-Wl,-z,max-page-size=4096"]
|
|
63
|
+
return []
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def compile_native(output: Path):
|
|
67
|
+
require_haiku()
|
|
68
|
+
output = output.resolve()
|
|
69
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
# Build atomically: never destroy the previous library on compiler failure.
|
|
71
|
+
with tempfile.TemporaryDirectory(prefix="ren-native-", dir=output.parent) as temporary:
|
|
72
|
+
library = Path(temporary) / "libren_ui.so"
|
|
73
|
+
command([*compiler(), "-std=c++17", "-O2", "-Wall", "-Wextra", "-Wno-multichar",
|
|
74
|
+
"-fPIC", "-shared", NATIVE / "bridge.cpp", "-o", library, "-lbe", "-ltranslation", "-ltracker",
|
|
75
|
+
*native_link_flags()])
|
|
76
|
+
elf_architecture(library)
|
|
77
|
+
os.replace(library, output)
|
|
78
|
+
return output
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def elf_architecture(path: Path) -> str:
|
|
82
|
+
with path.open("rb") as file:
|
|
83
|
+
header = file.read(20)
|
|
84
|
+
if len(header) != 20 or header[:4] != b"\x7fELF" or header[4] not in (1, 2) or header[5] not in (1, 2):
|
|
85
|
+
raise ProjectError(f"Not an ELF binary: {path}")
|
|
86
|
+
machine = struct.unpack("<H" if header[5] == 1 else ">H", header[18:20])[0]
|
|
87
|
+
if machine not in ARCHITECTURES:
|
|
88
|
+
raise ProjectError(f"Unsupported ELF machine: {machine}")
|
|
89
|
+
return ARCHITECTURES[machine]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def python_requirement(library: str, secondary: str = "") -> str:
|
|
93
|
+
"""The package-system name of a libpython, e.g. lib:libpython3.12 or lib:libpython3.12_x86."""
|
|
94
|
+
return "lib:" + library.split(".so")[0] + (f"_{secondary}" if secondary else "")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def stage(project: Project, destination: Path, *, architecture="x86_64", python_package=None, secondary=""):
|
|
98
|
+
"""Write a reviewable source staging tree, not an installable package."""
|
|
99
|
+
if destination.exists() or destination.is_symlink():
|
|
100
|
+
raise ProjectError(f"Stage destination already exists: {destination}")
|
|
101
|
+
sources = project.source_files()
|
|
102
|
+
resolved = destination.resolve()
|
|
103
|
+
for item in project.include:
|
|
104
|
+
source = project.safe_path(item)
|
|
105
|
+
if source.is_dir() and resolved.is_relative_to(source):
|
|
106
|
+
raise ProjectError("Stage destination cannot be inside an included source directory")
|
|
107
|
+
destination.mkdir(parents=True)
|
|
108
|
+
payload = destination / "data" / project.name
|
|
109
|
+
app = payload / "app"
|
|
110
|
+
app.mkdir(parents=True)
|
|
111
|
+
for relative, source in sources.items():
|
|
112
|
+
target = app / relative
|
|
113
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
shutil.copyfile(source, target)
|
|
115
|
+
runtime = payload / "runtime" / "ren_ui"
|
|
116
|
+
runtime.mkdir(parents=True)
|
|
117
|
+
for source in Path(__file__).parent.glob("*.py"):
|
|
118
|
+
# Runtime users do not need packaging/CLI source or a compiler.
|
|
119
|
+
if source.name not in {"cli.py", "build.py", "project.py", "__main__.py"}:
|
|
120
|
+
shutil.copyfile(source, runtime / source.name)
|
|
121
|
+
licenses = destination / "data" / "licenses"
|
|
122
|
+
licenses.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
# Namespace license files: different apps' MIT notices have different authors.
|
|
124
|
+
shutil.copyfile(project.safe_path(project.license_file), licenses / f"{project.name}_{project.license}")
|
|
125
|
+
# Preserve the bundled framework's license separately from the app's license.
|
|
126
|
+
(payload / "REN_UI_LICENSE").write_text(FRAMEWORK_LICENSE, encoding="utf-8")
|
|
127
|
+
if python_package is None:
|
|
128
|
+
python_package = python_requirement(f"libpython{sys.version_info.major}.{sys.version_info.minor}.so", secondary)
|
|
129
|
+
(destination / ".PackageInfo").write_text(
|
|
130
|
+
package_info(project, architecture, python_package, secondary), encoding="utf-8")
|
|
131
|
+
return destination
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def application_resources(project: Project) -> str:
|
|
135
|
+
source = f"resource app_signature {quote(project.signature)};\n"
|
|
136
|
+
if project.icon is not None and Path(project.icon).suffix.lower() == ".hvif":
|
|
137
|
+
data = project.icon_bytes()
|
|
138
|
+
# Hex literals avoid filesystem-dependent imports and RDEF injection.
|
|
139
|
+
chunks = "\n".join(' $"' + data[i:i + 32].hex() + '"' for i in range(0, len(data), 32))
|
|
140
|
+
source += "resource(101, \"BEOS:ICON\") #'VICN' array {\n" + chunks + "\n};\n"
|
|
141
|
+
return source
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def build(project: Project, output: Path, *, force=False) -> Path:
|
|
145
|
+
require_haiku()
|
|
146
|
+
for tool in [*compiler()[:1], "package", "rc", "xres", "resattr", "mimeset"]:
|
|
147
|
+
if not shutil.which(tool):
|
|
148
|
+
raise ProjectError(f"Required tool not found: {tool}. Run 'ren doctor'.")
|
|
149
|
+
if sys.version_info < (3, 11):
|
|
150
|
+
raise ProjectError("Python 3.11 or newer is required")
|
|
151
|
+
python_name = f"python{sys.version_info.major}.{sys.version_info.minor}"
|
|
152
|
+
if not shutil.which(python_name):
|
|
153
|
+
raise ProjectError(f"Packaged runtime interpreter not found: {python_name}")
|
|
154
|
+
# INSTSONAME is the name installed in the library path; the unversioned
|
|
155
|
+
# LDLIBRARY link only exists under develop/lib, where dlopen() does not look.
|
|
156
|
+
python_library = sysconfig.get_config_var("INSTSONAME") or sysconfig.get_config_var("LDLIBRARY")
|
|
157
|
+
if not isinstance(python_library, str) or ".so" not in python_library:
|
|
158
|
+
raise ProjectError("The packaged launcher requires a shared Python runtime (libpython*.so)")
|
|
159
|
+
python_library = Path(python_library).name
|
|
160
|
+
secondary = secondary_architecture()
|
|
161
|
+
output = output.resolve()
|
|
162
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
with tempfile.TemporaryDirectory(prefix="ren-build-") as directory:
|
|
164
|
+
work = Path(directory)
|
|
165
|
+
library = compile_native(work / "libren_ui.so")
|
|
166
|
+
architecture = elf_architecture(library)
|
|
167
|
+
# Secondary-architecture binaries ship in a package of the primary architecture.
|
|
168
|
+
package_architecture = primary_architecture() if secondary else architecture
|
|
169
|
+
target = output / f"{project.name}-{project.package_version}-{package_architecture}.hpkg"
|
|
170
|
+
if target.exists() and not force:
|
|
171
|
+
raise ProjectError(f"Package already exists: {target}; pass --force to replace it")
|
|
172
|
+
root = stage(project, work / "stage", architecture=package_architecture,
|
|
173
|
+
python_package=python_requirement(python_library, secondary), secondary=secondary)
|
|
174
|
+
shutil.copyfile(library, root / "data" / project.name / "runtime" / "ren_ui" / "libren_ui.so")
|
|
175
|
+
launcher = root / "apps" / project.name
|
|
176
|
+
launcher.parent.mkdir(parents=True)
|
|
177
|
+
command([*compiler(), "-std=c++17", "-O2", NATIVE / "launcher.cpp", "-o", launcher, "-lbe",
|
|
178
|
+
*native_link_flags(),
|
|
179
|
+
f"-DREN_APP_NAME={quote(project.name)}", f"-DREN_ENTRY={quote(project.entry)}",
|
|
180
|
+
f"-DREN_PYTHON={quote(python_name)}", f"-DREN_PYTHON_LIBRARY={quote(python_library)}"])
|
|
181
|
+
if elf_architecture(launcher) != architecture:
|
|
182
|
+
raise ProjectError("Launcher and bridge architectures differ")
|
|
183
|
+
resource = work / "app.rdef"
|
|
184
|
+
resource.write_text(application_resources(project), encoding="utf-8")
|
|
185
|
+
command(["rc", "-o", work / "app.rsrc", resource])
|
|
186
|
+
# Preserve the ELF while embedding resources as well as BFS attributes.
|
|
187
|
+
command(["xres", "-o", launcher, work / "app.rsrc"])
|
|
188
|
+
command(["resattr", "-o", launcher, work / "app.rsrc"])
|
|
189
|
+
if project.icon is not None and Path(project.icon).suffix.lower() == ".png":
|
|
190
|
+
icon_tool = work / "ren-icon"
|
|
191
|
+
command([*compiler(), "-std=c++17", "-O2", NATIVE / "icon.cpp", "-o", icon_tool,
|
|
192
|
+
"-lbe", "-ltranslation", *native_link_flags()])
|
|
193
|
+
command([icon_tool, root / "data" / project.name / "app" / project.icon, launcher])
|
|
194
|
+
command(["mimeset", "-f", launcher])
|
|
195
|
+
link = root / "data" / "deskbar" / "menu" / "Applications" / project.title
|
|
196
|
+
# title is metadata elsewhere, but here it must be a single filename.
|
|
197
|
+
link.parent.mkdir(parents=True, exist_ok=True)
|
|
198
|
+
link.symlink_to(f"../../../../apps/{project.name}")
|
|
199
|
+
# package create recursively includes the staged payload; -b would omit it.
|
|
200
|
+
# Output must be outside the input tree, per Haiku's package tool.
|
|
201
|
+
artifact = work / target.name
|
|
202
|
+
command(["package", "create", "-C", root, artifact])
|
|
203
|
+
if not artifact.is_file() or artifact.stat().st_size < 4:
|
|
204
|
+
raise ProjectError("package create did not produce an HPKG")
|
|
205
|
+
with artifact.open("rb") as file:
|
|
206
|
+
if file.read(4) != b"hpkg":
|
|
207
|
+
raise ProjectError("package create output is not an HPKG")
|
|
208
|
+
# Rename on the destination filesystem; a failed copy cannot leave a partial HPKG.
|
|
209
|
+
with tempfile.TemporaryDirectory(prefix=".ren-output-", dir=output) as temporary:
|
|
210
|
+
staged_output = Path(temporary) / target.name
|
|
211
|
+
shutil.copyfile(artifact, staged_output)
|
|
212
|
+
os.replace(staged_output, target)
|
|
213
|
+
return target
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def doctor() -> tuple[str, bool]:
|
|
217
|
+
host = sys.platform.startswith("haiku")
|
|
218
|
+
rows = [f"Host: {platform.system()} ({platform.machine()})", f"Python: {platform.python_version()}"]
|
|
219
|
+
ready = host
|
|
220
|
+
for tool in [*compiler()[:1], "package", "rc", "xres", "resattr", "mimeset"]:
|
|
221
|
+
found = shutil.which(tool)
|
|
222
|
+
rows.append(f"{tool}: {found or 'missing'}")
|
|
223
|
+
ready &= found is not None
|
|
224
|
+
shared = sysconfig.get_config_var("LDLIBRARY") or ""
|
|
225
|
+
rows.append(f"Python shared runtime: {shared or 'missing'}")
|
|
226
|
+
ready &= ".so" in shared
|
|
227
|
+
interpreter = f"python{sys.version_info.major}.{sys.version_info.minor}"
|
|
228
|
+
found = shutil.which(interpreter)
|
|
229
|
+
rows.append(f"{interpreter}: {found or 'missing'}")
|
|
230
|
+
ready &= found is not None
|
|
231
|
+
rows.append("Native build: ready" if ready else "Native build: requires Haiku, a modern C++17 compiler, and Haiku packaging tools")
|
|
232
|
+
return "\n".join(rows), ready
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
FRAMEWORK_LICENSE = """MIT License
|
|
236
|
+
|
|
237
|
+
Copyright (c) 2026 ren-ui-python contributors
|
|
238
|
+
|
|
239
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
240
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
241
|
+
in the Software without restriction, including without limitation the rights
|
|
242
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
243
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
244
|
+
furnished to do so, subject to the following conditions:
|
|
245
|
+
|
|
246
|
+
The above copyright notice and this permission notice shall be included in all
|
|
247
|
+
copies or substantial portions of the Software.
|
|
248
|
+
|
|
249
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
250
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
251
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
252
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
253
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
254
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
255
|
+
SOFTWARE.
|
|
256
|
+
"""
|
ren_ui/cli.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .build import FRAMEWORK_LICENSE, build, compile_native, doctor, require_haiku, stage
|
|
13
|
+
from .project import Project, ProjectError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def init_project(directory: Path, name=None):
|
|
17
|
+
name = name or directory.resolve().name.replace("-", "_").lower()
|
|
18
|
+
if not re.fullmatch(r"[a-z][a-z0-9_]*", name):
|
|
19
|
+
raise ProjectError("Choose a name containing only lowercase letters, digits, and underscores")
|
|
20
|
+
if directory.exists() and (not directory.is_dir() or any(directory.iterdir())):
|
|
21
|
+
raise ProjectError(f"Destination must be empty: {directory}")
|
|
22
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
signature = f"application/x-vnd.ren-{name}"
|
|
24
|
+
title = name.replace("_", " ").title()
|
|
25
|
+
manifest = f'''[app]
|
|
26
|
+
name = {json.dumps(name)}
|
|
27
|
+
title = {json.dumps(title)}
|
|
28
|
+
version = "0.1.0"
|
|
29
|
+
revision = 1
|
|
30
|
+
signature = {json.dumps(signature)}
|
|
31
|
+
summary = "A native Haiku application built with Python"
|
|
32
|
+
description = "My first ren-ui-python application"
|
|
33
|
+
author = "Your Name"
|
|
34
|
+
license = "MIT"
|
|
35
|
+
license-file = "LICENSE"
|
|
36
|
+
entry = "main.py"
|
|
37
|
+
|
|
38
|
+
[build]
|
|
39
|
+
include = ["main.py"]
|
|
40
|
+
requires = []
|
|
41
|
+
'''
|
|
42
|
+
source = f'''from ren_ui import Application, Event, Window, Column, Row, Label, TextInput, Button
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main():
|
|
46
|
+
with Application({signature!r}) as app:
|
|
47
|
+
greeting = Label("Welcome to Haiku!")
|
|
48
|
+
name = TextInput(label="Name:")
|
|
49
|
+
|
|
50
|
+
greet_button = Button("Say hello")
|
|
51
|
+
quit_button = Button("Quit")
|
|
52
|
+
|
|
53
|
+
@greet_button.onclick
|
|
54
|
+
def greet(event: Event):
|
|
55
|
+
greeting.text = f"Hello, {{name.text or 'Haiku'}}!"
|
|
56
|
+
|
|
57
|
+
@quit_button.onclick
|
|
58
|
+
def quit_app(event: Event):
|
|
59
|
+
app.quit()
|
|
60
|
+
|
|
61
|
+
Window(app, {title!r}, Column(
|
|
62
|
+
greeting,
|
|
63
|
+
name,
|
|
64
|
+
Row(greet_button, quit_button, padding=0),
|
|
65
|
+
)).show()
|
|
66
|
+
app.run()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
if __name__ == "__main__":
|
|
70
|
+
main()
|
|
71
|
+
'''
|
|
72
|
+
(directory / "ren.toml").write_text(manifest, encoding="utf-8")
|
|
73
|
+
(directory / "main.py").write_text(source, encoding="utf-8")
|
|
74
|
+
(directory / "LICENSE").write_text(FRAMEWORK_LICENSE.replace(
|
|
75
|
+
"2026 ren-ui-python contributors", "2026 Your Name"), encoding="utf-8")
|
|
76
|
+
(directory / ".gitignore").write_text(".ren-build/\ndist/\n__pycache__/\n*.pyc\n", encoding="utf-8")
|
|
77
|
+
return directory
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def parser():
|
|
81
|
+
result = argparse.ArgumentParser(prog="ren", description="Python native Haiku apps and HPKG builds")
|
|
82
|
+
result.add_argument("--version", action="version", version=__version__)
|
|
83
|
+
sub = result.add_subparsers(dest="command", required=True)
|
|
84
|
+
init = sub.add_parser("init", help="create an application in an empty directory")
|
|
85
|
+
init.add_argument("directory", type=Path)
|
|
86
|
+
init.add_argument("--name")
|
|
87
|
+
sub.add_parser("doctor", help="check native runtime and build prerequisites")
|
|
88
|
+
native = sub.add_parser("native", help="compile the native bridge on Haiku")
|
|
89
|
+
native.add_argument("--output", type=Path, default=Path(".ren-build/libren_ui.so"))
|
|
90
|
+
for name, help_text in (("run", "build the bridge and run an app on Haiku"),
|
|
91
|
+
("build", "build a complete HPKG on Haiku"),
|
|
92
|
+
("stage", "prepare sources and PackageInfo for inspection; no native binaries"),
|
|
93
|
+
("check", "validate manifest, source paths and Python syntax")):
|
|
94
|
+
child = sub.add_parser(name, help=help_text)
|
|
95
|
+
child.add_argument("project", nargs="?", type=Path, default=Path.cwd())
|
|
96
|
+
if name == "build":
|
|
97
|
+
child.add_argument("--output", type=Path, help="artifact directory (default: PROJECT/dist)")
|
|
98
|
+
child.add_argument("--force", action="store_true", help="replace an existing HPKG")
|
|
99
|
+
if name == "stage":
|
|
100
|
+
child.add_argument("--output", type=Path, help="new staging directory (default: PROJECT/.ren-build/stage)")
|
|
101
|
+
child.add_argument("--arch", choices=("x86", "x86_64", "arm64", "riscv64"), default="x86_64")
|
|
102
|
+
if name == "run":
|
|
103
|
+
child.add_argument("args", nargs=argparse.REMAINDER, help="arguments passed to the app")
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def check_sources(project):
|
|
108
|
+
files = project.source_files()
|
|
109
|
+
for relative, path in files.items():
|
|
110
|
+
if path.suffix == ".py":
|
|
111
|
+
try:
|
|
112
|
+
compile(path.read_bytes(), relative, "exec")
|
|
113
|
+
except (SyntaxError, ValueError) as exc:
|
|
114
|
+
raise ProjectError(f"Invalid Python source in {relative}: {exc}") from exc
|
|
115
|
+
return len(files)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def main(argv=None):
|
|
119
|
+
args = parser().parse_args(argv)
|
|
120
|
+
try:
|
|
121
|
+
if args.command == "init":
|
|
122
|
+
path = init_project(args.directory, args.name)
|
|
123
|
+
print(f"Created {path}\nNext: ren check {path}\nOn Haiku: ren run {path} / ren build {path}")
|
|
124
|
+
elif args.command == "doctor":
|
|
125
|
+
report, ready = doctor()
|
|
126
|
+
print(report)
|
|
127
|
+
return 0 if ready else 1
|
|
128
|
+
elif args.command == "native":
|
|
129
|
+
path = compile_native(args.output)
|
|
130
|
+
print(f"Built {path}\nUse 'ren run' or set REN_UI_LIBRARY to this absolute path.")
|
|
131
|
+
else:
|
|
132
|
+
project = Project.load(args.project)
|
|
133
|
+
count = check_sources(project)
|
|
134
|
+
if args.command == "check":
|
|
135
|
+
print(f"Valid: {project.name} {project.package_version} ({count} source files)")
|
|
136
|
+
elif args.command == "stage":
|
|
137
|
+
path = stage(project, args.output or project.root / ".ren-build" / "stage", architecture=args.arch)
|
|
138
|
+
print(f"Staged sources: {path}\nThis is an inspection tree, not a complete package. Use 'ren build' on Haiku.")
|
|
139
|
+
elif args.command == "build":
|
|
140
|
+
print(build(project, args.output or project.root / "dist", force=args.force))
|
|
141
|
+
elif args.command == "run":
|
|
142
|
+
require_haiku()
|
|
143
|
+
library = compile_native(project.root / ".ren-build" / "libren_ui.so")
|
|
144
|
+
env = os.environ.copy()
|
|
145
|
+
env["REN_UI_LIBRARY"] = str(library)
|
|
146
|
+
env["REN_UI_ASSET_ROOT"] = str(project.root)
|
|
147
|
+
env["PYTHONPATH"] = os.pathsep.join(filter(None, [str(Path(__file__).parent.parent), str(project.root), env.get("PYTHONPATH")]))
|
|
148
|
+
arguments = args.args[1:] if args.args[:1] == ["--"] else args.args
|
|
149
|
+
return subprocess.run([sys.executable, "-B", str(project.root / project.entry), *arguments], cwd=project.root, env=env).returncode
|
|
150
|
+
return 0
|
|
151
|
+
except (ProjectError, OSError) as exc:
|
|
152
|
+
print(f"ren: {exc}", file=sys.stderr)
|
|
153
|
+
return 1
|
|
154
|
+
except KeyboardInterrupt:
|
|
155
|
+
return 130
|