protonic 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.
- protonic/__init__.py +10 -0
- protonic/app.py +278 -0
- protonic/backend_tk.py +1188 -0
- protonic/node.py +77 -0
- protonic/parser.py +42 -0
- protonic/physics.py +91 -0
- protonic/proxy.py +249 -0
- protonic/viewport3d.py +480 -0
- protonic-0.1.0.dist-info/METADATA +91 -0
- protonic-0.1.0.dist-info/RECORD +13 -0
- protonic-0.1.0.dist-info/WHEEL +5 -0
- protonic-0.1.0.dist-info/licenses/LICENSE +21 -0
- protonic-0.1.0.dist-info/top_level.txt +1 -0
protonic/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Protonic: Instant OS App Prototyping & Declarative UI Engine.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .app import App, load, bg
|
|
6
|
+
from .proxy import ElementProxy
|
|
7
|
+
from .node import Node
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
__all__ = ["App", "load", "bg", "ElementProxy", "Node", "__version__"]
|
protonic/app.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Proton Application Engine & Event Loop Bridge.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
import queue
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Dict, Any, Callable, Optional, Union
|
|
10
|
+
|
|
11
|
+
from .node import Node
|
|
12
|
+
from .parser import parse_xml
|
|
13
|
+
from .proxy import ElementProxy
|
|
14
|
+
from .backend_tk import TkBackend
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def bg(func: Callable) -> Callable:
|
|
18
|
+
"""
|
|
19
|
+
Decorator that runs a handler function in a dedicated background worker thread,
|
|
20
|
+
keeping the GUI thread and 60fps rendering completely silky and unblocked.
|
|
21
|
+
"""
|
|
22
|
+
def wrapper(*args, **kwargs):
|
|
23
|
+
thread = threading.Thread(target=func, args=args, kwargs=kwargs, daemon=True)
|
|
24
|
+
thread.start()
|
|
25
|
+
return thread
|
|
26
|
+
return wrapper
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class App:
|
|
30
|
+
"""
|
|
31
|
+
Main Proton Application runtime.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, xml_source: Optional[Union[str, Path]] = None, xml: Optional[Union[str, Path]] = None):
|
|
35
|
+
source = xml if xml is not None else xml_source
|
|
36
|
+
self._xml_source = source
|
|
37
|
+
self.root_node: Optional[Node] = None
|
|
38
|
+
self._ready_handlers: list = []
|
|
39
|
+
self._click_handlers: Dict[str, Callable] = {}
|
|
40
|
+
self._change_handlers: Dict[str, Callable] = {}
|
|
41
|
+
self._shortcuts: Dict[str, Callable] = {}
|
|
42
|
+
self._event_queue: queue.Queue = queue.Queue()
|
|
43
|
+
self._backend: Optional[TkBackend] = None
|
|
44
|
+
self._proxies: Dict[str, ElementProxy] = {}
|
|
45
|
+
|
|
46
|
+
if source is not None:
|
|
47
|
+
self.load(source)
|
|
48
|
+
|
|
49
|
+
def load(self, xml_source: Union[str, Path]) -> "App":
|
|
50
|
+
"""Loads and parses Proton XML source (string or file path)."""
|
|
51
|
+
self._xml_source = xml_source
|
|
52
|
+
self.root_node = parse_xml(xml_source)
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
def register_handle(self, target_id: str, handle: Any) -> None:
|
|
56
|
+
"""Registers a backend widget handle with an element proxy."""
|
|
57
|
+
if target_id in self._proxies:
|
|
58
|
+
self._proxies[target_id]._backend = handle
|
|
59
|
+
else:
|
|
60
|
+
self._proxies[target_id] = ElementProxy(target_id, self, backend_handle=handle)
|
|
61
|
+
|
|
62
|
+
def get_node(self, node_id: str) -> Optional[Node]:
|
|
63
|
+
"""Looks up a Node in the AST by id."""
|
|
64
|
+
if self.root_node is None:
|
|
65
|
+
return None
|
|
66
|
+
return self.root_node.find_by_id(node_id)
|
|
67
|
+
|
|
68
|
+
def __getitem__(self, selector: str) -> ElementProxy:
|
|
69
|
+
"""
|
|
70
|
+
Element selector accessor.
|
|
71
|
+
app['#id'] returns an ElementProxy for that widget.
|
|
72
|
+
"""
|
|
73
|
+
target_id = selector.lstrip("#")
|
|
74
|
+
if target_id not in self._proxies:
|
|
75
|
+
handle = self._backend.get_handle(target_id) if self._backend else None
|
|
76
|
+
self._proxies[target_id] = ElementProxy(target_id, self, backend_handle=handle)
|
|
77
|
+
return self._proxies[target_id]
|
|
78
|
+
|
|
79
|
+
def __setitem__(self, selector: str, value: Any) -> None:
|
|
80
|
+
"""
|
|
81
|
+
Syntactic sugar: app['#status'] = 'Active' sets .val or .text.
|
|
82
|
+
"""
|
|
83
|
+
proxy = self[selector]
|
|
84
|
+
node = self.get_node(proxy.id)
|
|
85
|
+
if node and node.tag in ("input", "slider", "switch", "select", "list", "textarea", "progress", "table", "tableview", "treeview"):
|
|
86
|
+
proxy.val = value
|
|
87
|
+
else:
|
|
88
|
+
proxy.text = str(value)
|
|
89
|
+
|
|
90
|
+
# --- Decorators & Event Registrations ---
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def bg(func: Callable) -> Callable:
|
|
94
|
+
"""Decorator for running tasks in a background thread."""
|
|
95
|
+
return bg(func)
|
|
96
|
+
|
|
97
|
+
def action(self, name: str) -> Callable:
|
|
98
|
+
"""Decorator for action handlers mapped via do="action_name": @app.action('do_add')"""
|
|
99
|
+
clean_name = name.strip()
|
|
100
|
+
def decorator(func: Callable) -> Callable:
|
|
101
|
+
self._click_handlers[clean_name] = func
|
|
102
|
+
return func
|
|
103
|
+
return decorator
|
|
104
|
+
|
|
105
|
+
def click(self, selector: str) -> Callable:
|
|
106
|
+
"""Decorator for button / widget click events: @app.click('#submit')"""
|
|
107
|
+
target_id = selector.lstrip("#")
|
|
108
|
+
def decorator(func: Callable) -> Callable:
|
|
109
|
+
self._click_handlers[target_id] = func
|
|
110
|
+
return func
|
|
111
|
+
return decorator
|
|
112
|
+
|
|
113
|
+
def double_click(self, selector: str) -> Callable:
|
|
114
|
+
"""Decorator for double click events on tables/lists/trees: @app.double_click('#files_table')"""
|
|
115
|
+
target_id = selector.lstrip("#")
|
|
116
|
+
specific_key = f"{target_id}:double_click"
|
|
117
|
+
def decorator(func: Callable) -> Callable:
|
|
118
|
+
self._click_handlers[specific_key] = func
|
|
119
|
+
return func
|
|
120
|
+
return decorator
|
|
121
|
+
|
|
122
|
+
def submit(self, selector: str) -> Callable:
|
|
123
|
+
"""Decorator for enter/submit events on inputs: @app.submit('#path_input')"""
|
|
124
|
+
target_id = selector.lstrip("#")
|
|
125
|
+
specific_key = f"{target_id}:submit"
|
|
126
|
+
def decorator(func: Callable) -> Callable:
|
|
127
|
+
self._click_handlers[specific_key] = func
|
|
128
|
+
return func
|
|
129
|
+
return decorator
|
|
130
|
+
|
|
131
|
+
def change(self, selector: str) -> Callable:
|
|
132
|
+
"""Decorator for value change events (slider, switch, input): @app.change('#slider')"""
|
|
133
|
+
target_id = selector.lstrip("#")
|
|
134
|
+
def decorator(func: Callable) -> Callable:
|
|
135
|
+
self._change_handlers[target_id] = func
|
|
136
|
+
return func
|
|
137
|
+
return decorator
|
|
138
|
+
|
|
139
|
+
def shortcut(self, hotkey: str) -> Callable:
|
|
140
|
+
"""
|
|
141
|
+
Decorator to bind a global keyboard accelerator / hotkey.
|
|
142
|
+
Example:
|
|
143
|
+
@app.shortcut('Ctrl+S')
|
|
144
|
+
@app.key('F5')
|
|
145
|
+
@app.key('Escape')
|
|
146
|
+
"""
|
|
147
|
+
def decorator(func: Callable) -> Callable:
|
|
148
|
+
self.register_shortcut(hotkey, func)
|
|
149
|
+
return func
|
|
150
|
+
return decorator
|
|
151
|
+
|
|
152
|
+
key = shortcut # Universal alias: @app.key('Ctrl+Q')
|
|
153
|
+
|
|
154
|
+
def register_shortcut(self, hotkey: str, handler: Callable) -> None:
|
|
155
|
+
"""Registers a global keyboard shortcut with the runtime and active backend."""
|
|
156
|
+
clean = hotkey.strip()
|
|
157
|
+
self._shortcuts[clean] = handler
|
|
158
|
+
if self._backend and self._backend.root:
|
|
159
|
+
self._backend.bind_shortcut(clean, handler)
|
|
160
|
+
|
|
161
|
+
# --- Internal Event Triggers (called by Backend) ---
|
|
162
|
+
|
|
163
|
+
def trigger_click(self, node: Node) -> None:
|
|
164
|
+
"""Dispatches click event to registered handler and/or node 'do' attribute."""
|
|
165
|
+
if node.id and node.id in self._click_handlers:
|
|
166
|
+
self._click_handlers[node.id]()
|
|
167
|
+
if node.do and node.do in self._click_handlers:
|
|
168
|
+
self._click_handlers[node.do]()
|
|
169
|
+
|
|
170
|
+
def trigger_action(self, node: Node, event_type: str, *args, **kwargs) -> None:
|
|
171
|
+
"""Dispatches specialized events (e.g. double_click, submit)."""
|
|
172
|
+
specific_key = f"{node.id}:{event_type}" if node.id else None
|
|
173
|
+
if specific_key and specific_key in self._click_handlers:
|
|
174
|
+
try:
|
|
175
|
+
self._click_handlers[specific_key](*args, **kwargs)
|
|
176
|
+
except TypeError:
|
|
177
|
+
self._click_handlers[specific_key]()
|
|
178
|
+
elif node.id and node.id in self._click_handlers:
|
|
179
|
+
try:
|
|
180
|
+
self._click_handlers[node.id](*args, **kwargs)
|
|
181
|
+
except TypeError:
|
|
182
|
+
self._click_handlers[node.id]()
|
|
183
|
+
elif node.do and node.do in self._click_handlers:
|
|
184
|
+
try:
|
|
185
|
+
self._click_handlers[node.do](*args, **kwargs)
|
|
186
|
+
except TypeError:
|
|
187
|
+
self._click_handlers[node.do]()
|
|
188
|
+
|
|
189
|
+
def trigger_change(self, node: Node, val: Any) -> None:
|
|
190
|
+
"""Dispatches change event to registered handler."""
|
|
191
|
+
if node.id and node.id in self._change_handlers:
|
|
192
|
+
self._change_handlers[node.id](val)
|
|
193
|
+
|
|
194
|
+
def ready(self, func: Callable) -> Callable:
|
|
195
|
+
"""Decorator for lifecycle callbacks executed when UI is mapped and ready: @app.ready"""
|
|
196
|
+
self._ready_handlers.append(func)
|
|
197
|
+
return func
|
|
198
|
+
|
|
199
|
+
def trigger_ready(self) -> None:
|
|
200
|
+
"""Dispatches ready events to registered startup handlers."""
|
|
201
|
+
for handler in self._ready_handlers:
|
|
202
|
+
try:
|
|
203
|
+
handler()
|
|
204
|
+
except Exception as e:
|
|
205
|
+
print(f"[Proton Ready Error] {e}", file=sys.stderr)
|
|
206
|
+
|
|
207
|
+
# --- Threading & Main Dispatch ---
|
|
208
|
+
|
|
209
|
+
def dispatch_to_main(self, action: Callable[[], None]) -> None:
|
|
210
|
+
"""Queues an action to be safely executed on the main GUI thread."""
|
|
211
|
+
if self._backend:
|
|
212
|
+
if threading.current_thread() is threading.main_thread():
|
|
213
|
+
action()
|
|
214
|
+
else:
|
|
215
|
+
self._backend.post_action(action)
|
|
216
|
+
else:
|
|
217
|
+
action()
|
|
218
|
+
|
|
219
|
+
# --- Standard Dialogs ---
|
|
220
|
+
|
|
221
|
+
def alert(self, title: str, message: str) -> None:
|
|
222
|
+
"""Shows an alert modal dialog."""
|
|
223
|
+
if self._backend:
|
|
224
|
+
self._backend.show_alert(title, message)
|
|
225
|
+
else:
|
|
226
|
+
print(f"[ALERT: {title}] {message}")
|
|
227
|
+
|
|
228
|
+
def confirm(self, title: str, message: str) -> bool:
|
|
229
|
+
"""Shows a confirmation modal dialog (Yes/No). Returns True if confirmed."""
|
|
230
|
+
if self._backend:
|
|
231
|
+
return self._backend.show_confirm(title, message)
|
|
232
|
+
return True
|
|
233
|
+
|
|
234
|
+
def open_file(self, title: str = "Open File", filetypes: Optional[list] = None) -> Optional[str]:
|
|
235
|
+
"""Shows native open file dialog. Returns selected file path or None."""
|
|
236
|
+
if self._backend:
|
|
237
|
+
return self._backend.show_open_file(title, filetypes)
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
def save_file(self, title: str = "Save File", default_extension: str = "", filetypes: Optional[list] = None) -> Optional[str]:
|
|
241
|
+
"""Shows native save file dialog. Returns selected file path or None."""
|
|
242
|
+
if self._backend:
|
|
243
|
+
return self._backend.show_save_file(title, default_extension, filetypes)
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
def choose_dir(self, title: str = "Select Directory") -> Optional[str]:
|
|
247
|
+
"""Shows native directory picker dialog. Returns selected folder path or None."""
|
|
248
|
+
if self._backend:
|
|
249
|
+
return self._backend.show_choose_dir(title)
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
def after(self, ms: int, func: Callable) -> Optional[str]:
|
|
253
|
+
"""Schedules a function to run after ms milliseconds on the main thread."""
|
|
254
|
+
if self._backend and hasattr(self._backend, "root") and self._backend.root:
|
|
255
|
+
return self._backend.root.after(ms, func)
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
def cancel_after(self, after_id: Optional[str]) -> None:
|
|
259
|
+
"""Cancels a scheduled after timer."""
|
|
260
|
+
if after_id and self._backend and hasattr(self._backend, "root") and self._backend.root:
|
|
261
|
+
try:
|
|
262
|
+
self._backend.root.after_cancel(after_id)
|
|
263
|
+
except Exception:
|
|
264
|
+
pass
|
|
265
|
+
|
|
266
|
+
# --- Run Loop ---
|
|
267
|
+
|
|
268
|
+
def run(self) -> None:
|
|
269
|
+
"""Starts the native Proton window and enters the GUI event loop."""
|
|
270
|
+
if self.root_node is None:
|
|
271
|
+
raise RuntimeError("Cannot run Proton App: No XML UI loaded.")
|
|
272
|
+
self._backend = TkBackend(self)
|
|
273
|
+
self._backend.render_and_run(self.root_node)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def load(xml_source: Union[str, Path]) -> App:
|
|
277
|
+
"""Convenience helper to load an XML layout and return an App instance."""
|
|
278
|
+
return App(xml_source)
|