protonic 0.1.0__tar.gz

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-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kyle Gallagher
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,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: protonic
3
+ Version: 0.1.0
4
+ Summary: Instant OS App Prototyping & Declarative UI Engine
5
+ Author-email: Kyle Gallagher <kidlucrid@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/dieablo101/protonic
8
+ Project-URL: Documentation, https://dieablo101.github.io/protonic/
9
+ Project-URL: Repository, https://github.com/dieablo101/protonic.git
10
+ Project-URL: Issues, https://github.com/dieablo101/protonic/issues
11
+ Keywords: gui,ui,tkinter,declarative,xml,rapid-prototyping,desktop-app
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: User Interfaces
20
+ Classifier: Operating System :: OS Independent
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Dynamic: license-file
25
+
26
+ # Protonic App Engine
27
+
28
+ **Instant OS App Prototyping & Declarative XML UI Engine for Python.**
29
+
30
+ Protonic eliminates GUI boilerplate. You design your layout using clean, semantic XML and interact with it in Python using simple selectors, properties, and decorators.
31
+
32
+ ---
33
+
34
+ ## Key Pillars
35
+
36
+ 1. **Zero Boilerplate:** No window setup rites, geometry manager wrestling, or manual frame hierarchies.
37
+ 2. **Predictable Layout Physics:** Common-sense defaults (`expand="true"` vs `expand="false"`) with intrinsic content footprint for every widget.
38
+ 3. **Ergonomic Python Selectors:** `app['#id'].val`, `app['#id'].text`, `app['#id'].show()`.
39
+ 4. **Thread-Safe Background Workers:** `@app.bg` executes tasks in worker threads without freezing the 60fps GUI.
40
+ 5. **Universal Sizing Heuristics:** Widgets never collapse to 0px or randomly explode across screens.
41
+
42
+ ---
43
+
44
+ ## Architecture Overview
45
+
46
+ ```text
47
+ Protonic XML (<window>, <vbox>, <hbox>, <card>, <tabs>, ...)
48
+ │
49
+ ▼
50
+ Protonic Parser (AST Node Tree)
51
+ │
52
+ ▼
53
+ Layout Physics & Intrinsic Sizing Heuristics
54
+ │
55
+ ▼
56
+ GUI Backend (Tkinter/TTK Desktop Engine)
57
+ │
58
+ ▲
59
+ Python Bridge (App, ElementProxy, @app.click, @app.bg)
60
+ ```
61
+
62
+ ---
63
+
64
+ ## Sizing Physics & Defaults (`expand`)
65
+
66
+ | Widget | Default `expand` | Intrinsic Footprint / Shrink Behavior |
67
+ |---|---|---|
68
+ | `<button>` | `false` | Shrink-wraps to text length + button padding |
69
+ | `<label>` | `false` | Shrink-wraps to text string |
70
+ | `<input>` | `false` | Default 20 character columns (`chars="20"`) or placeholder |
71
+ | `<slider>`, `<switch>` | `false` | Natural ergonomic control dimensions |
72
+ | `<select>` | `false` | Shrink-wraps to longest option text + arrow |
73
+ | `<image>` | `false` | Native image file resolution (e.g. 128×128) |
74
+ | `<card>` | `false` | Shrink-wraps to inner children + card padding |
75
+ | `<vbox>`, `<hbox>` | `false` | Shrink-wraps to inner children flow |
76
+ | `<tabs>` | `true` | Expands to fill available space |
77
+ | `<scroll>` | `true` | Elastic viewport for scrolling content |
78
+ | `<list>` | `true` | Default 6 rows × 25 chars or expands to container |
79
+ | `<canvas>` | `true` | Default 300×200px or expands to fill |
80
+ | `<textarea>` | `true` | Default 4 rows × 40 cols or expands to fill |
81
+ | `<spacer />` | `true` | Elastic spring absorbing remaining space |
82
+ | `<divider />`| `true` | Expands across parent cross-axis |
83
+
84
+ ---
85
+
86
+ ## Testing
87
+
88
+ Run the test suite:
89
+ ```bash
90
+ python3 -m unittest discover -s tests
91
+ ```
@@ -0,0 +1,66 @@
1
+ # Protonic App Engine
2
+
3
+ **Instant OS App Prototyping & Declarative XML UI Engine for Python.**
4
+
5
+ Protonic eliminates GUI boilerplate. You design your layout using clean, semantic XML and interact with it in Python using simple selectors, properties, and decorators.
6
+
7
+ ---
8
+
9
+ ## Key Pillars
10
+
11
+ 1. **Zero Boilerplate:** No window setup rites, geometry manager wrestling, or manual frame hierarchies.
12
+ 2. **Predictable Layout Physics:** Common-sense defaults (`expand="true"` vs `expand="false"`) with intrinsic content footprint for every widget.
13
+ 3. **Ergonomic Python Selectors:** `app['#id'].val`, `app['#id'].text`, `app['#id'].show()`.
14
+ 4. **Thread-Safe Background Workers:** `@app.bg` executes tasks in worker threads without freezing the 60fps GUI.
15
+ 5. **Universal Sizing Heuristics:** Widgets never collapse to 0px or randomly explode across screens.
16
+
17
+ ---
18
+
19
+ ## Architecture Overview
20
+
21
+ ```text
22
+ Protonic XML (<window>, <vbox>, <hbox>, <card>, <tabs>, ...)
23
+ │
24
+ ▼
25
+ Protonic Parser (AST Node Tree)
26
+ │
27
+ ▼
28
+ Layout Physics & Intrinsic Sizing Heuristics
29
+ │
30
+ ▼
31
+ GUI Backend (Tkinter/TTK Desktop Engine)
32
+ │
33
+ ▲
34
+ Python Bridge (App, ElementProxy, @app.click, @app.bg)
35
+ ```
36
+
37
+ ---
38
+
39
+ ## Sizing Physics & Defaults (`expand`)
40
+
41
+ | Widget | Default `expand` | Intrinsic Footprint / Shrink Behavior |
42
+ |---|---|---|
43
+ | `<button>` | `false` | Shrink-wraps to text length + button padding |
44
+ | `<label>` | `false` | Shrink-wraps to text string |
45
+ | `<input>` | `false` | Default 20 character columns (`chars="20"`) or placeholder |
46
+ | `<slider>`, `<switch>` | `false` | Natural ergonomic control dimensions |
47
+ | `<select>` | `false` | Shrink-wraps to longest option text + arrow |
48
+ | `<image>` | `false` | Native image file resolution (e.g. 128×128) |
49
+ | `<card>` | `false` | Shrink-wraps to inner children + card padding |
50
+ | `<vbox>`, `<hbox>` | `false` | Shrink-wraps to inner children flow |
51
+ | `<tabs>` | `true` | Expands to fill available space |
52
+ | `<scroll>` | `true` | Elastic viewport for scrolling content |
53
+ | `<list>` | `true` | Default 6 rows × 25 chars or expands to container |
54
+ | `<canvas>` | `true` | Default 300×200px or expands to fill |
55
+ | `<textarea>` | `true` | Default 4 rows × 40 cols or expands to fill |
56
+ | `<spacer />` | `true` | Elastic spring absorbing remaining space |
57
+ | `<divider />`| `true` | Expands across parent cross-axis |
58
+
59
+ ---
60
+
61
+ ## Testing
62
+
63
+ Run the test suite:
64
+ ```bash
65
+ python3 -m unittest discover -s tests
66
+ ```
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "protonic"
7
+ version = "0.1.0"
8
+ description = "Instant OS App Prototyping & Declarative UI Engine"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "Kyle Gallagher", email = "kidlucrid@gmail.com" }
14
+ ]
15
+ keywords = ["gui", "ui", "tkinter", "declarative", "xml", "rapid-prototyping", "desktop-app"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Topic :: Software Development :: User Interfaces",
25
+ "Operating System :: OS Independent",
26
+ ]
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/dieablo101/protonic"
30
+ Documentation = "https://dieablo101.github.io/protonic/"
31
+ Repository = "https://github.com/dieablo101/protonic.git"
32
+ Issues = "https://github.com/dieablo101/protonic/issues"
33
+
34
+ [tool.setuptools.packages.find]
35
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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__"]
@@ -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)