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 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
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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