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 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)