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/widgets.py ADDED
@@ -0,0 +1,380 @@
1
+ from __future__ import annotations
2
+
3
+ import ctypes
4
+ import math
5
+ from pathlib import Path
6
+ from typing import Literal
7
+
8
+ from ._native import utf8
9
+ from .resources import image_path
10
+
11
+
12
+ def _dimension(value, name, *, positive=False):
13
+ value = float(value)
14
+ if not math.isfinite(value) or value < 0 or (positive and value == 0):
15
+ raise ValueError(f"{name} must be {'positive' if positive else 'nonnegative'} and finite")
16
+ return value
17
+
18
+
19
+ class Widget:
20
+ kind = ""
21
+
22
+ def __init__(self, text="", *, on_change=None, enabled=True):
23
+ utf8(text)
24
+ if on_change is not None and not callable(on_change):
25
+ raise TypeError("on_change must be callable")
26
+ self._text = text
27
+ self._value = 0
28
+ self._enabled = bool(enabled)
29
+ self.on_change = on_change
30
+ self._window = None
31
+ self._id = None
32
+
33
+ def _call(self, name, *args):
34
+ window = self._window
35
+ window._check_open()
36
+ return window.app._native.call(name, window.app._handle, window._id, self._id, *args)
37
+
38
+ def _mount(self, window, parent=0):
39
+ if self._window is not None:
40
+ raise ValueError("A widget can only belong to one window")
41
+ extra = getattr(self, "label", "")
42
+ a, b = (getattr(self, "spacing", 0), getattr(self, "padding", 0))
43
+ if self.kind == "slider":
44
+ a, b = self.minimum, self.maximum
45
+ elif self.kind == "image":
46
+ a, b = self.width, self.height
47
+ extra = self.fit
48
+ elif self.kind == "textarea":
49
+ a = self.wrap
50
+ self._id = window.app._native.call(
51
+ "view_create", window.app._handle, window._id, parent,
52
+ utf8(self.kind), utf8(self._text), utf8(extra), a, b,
53
+ )
54
+ self._window = window
55
+ window._widgets[self._id] = self
56
+ if self.kind == "button" and self._icon is not None:
57
+ self._call("button_icon", utf8(str(self._icon)), self.icon_size)
58
+ if self.kind in ("checkbox", "slider"):
59
+ self.value = self._value
60
+ if not self._enabled:
61
+ self.enabled = False
62
+ for child in getattr(self, "children", ()):
63
+ child._mount(window, self._id)
64
+
65
+ @property
66
+ def text(self):
67
+ if self._window:
68
+ return self._call("view_text").decode("utf-8")
69
+ return self._text
70
+
71
+ @text.setter
72
+ def text(self, value):
73
+ encoded = utf8(value)
74
+ if self._window:
75
+ self._call("view_set_text", encoded)
76
+ self._text = value
77
+
78
+ @property
79
+ def value(self):
80
+ if self._window:
81
+ result = ctypes.c_int()
82
+ self._call("view_value", ctypes.byref(result))
83
+ return result.value
84
+ return self._value
85
+
86
+ @value.setter
87
+ def value(self, value):
88
+ value = int(value)
89
+ if self.kind == "slider" and not self.minimum <= value <= self.maximum:
90
+ raise ValueError("slider value is outside its range")
91
+ if self.kind == "checkbox" and value not in (0, 1):
92
+ raise ValueError("checkbox value must be 0 or 1")
93
+ if self._window:
94
+ self._call("view_set_value", value)
95
+ self._value = value
96
+
97
+ @property
98
+ def enabled(self):
99
+ return self._enabled
100
+
101
+ @enabled.setter
102
+ def enabled(self, value):
103
+ if self._window:
104
+ self._call("view_enabled", bool(value))
105
+ self._enabled = bool(value)
106
+
107
+
108
+ class Label(Widget):
109
+ kind = "label"
110
+
111
+
112
+ class Button(Widget):
113
+ kind = "button"
114
+
115
+ def __init__(self, text, onclick=None, *, enabled=True,
116
+ icon: str | Path | None = None, icon_size: int = 24):
117
+ super().__init__(text, on_change=onclick, enabled=enabled)
118
+ if type(icon_size) is not int or not 1 <= icon_size <= 256:
119
+ raise ValueError("icon_size must be an integer from 1 to 256")
120
+ self._icon_size = icon_size
121
+ self._icon = image_path(icon) if icon is not None else None
122
+
123
+ @property
124
+ def icon_size(self) -> int:
125
+ return self._icon_size
126
+
127
+ @property
128
+ def icon(self) -> Path | None:
129
+ return self._icon
130
+
131
+ @icon.setter
132
+ def icon(self, source: str | Path | None):
133
+ path = image_path(source) if source is not None else None
134
+ if self._window:
135
+ self._call("button_icon", utf8(str(path)) if path else b"", self.icon_size)
136
+ self._icon = path
137
+
138
+ def onclick(self, callback):
139
+ """Register one click handler; usable as a decorator or a method.
140
+
141
+ Replaces the previous handler and returns the original function.
142
+ The handler receives the same Event as a constructor callback.
143
+ """
144
+ if not callable(callback):
145
+ raise TypeError("onclick must be callable")
146
+ self.on_change = callback
147
+ return callback
148
+
149
+ def click(self):
150
+ """Send the native button invocation through the window's event loop."""
151
+ if self._window is None:
152
+ raise RuntimeError("Show the window before invoking a button")
153
+ if self.enabled:
154
+ self._call("view_invoke")
155
+
156
+
157
+ class Image(Widget):
158
+ """An image in a fixed-size viewport, decoded by Haiku's Translation Kit."""
159
+ kind = "image"
160
+
161
+ def __init__(self, source: str | Path, *, width: float = 320, height: float = 200,
162
+ fit: Literal["contain", "cover", "stretch"] = "contain"):
163
+ self._width = _dimension(width, "width", positive=True)
164
+ self._height = _dimension(height, "height", positive=True)
165
+ if self._width > 4096 or self._height > 4096:
166
+ raise ValueError("Image dimensions must not exceed 4096")
167
+ if fit not in ("contain", "cover", "stretch"):
168
+ raise ValueError("fit must be contain, cover, or stretch")
169
+ self._fit = fit
170
+ self._source = image_path(source)
171
+ super().__init__(str(self._source))
172
+
173
+ @property
174
+ def width(self) -> float:
175
+ return self._width
176
+
177
+ @property
178
+ def height(self) -> float:
179
+ return self._height
180
+
181
+ @property
182
+ def fit(self) -> str:
183
+ return self._fit
184
+
185
+ @property
186
+ def source(self) -> Path:
187
+ return self._source
188
+
189
+ @source.setter
190
+ def source(self, source: str | Path):
191
+ path = image_path(source)
192
+ if self._window:
193
+ self._call("image_source", utf8(str(path)))
194
+ self._source = path
195
+ self._text = str(path)
196
+
197
+
198
+ class TextInput(Widget):
199
+ kind = "text"
200
+
201
+ def __init__(self, text="", *, label="", on_change=None, enabled=True):
202
+ super().__init__(text, on_change=on_change, enabled=enabled)
203
+ utf8(label)
204
+ self.label = label
205
+
206
+
207
+ class TextArea(Widget):
208
+ """Scrollable UTF-8 text, up to 1 MiB, with native editing and undo.
209
+
210
+ Changes, including assignments to text after mounting, emit on_change.
211
+ wrap is a construction option; editable can be changed at runtime.
212
+ """
213
+ kind = "textarea"
214
+ MAX_BYTES = 1024 * 1024
215
+
216
+ def __init__(self, text="", *, on_change=None, editable=True, wrap=True):
217
+ self._validate_text(text)
218
+ super().__init__(text, on_change=on_change, enabled=editable)
219
+ self.wrap = bool(wrap)
220
+
221
+ @staticmethod
222
+ def _validate_text(value):
223
+ if len(utf8(value)) > TextArea.MAX_BYTES:
224
+ raise ValueError("TextArea supports at most 1 MiB of UTF-8 text")
225
+
226
+ @Widget.text.setter
227
+ def text(self, value):
228
+ self._validate_text(value)
229
+ Widget.text.fset(self, value)
230
+
231
+ @property
232
+ def editable(self):
233
+ return self.enabled
234
+
235
+ @editable.setter
236
+ def editable(self, value):
237
+ self.enabled = value
238
+
239
+
240
+ class CheckBox(Widget):
241
+ kind = "checkbox"
242
+
243
+ def __init__(self, text, *, checked=False, on_change=None, enabled=True):
244
+ super().__init__(text, on_change=on_change, enabled=enabled)
245
+ self._value = int(bool(checked))
246
+
247
+ @property
248
+ def checked(self):
249
+ return bool(self.value)
250
+
251
+ @checked.setter
252
+ def checked(self, value):
253
+ self.value = int(bool(value))
254
+
255
+
256
+ class Slider(Widget):
257
+ kind = "slider"
258
+
259
+ def __init__(self, text="", *, minimum=0, maximum=100, value=0, on_change=None):
260
+ super().__init__(text, on_change=on_change)
261
+ # The ABI carries layout/range values as floats; keep integers exact.
262
+ if not all(isinstance(v, int) and abs(v) <= 2**24 for v in (minimum, maximum, value)):
263
+ raise ValueError("slider bounds and value must be integers within +/- 2**24")
264
+ if minimum >= maximum:
265
+ raise ValueError("minimum must be less than maximum")
266
+ self.minimum, self.maximum = minimum, maximum
267
+ self.value = value
268
+
269
+
270
+ class Column(Widget):
271
+ kind = "column"
272
+
273
+ def __init__(self, *children, spacing=8, padding=12):
274
+ super().__init__()
275
+ if not all(isinstance(child, Widget) for child in children):
276
+ raise TypeError("Layouts only accept widgets")
277
+ self.children = tuple(children)
278
+ self.spacing = _dimension(spacing, "spacing")
279
+ self.padding = _dimension(padding, "padding")
280
+
281
+
282
+ class Row(Column):
283
+ kind = "row"
284
+
285
+
286
+ class Window:
287
+ def __init__(self, app, title, content, *, width=480, height=280, resizable=True, onclose=None, on_close=None):
288
+ utf8(title)
289
+ if not isinstance(content, Widget):
290
+ raise TypeError("content must be a widget or layout")
291
+ if on_close is not None and not callable(on_close):
292
+ raise TypeError("on_close must be callable")
293
+ self.app, self.content, self.on_close = app, content, on_close
294
+ self._title = title
295
+ self.width = _dimension(width, "width", positive=True)
296
+ self.height = _dimension(height, "height", positive=True)
297
+ self.resizable = bool(resizable)
298
+ self._id = None
299
+ self._closed = False
300
+ self._closing = False
301
+ self._onclose = None
302
+ if onclose is not None:
303
+ self.onclose(onclose)
304
+ self._widgets = {}
305
+
306
+ def _check_open(self):
307
+ self.app._check_ui_thread()
308
+ if self._closed or self._closing or self.app._closed:
309
+ raise RuntimeError("Window has closed or is closing")
310
+
311
+ def onclose(self, callback):
312
+ """Register a close guard. Only a literal True allows the close.
313
+
314
+ Receives Event(source=self, kind="close_requested"). Also a decorator;
315
+ replaces the previous guard and returns the original function.
316
+ """
317
+ if not callable(callback):
318
+ raise TypeError("onclose must be callable")
319
+ self._onclose = callback
320
+ return callback
321
+
322
+ def _decide_close(self, event):
323
+ allow = False
324
+ try:
325
+ allow = self._onclose is None or self._onclose(event) is True
326
+ finally:
327
+ # An exception cancels the request and re-enables future attempts.
328
+ self.app._native.call("window_close_reply", self.app._handle, self._id, allow)
329
+ self._closing = allow
330
+
331
+ def _validate_tree(self):
332
+ seen = set()
333
+ def visit(widget):
334
+ if id(widget) in seen or widget._window is not None:
335
+ raise ValueError("Each widget must appear exactly once and cannot be reused")
336
+ seen.add(id(widget))
337
+ for child in getattr(widget, "children", ()):
338
+ visit(child)
339
+ visit(self.content)
340
+
341
+ def show(self):
342
+ self._check_open()
343
+ if self._id is None:
344
+ self._validate_tree()
345
+ native = self.app._ensure_native()
346
+ self._id = native.call("window_create", self.app._handle, utf8(self.title),
347
+ self.width, self.height, self.resizable)
348
+ self.app._windows[self._id] = self
349
+ try:
350
+ # A bare widget is hosted like Column(widget): without a layout
351
+ # its preferred size would cap the window below the requested size.
352
+ root = self.content if isinstance(self.content, Column) else Column(self.content)
353
+ root._mount(self)
354
+ except Exception:
355
+ self.close()
356
+ raise
357
+ self.app._native.call("window_show", self.app._handle, self._id)
358
+ return self
359
+
360
+ def close(self):
361
+ """Request a close asynchronously through onclose, if registered."""
362
+ self.app._check_ui_thread()
363
+ if self._closed or self._closing or self.app._closed:
364
+ return
365
+ if self._id is not None:
366
+ self.app._native.call("window_close", self.app._handle, self._id)
367
+ else:
368
+ self._closed = True # No native window or close event before show().
369
+
370
+ @property
371
+ def title(self):
372
+ return self._title
373
+
374
+ @title.setter
375
+ def title(self, value):
376
+ encoded = utf8(value)
377
+ self._check_open()
378
+ if self._id is not None:
379
+ self.app._native.call("window_title", self.app._handle, self._id, encoded)
380
+ self._title = value
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.4
2
+ Name: ren-ui-python
3
+ Version: 0.1.0
4
+ Summary: Native RenkuOS/HaikuOS desktop apps in Python, packaged as HPKG
5
+ Author-email: Lee JunHaeng aka rainygirl <rainygirl@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Documentation, https://ren-ui-python.rainygirl.com/
8
+ Keywords: haiku,renkuos,gui,native,hpkg
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: Other OS
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Programming Language :: C++
19
+ Classifier: Topic :: Software Development :: User Interfaces
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.11
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Dynamic: license-file
25
+
26
+ # ren-ui-python
27
+
28
+ Write native RenkuOS/HaikuOS desktop apps in plain Python and package them as `.hpkg`.
29
+
30
+ The windows, buttons and dialogs are the system's own controls, driven through a small C++ bridge to `libbe`. There is no web view and no extra syntax to learn beyond Python.
31
+
32
+ ```python
33
+ from ren_ui import Application, Event, Window, Column, Row, Label, TextInput, Button
34
+
35
+ with Application("application/x-vnd.example-hello") as app:
36
+ message = Label("Welcome!")
37
+ name = TextInput(label="Name:")
38
+ greet_button = Button("Say hello")
39
+
40
+ @greet_button.onclick
41
+ def greet(event: Event):
42
+ message.text = f"Hello, {name.text or 'Haiku'}!"
43
+
44
+ Window(app, "Hello", Column(message, name, Row(greet_button, padding=0))).show()
45
+ app.run()
46
+ ```
47
+
48
+ ## Documentation
49
+
50
+ **<https://ren-ui-python.rainygirl.com/>** - reference and tutorials in English, 한국어, 日本語, Italiano and Français.
51
+
52
+ ## Requirements
53
+
54
+ - Running apps and building packages: RenkuOS or Haiku (x86_64, 32-bit x86 hybrid, arm64), Python 3.11 or newer with a shared `libpython`, and a C++17 compiler (`ren doctor` checks the tools).
55
+ - Other systems (macOS, Linux, Windows): the package installs and you can create, check and stage projects, but windows cannot be shown.
56
+
57
+ ## Install
58
+
59
+ ```sh
60
+ pip install ren-ui-python
61
+ ```
62
+
63
+ ## Command line
64
+
65
+ ```sh
66
+ ren init my_app # create a project
67
+ ren check my_app # validate the manifest and sources (any OS)
68
+ ren doctor # check the build tools
69
+ ren run my_app # compile the bridge and run the app
70
+ ren build my_app # produce an installable .hpkg
71
+ ```
72
+
73
+ A built package depends on the system's Python library (for example `lib:libpython3.12`), so `pkgman install` pulls in Python automatically.
74
+
75
+ ## What is included
76
+
77
+ - Widgets: `Label`, `Button`, `TextInput`, `TextArea`, `CheckBox`, `Slider`, `Image`, laid out with `Column` and `Row`
78
+ - `ren_ui.system`: alerts and confirmations, native open/save file panels, clipboard, opening URLs, the settings directory
79
+ - The `ren` tool: project scaffold, validation, HPKG packaging with app signature, icon and Deskbar entry
80
+
81
+ ## License
82
+
83
+ MIT
@@ -0,0 +1,20 @@
1
+ ren_ui/__init__.py,sha256=4Y1PmeU5j23zmuEHzlCcVUcnN6soSUkltOBTM45klyo,432
2
+ ren_ui/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ ren_ui/_native.py,sha256=KTHsIGLHG5fVcSdfhfTS0_mHME3TjTtnKE78zRGG8WE,2907
4
+ ren_ui/app.py,sha256=9ZoOBPUeXzFMAnWclgi6W4gsxS6gP6ZQc3nDSN0wepw,5637
5
+ ren_ui/build.py,sha256=zw_5m7AiFHw1JcpQVXvI-z8QklIg_plv8rbAHA9t2Us,13039
6
+ ren_ui/cli.py,sha256=btDNi185_zFyjcQGUfHrz-ela1C1YACAEIylimr59rk,6701
7
+ ren_ui/project.py,sha256=9H-d_2km-gF6CMUk4dUJkLNDHp_Mpgie-TKkZ4XqX8U,9976
8
+ ren_ui/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ ren_ui/resources.py,sha256=9DL2ioFogrd_Fqn4mqzUi2_gTS5qE_at4CR7qMy-HuM,1208
10
+ ren_ui/system.py,sha256=CyXttiKEd7Yns2ziwmQYWGvBszpflB-NJLndwn9Amdw,2605
11
+ ren_ui/widgets.py,sha256=Q0xpmKvvF5GlNxnwiu7k5L_I32vjWQQtR7dUyjT1_q4,12829
12
+ ren_ui/native/bridge.cpp,sha256=px_hwpqsHJoCkjaBX6YZShjnH8TEy6_j5aJZD0P3hpo,25738
13
+ ren_ui/native/icon.cpp,sha256=SD8zUQiI7umRVhR1QZxjZpE_PKmomCVlIkRaE60xBRo,3180
14
+ ren_ui/native/launcher.cpp,sha256=BTx4AZeCwb1grZNlqKTlo5FJ0kaM3nEmCfQH8S74LbM,2371
15
+ ren_ui_python-0.1.0.dist-info/licenses/LICENSE,sha256=UalO_7O5rJWiGc4bYF0OH8J9Qhs5dfHtxWFulh_BKjQ,1083
16
+ ren_ui_python-0.1.0.dist-info/METADATA,sha256=p5a3gvfXogEoVaGusZzEv0tXve6BMpdHaMyUQTzkxhM,3119
17
+ ren_ui_python-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
18
+ ren_ui_python-0.1.0.dist-info/entry_points.txt,sha256=LOkVNW7u9QKiSoTpTn4D-PBn7SuVlws5Gs9wHCPZM2g,65
19
+ ren_ui_python-0.1.0.dist-info/top_level.txt,sha256=9NpBXuu4pH2npjBvFuTgZhg-QhPeshZxZByoj3WUDSg,7
20
+ ren_ui_python-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ ren = ren_ui.cli:main
3
+ ren-ui = ren_ui.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ren-ui-python contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ ren_ui