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 +21 -0
- protonic-0.1.0/PKG-INFO +91 -0
- protonic-0.1.0/README.md +66 -0
- protonic-0.1.0/pyproject.toml +35 -0
- protonic-0.1.0/setup.cfg +4 -0
- protonic-0.1.0/src/protonic/__init__.py +10 -0
- protonic-0.1.0/src/protonic/app.py +278 -0
- protonic-0.1.0/src/protonic/backend_tk.py +1188 -0
- protonic-0.1.0/src/protonic/node.py +77 -0
- protonic-0.1.0/src/protonic/parser.py +42 -0
- protonic-0.1.0/src/protonic/physics.py +91 -0
- protonic-0.1.0/src/protonic/proxy.py +249 -0
- protonic-0.1.0/src/protonic/viewport3d.py +480 -0
- protonic-0.1.0/src/protonic.egg-info/PKG-INFO +91 -0
- protonic-0.1.0/src/protonic.egg-info/SOURCES.txt +25 -0
- protonic-0.1.0/src/protonic.egg-info/dependency_links.txt +1 -0
- protonic-0.1.0/src/protonic.egg-info/top_level.txt +1 -0
- protonic-0.1.0/tests/test_app.py +95 -0
- protonic-0.1.0/tests/test_examples.py +116 -0
- protonic-0.1.0/tests/test_hbox_layout.py +93 -0
- protonic-0.1.0/tests/test_new_primitives.py +139 -0
- protonic-0.1.0/tests/test_parser.py +58 -0
- protonic-0.1.0/tests/test_physics.py +55 -0
- protonic-0.1.0/tests/test_sizing.py +99 -0
- protonic-0.1.0/tests/test_smoke.py +17 -0
- protonic-0.1.0/tests/test_table_and_shortcuts.py +195 -0
- protonic-0.1.0/tests/test_window.py +38 -0
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.
|
protonic-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
```
|
protonic-0.1.0/README.md
ADDED
|
@@ -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"]
|
protonic-0.1.0/setup.cfg
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__"]
|
|
@@ -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)
|