iskg 0.3.8__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.
- iskg/__init__.py +106 -0
- iskg/_vendor.py +66 -0
- iskg/_version.py +1 -0
- iskg/app.py +510 -0
- iskg/base.py +796 -0
- iskg/py.typed +0 -0
- iskg/template.py +216 -0
- iskg/theme.py +910 -0
- iskg/themes.py +209 -0
- iskg/vars.py +102 -0
- iskg/widgets/__init__.py +82 -0
- iskg/widgets/_canvas.py +253 -0
- iskg/widgets/_containers.py +442 -0
- iskg/widgets/_controls.py +798 -0
- iskg/widgets/_data.py +306 -0
- iskg/widgets/_dialogs.py +147 -0
- iskg/widgets/_display.py +453 -0
- iskg/widgets/_menus.py +224 -0
- iskg/widgets/_misc.py +51 -0
- iskg/widgets/_text.py +183 -0
- iskg-0.3.8.dist-info/METADATA +176 -0
- iskg-0.3.8.dist-info/RECORD +25 -0
- iskg-0.3.8.dist-info/WHEEL +5 -0
- iskg-0.3.8.dist-info/licenses/LICENSE +674 -0
- iskg-0.3.8.dist-info/top_level.txt +1 -0
iskg/__init__.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""ISKG — IFAZ Widget Toolkit.
|
|
2
|
+
|
|
3
|
+
A Python widget framework with tactical IFAZ theme, rendering widgets
|
|
4
|
+
as HTML/CSS/JS inside a native window via pywebview.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from ._version import VERSION
|
|
8
|
+
from .app import Application, Window
|
|
9
|
+
from .base import Widget
|
|
10
|
+
from .vars import BooleanVar, DoubleVar, IntVar, StringVar, Variable
|
|
11
|
+
from .widgets import (
|
|
12
|
+
Button,
|
|
13
|
+
Canvas,
|
|
14
|
+
CheckBox,
|
|
15
|
+
ComboBox,
|
|
16
|
+
DataGrid,
|
|
17
|
+
DropTarget,
|
|
18
|
+
Entry,
|
|
19
|
+
FileDialog,
|
|
20
|
+
Frame,
|
|
21
|
+
IconLabel,
|
|
22
|
+
ImageBox,
|
|
23
|
+
IndicatorLED,
|
|
24
|
+
Knob,
|
|
25
|
+
Label,
|
|
26
|
+
LEDDisplay,
|
|
27
|
+
ListBox,
|
|
28
|
+
Menu,
|
|
29
|
+
MenuBar,
|
|
30
|
+
MessageDialog,
|
|
31
|
+
Notebook,
|
|
32
|
+
PanedWindow,
|
|
33
|
+
ProgressBar,
|
|
34
|
+
RadialGauge,
|
|
35
|
+
RadioButton,
|
|
36
|
+
RichText,
|
|
37
|
+
Scale,
|
|
38
|
+
ScrollBar,
|
|
39
|
+
ScrolledFrame,
|
|
40
|
+
Separator,
|
|
41
|
+
Slider,
|
|
42
|
+
Spacer,
|
|
43
|
+
SpinBox,
|
|
44
|
+
StatusBar,
|
|
45
|
+
Text,
|
|
46
|
+
ToggleSwitch,
|
|
47
|
+
Tooltip,
|
|
48
|
+
TreeView,
|
|
49
|
+
showerror,
|
|
50
|
+
showinfo,
|
|
51
|
+
showquestion,
|
|
52
|
+
showwarning,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"VERSION",
|
|
57
|
+
"Widget",
|
|
58
|
+
"Label",
|
|
59
|
+
"Button",
|
|
60
|
+
"Entry",
|
|
61
|
+
"CheckBox",
|
|
62
|
+
"RadioButton",
|
|
63
|
+
"ComboBox",
|
|
64
|
+
"Slider",
|
|
65
|
+
"ProgressBar",
|
|
66
|
+
"Frame",
|
|
67
|
+
"ScrolledFrame",
|
|
68
|
+
"PanedWindow",
|
|
69
|
+
"ListBox",
|
|
70
|
+
"ScrollBar",
|
|
71
|
+
"Text",
|
|
72
|
+
"SpinBox",
|
|
73
|
+
"Separator",
|
|
74
|
+
"Notebook",
|
|
75
|
+
"Canvas",
|
|
76
|
+
"Scale",
|
|
77
|
+
"MessageDialog",
|
|
78
|
+
"Knob",
|
|
79
|
+
"LEDDisplay",
|
|
80
|
+
"DataGrid",
|
|
81
|
+
"IndicatorLED",
|
|
82
|
+
"RadialGauge",
|
|
83
|
+
"ToggleSwitch",
|
|
84
|
+
"StatusBar",
|
|
85
|
+
"Tooltip",
|
|
86
|
+
"Spacer",
|
|
87
|
+
"ImageBox",
|
|
88
|
+
"IconLabel",
|
|
89
|
+
"RichText",
|
|
90
|
+
"TreeView",
|
|
91
|
+
"DropTarget",
|
|
92
|
+
"Menu",
|
|
93
|
+
"MenuBar",
|
|
94
|
+
"FileDialog",
|
|
95
|
+
"Variable",
|
|
96
|
+
"StringVar",
|
|
97
|
+
"IntVar",
|
|
98
|
+
"DoubleVar",
|
|
99
|
+
"BooleanVar",
|
|
100
|
+
"showinfo",
|
|
101
|
+
"showwarning",
|
|
102
|
+
"showerror",
|
|
103
|
+
"showquestion",
|
|
104
|
+
"Application",
|
|
105
|
+
"Window",
|
|
106
|
+
]
|
iskg/_vendor.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
_VENDOR_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "_vendor")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _ensure_vendor_dir() -> str:
|
|
11
|
+
os.makedirs(_VENDOR_DIR, exist_ok=True)
|
|
12
|
+
return _VENDOR_DIR
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def try_import(module: str, package: str = "") -> None:
|
|
16
|
+
"""Import *module*, auto-installing it locally if missing.
|
|
17
|
+
|
|
18
|
+
Installs into ``_vendor/`` at project root so the project remains
|
|
19
|
+
self-contained (portable). Only pure-Python wheels are guaranteed;
|
|
20
|
+
platform-native dependencies (GTK, WebKit) must be pre-installed.
|
|
21
|
+
"""
|
|
22
|
+
try:
|
|
23
|
+
__import__(module)
|
|
24
|
+
return
|
|
25
|
+
except ImportError:
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
pkg = package or module
|
|
29
|
+
vendor = _ensure_vendor_dir()
|
|
30
|
+
|
|
31
|
+
if vendor not in sys.path:
|
|
32
|
+
sys.path.insert(0, vendor)
|
|
33
|
+
|
|
34
|
+
# Check again after adding to path
|
|
35
|
+
try:
|
|
36
|
+
__import__(module)
|
|
37
|
+
return
|
|
38
|
+
except ImportError:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
print(f"ISKG: Installing {pkg} locally...")
|
|
42
|
+
try:
|
|
43
|
+
subprocess.check_call(
|
|
44
|
+
[
|
|
45
|
+
sys.executable,
|
|
46
|
+
"-m",
|
|
47
|
+
"pip",
|
|
48
|
+
"install",
|
|
49
|
+
"--target",
|
|
50
|
+
vendor,
|
|
51
|
+
pkg,
|
|
52
|
+
],
|
|
53
|
+
stdout=subprocess.DEVNULL,
|
|
54
|
+
stderr=subprocess.STDOUT,
|
|
55
|
+
)
|
|
56
|
+
except subprocess.CalledProcessError as exc:
|
|
57
|
+
print(
|
|
58
|
+
f"ISKG: Failed to install {pkg}. "
|
|
59
|
+
f"Try: pip install {pkg}\n"
|
|
60
|
+
f"Error: {exc}",
|
|
61
|
+
file=sys.stderr,
|
|
62
|
+
)
|
|
63
|
+
sys.exit(1)
|
|
64
|
+
|
|
65
|
+
print(f"ISKG: {pkg} installed locally.")
|
|
66
|
+
__import__(module)
|
iskg/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
VERSION = "0.3.7"
|
iskg/app.py
ADDED
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
"""Application and window management, JS bridge, and file dialogs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import threading
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .base import Widget
|
|
13
|
+
from .template import build_html
|
|
14
|
+
from .theme import IFAZ_CSS
|
|
15
|
+
|
|
16
|
+
_HANDLERS: dict[str, Callable] = {}
|
|
17
|
+
_LOCK = threading.Lock()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _JSAPI:
|
|
21
|
+
_in_progress: set[tuple[str, str, str | None]] = set()
|
|
22
|
+
|
|
23
|
+
def on_event(
|
|
24
|
+
self,
|
|
25
|
+
widget_id: str,
|
|
26
|
+
event_name: str,
|
|
27
|
+
event_data_json: str | None,
|
|
28
|
+
) -> None:
|
|
29
|
+
key = (widget_id, event_name, event_data_json)
|
|
30
|
+
with _LOCK:
|
|
31
|
+
if key in self._in_progress:
|
|
32
|
+
return
|
|
33
|
+
self._in_progress.add(key)
|
|
34
|
+
try:
|
|
35
|
+
handler = _HANDLERS.get(widget_id)
|
|
36
|
+
if handler:
|
|
37
|
+
try:
|
|
38
|
+
data: Any = json.loads(event_data_json) if event_data_json else None
|
|
39
|
+
except json.JSONDecodeError:
|
|
40
|
+
data = event_data_json
|
|
41
|
+
handler(event_name, data)
|
|
42
|
+
finally:
|
|
43
|
+
with _LOCK:
|
|
44
|
+
self._in_progress.discard(key)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_JSAPI_INSTANCE = _JSAPI()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Application:
|
|
51
|
+
"""Main application entry point.
|
|
52
|
+
|
|
53
|
+
Creates a native window via pywebview, renders all widgets as HTML/CSS/JS,
|
|
54
|
+
and manages the JS bridge for event handling.
|
|
55
|
+
|
|
56
|
+
Usage::
|
|
57
|
+
|
|
58
|
+
app = Application("My App", 800, 600)
|
|
59
|
+
label = Label(text="Hello")
|
|
60
|
+
app.add(label)
|
|
61
|
+
app.run()
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
title: str = "ISKG App",
|
|
67
|
+
width: int = 800,
|
|
68
|
+
height: int = 600,
|
|
69
|
+
scanlines: bool = True,
|
|
70
|
+
vignette: bool = True,
|
|
71
|
+
theme: str = "ifaz",
|
|
72
|
+
) -> None:
|
|
73
|
+
self._title = title
|
|
74
|
+
self._width = width
|
|
75
|
+
self._height = height
|
|
76
|
+
self._scanlines = scanlines
|
|
77
|
+
self._vignette = vignette
|
|
78
|
+
self._theme_name = theme
|
|
79
|
+
|
|
80
|
+
self._root_widgets: list[Widget] = []
|
|
81
|
+
self._running = False
|
|
82
|
+
self._on_close_callbacks: list[Callable] = []
|
|
83
|
+
self._window: Any = None
|
|
84
|
+
|
|
85
|
+
def add(self, widget: Widget) -> Widget:
|
|
86
|
+
"""Register a root-level widget with the application.
|
|
87
|
+
|
|
88
|
+
Widgets must be added to the app before ``run()`` is called.
|
|
89
|
+
"""
|
|
90
|
+
if widget not in self._root_widgets:
|
|
91
|
+
self._root_widgets.append(widget)
|
|
92
|
+
widget._app = self
|
|
93
|
+
for _, w in widget._collect_widgets():
|
|
94
|
+
w._app = self
|
|
95
|
+
if isinstance(w, Widget):
|
|
96
|
+
_HANDLERS[w._id] = w._handle_bridge_event
|
|
97
|
+
return widget
|
|
98
|
+
|
|
99
|
+
def remove(self, widget: Widget) -> None:
|
|
100
|
+
"""Unregister a root-level widget."""
|
|
101
|
+
if widget in self._root_widgets:
|
|
102
|
+
self._root_widgets.remove(widget)
|
|
103
|
+
|
|
104
|
+
def on_close(self, callback: Callable) -> None:
|
|
105
|
+
"""Register a callback to call when the window is closed."""
|
|
106
|
+
self._on_close_callbacks.append(callback)
|
|
107
|
+
|
|
108
|
+
def title(self, text: str | None = None) -> str:
|
|
109
|
+
"""Get or set the window title."""
|
|
110
|
+
if text is not None:
|
|
111
|
+
self._title = text
|
|
112
|
+
return self._title
|
|
113
|
+
|
|
114
|
+
def geometry(
|
|
115
|
+
self,
|
|
116
|
+
x: int | None = None,
|
|
117
|
+
y: int | None = None,
|
|
118
|
+
w: int | None = None,
|
|
119
|
+
h: int | None = None,
|
|
120
|
+
) -> tuple[int, int, int, int]:
|
|
121
|
+
"""Get or set the window position and size."""
|
|
122
|
+
if w is not None and h is not None:
|
|
123
|
+
self._width = w
|
|
124
|
+
self._height = h
|
|
125
|
+
if x is not None and y is not None:
|
|
126
|
+
self._x = x
|
|
127
|
+
self._y = y
|
|
128
|
+
return (
|
|
129
|
+
getattr(self, "_x", 0),
|
|
130
|
+
getattr(self, "_y", 0),
|
|
131
|
+
self._width,
|
|
132
|
+
self._height,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
_saved_stderr: int | None = None
|
|
136
|
+
|
|
137
|
+
def run(self, extra_js: str = "") -> None:
|
|
138
|
+
"""Open the window and start the application main loop.
|
|
139
|
+
|
|
140
|
+
Blocks until the window is closed. Redirects GTK stderr warnings
|
|
141
|
+
to /dev/null during execution.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
extra_js: additional JavaScript to execute on startup (e.g. tooltip init code).
|
|
145
|
+
"""
|
|
146
|
+
from ._vendor import try_import
|
|
147
|
+
|
|
148
|
+
try_import("webview", "pywebview>=5.0")
|
|
149
|
+
import webview
|
|
150
|
+
|
|
151
|
+
self._running = True
|
|
152
|
+
html = self._build_html(extra_js)
|
|
153
|
+
|
|
154
|
+
self._window = webview.create_window(
|
|
155
|
+
self._title,
|
|
156
|
+
html=html,
|
|
157
|
+
width=self._width,
|
|
158
|
+
height=self._height,
|
|
159
|
+
js_api=_JSAPI_INSTANCE,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
self._saved_stderr = os.dup(2)
|
|
163
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
164
|
+
os.dup2(devnull, 2)
|
|
165
|
+
os.close(devnull)
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
webview.start(private_mode=False, debug=False)
|
|
169
|
+
except Exception as exc:
|
|
170
|
+
if self._saved_stderr is not None:
|
|
171
|
+
os.dup2(self._saved_stderr, 2)
|
|
172
|
+
os.close(self._saved_stderr)
|
|
173
|
+
self._saved_stderr = None
|
|
174
|
+
print(f"ISKG: webview.start() failed: {exc}", file=__import__("sys").stderr)
|
|
175
|
+
__import__("sys").exit(1)
|
|
176
|
+
|
|
177
|
+
self._running = False
|
|
178
|
+
for cb in self._on_close_callbacks:
|
|
179
|
+
cb()
|
|
180
|
+
if self._saved_stderr is not None:
|
|
181
|
+
os.dup2(self._saved_stderr, 2)
|
|
182
|
+
os.close(self._saved_stderr)
|
|
183
|
+
self._saved_stderr = None
|
|
184
|
+
|
|
185
|
+
def _build_html(self, extra_js: str = "") -> str:
|
|
186
|
+
html = build_html(
|
|
187
|
+
self._root_widgets, IFAZ_CSS, extra_js=extra_js, theme_name=self._theme_name
|
|
188
|
+
)
|
|
189
|
+
if not self._scanlines:
|
|
190
|
+
html = html.replace('<div id="iskg-scanlines"></div>', "")
|
|
191
|
+
if not self._vignette:
|
|
192
|
+
html = html.replace('<div id="iskg-vignette"></div>', "")
|
|
193
|
+
return html
|
|
194
|
+
|
|
195
|
+
def _eval_js(self, js: str) -> None:
|
|
196
|
+
if self._window and self._running:
|
|
197
|
+
with contextlib.suppress(Exception):
|
|
198
|
+
self._window.evaluate_js(js)
|
|
199
|
+
|
|
200
|
+
def _widget_destroyed(self, widget_id: str) -> None:
|
|
201
|
+
_HANDLERS.pop(widget_id, None)
|
|
202
|
+
|
|
203
|
+
def winfo_screenwidth(self) -> int:
|
|
204
|
+
"""Return the screen width in pixels (requires a running window)."""
|
|
205
|
+
val = self._eval_js("window.screen.width;") if self._window else None
|
|
206
|
+
return int(val) if val is not None else 0
|
|
207
|
+
|
|
208
|
+
def winfo_screenheight(self) -> int:
|
|
209
|
+
"""Return the screen height in pixels (requires a running window)."""
|
|
210
|
+
val = self._eval_js("window.screen.height;") if self._window else None
|
|
211
|
+
return int(val) if val is not None else 0
|
|
212
|
+
|
|
213
|
+
def winfo_screendpi(self) -> int:
|
|
214
|
+
"""Return the screen DPI (approximate, requires a running window)."""
|
|
215
|
+
val = self._eval_js("window.devicePixelRatio*96;") if self._window else None
|
|
216
|
+
return int(val) if val is not None else 96
|
|
217
|
+
|
|
218
|
+
def set_theme(self, name: str) -> Application:
|
|
219
|
+
"""Switch the UI theme at runtime.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
name: one of ``"ifaz"``, ``"cold"``, ``"warm"``, ``"night"``, or
|
|
223
|
+
a custom name previously registered via :meth:`register_theme`.
|
|
224
|
+
"""
|
|
225
|
+
from .themes import resolve_theme, theme_js
|
|
226
|
+
|
|
227
|
+
resolve_theme(name)
|
|
228
|
+
self._theme_name = name
|
|
229
|
+
self._eval_js(theme_js(name))
|
|
230
|
+
return self
|
|
231
|
+
|
|
232
|
+
def current_theme(self) -> str:
|
|
233
|
+
"""Return the name of the currently active theme."""
|
|
234
|
+
return self._theme_name
|
|
235
|
+
|
|
236
|
+
def register_theme(self, name: str, overrides: dict[str, str]) -> Application:
|
|
237
|
+
"""Register a new theme for runtime use.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
name: unique theme name (e.g. ``"mytheme"``).
|
|
241
|
+
overrides: dict of CSS custom properties,
|
|
242
|
+
e.g. ``{"--bg-primary": "#000", "--text": "#fff"}``.
|
|
243
|
+
"""
|
|
244
|
+
from .themes import THEMES
|
|
245
|
+
|
|
246
|
+
THEMES[name] = dict(overrides)
|
|
247
|
+
import json
|
|
248
|
+
|
|
249
|
+
self._eval_js(f"iskg_register_themes({json.dumps({name: overrides})});")
|
|
250
|
+
return self
|
|
251
|
+
|
|
252
|
+
def execute_js(self, js_code: str) -> Application:
|
|
253
|
+
"""Execute arbitrary JavaScript in the webview window."""
|
|
254
|
+
self._eval_js(js_code)
|
|
255
|
+
return self
|
|
256
|
+
|
|
257
|
+
def quit(self) -> None:
|
|
258
|
+
"""Close the application window and exit the main loop."""
|
|
259
|
+
if self._window:
|
|
260
|
+
self._window.destroy()
|
|
261
|
+
self._running = False
|
|
262
|
+
|
|
263
|
+
def set_clipboard(self, text: str) -> None:
|
|
264
|
+
"""Copy text to the system clipboard (requires pyperclip)."""
|
|
265
|
+
try:
|
|
266
|
+
import pyperclip
|
|
267
|
+
|
|
268
|
+
pyperclip.copy(text)
|
|
269
|
+
except ImportError:
|
|
270
|
+
pass
|
|
271
|
+
|
|
272
|
+
def get_clipboard(self) -> str:
|
|
273
|
+
"""Read text from the system clipboard (requires pyperclip)."""
|
|
274
|
+
try:
|
|
275
|
+
import pyperclip
|
|
276
|
+
|
|
277
|
+
return pyperclip.paste()
|
|
278
|
+
except ImportError:
|
|
279
|
+
return ""
|
|
280
|
+
|
|
281
|
+
def file_dialog(
|
|
282
|
+
self,
|
|
283
|
+
dialog_type: str = "open",
|
|
284
|
+
directory: str = "",
|
|
285
|
+
file_types: list[str] | None = None,
|
|
286
|
+
allow_multiple: bool = False,
|
|
287
|
+
) -> Any | None:
|
|
288
|
+
"""Open a native OS file dialog.
|
|
289
|
+
|
|
290
|
+
Uses GTK directly (same toolkit as pywebview underneath) with
|
|
291
|
+
explicit dialog sizing. Falls back to pywebview's built-in dialog.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
dialog_type: ``"open"``, ``"save"``, or ``"folder"``.
|
|
295
|
+
directory: starting directory path.
|
|
296
|
+
file_types: list of extensions like ``["*.txt", "*.py"]``.
|
|
297
|
+
allow_multiple: allow multiple file selection (open only).
|
|
298
|
+
"""
|
|
299
|
+
result = self._gtk_file_dialog(dialog_type, directory, file_types, allow_multiple)
|
|
300
|
+
if result is not None:
|
|
301
|
+
return result
|
|
302
|
+
try:
|
|
303
|
+
import webview as _wv
|
|
304
|
+
except ImportError:
|
|
305
|
+
return None
|
|
306
|
+
if not self._window or not self._running:
|
|
307
|
+
return None
|
|
308
|
+
dt = _wv.FileDialog.OPEN
|
|
309
|
+
if dialog_type == "save":
|
|
310
|
+
dt = _wv.FileDialog.SAVE
|
|
311
|
+
elif dialog_type == "folder":
|
|
312
|
+
dt = _wv.FileDialog.FOLDER
|
|
313
|
+
return self._window.create_file_dialog(
|
|
314
|
+
dt,
|
|
315
|
+
directory,
|
|
316
|
+
allow_multiple,
|
|
317
|
+
file_types=file_types or (),
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
def _gtk_file_dialog(
|
|
321
|
+
self,
|
|
322
|
+
dialog_type: str,
|
|
323
|
+
directory: str,
|
|
324
|
+
file_types: list[str] | None,
|
|
325
|
+
allow_multiple: bool,
|
|
326
|
+
) -> Any | None:
|
|
327
|
+
try:
|
|
328
|
+
import gi # type: ignore[import-untyped]
|
|
329
|
+
|
|
330
|
+
gi.require_version("Gtk", "3.0")
|
|
331
|
+
from gi.repository import Gtk # type: ignore[import-untyped]
|
|
332
|
+
except (ImportError, ValueError):
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
action_map = {
|
|
336
|
+
"open": Gtk.FileChooserAction.OPEN,
|
|
337
|
+
"save": Gtk.FileChooserAction.SAVE,
|
|
338
|
+
"folder": Gtk.FileChooserAction.SELECT_FOLDER,
|
|
339
|
+
}
|
|
340
|
+
action = action_map.get(dialog_type, Gtk.FileChooserAction.OPEN)
|
|
341
|
+
accept = {
|
|
342
|
+
"open": "_Open",
|
|
343
|
+
"save": "_Save",
|
|
344
|
+
"folder": "_Select",
|
|
345
|
+
}.get(dialog_type, "_Open")
|
|
346
|
+
|
|
347
|
+
dialog = Gtk.FileChooserDialog(
|
|
348
|
+
title="",
|
|
349
|
+
parent=None,
|
|
350
|
+
action=action,
|
|
351
|
+
buttons=(
|
|
352
|
+
"_Cancel",
|
|
353
|
+
Gtk.ResponseType.CANCEL,
|
|
354
|
+
accept,
|
|
355
|
+
Gtk.ResponseType.ACCEPT,
|
|
356
|
+
),
|
|
357
|
+
)
|
|
358
|
+
dialog.set_default_size(700, 500)
|
|
359
|
+
dialog.set_position(Gtk.WindowPosition.CENTER_ALWAYS)
|
|
360
|
+
|
|
361
|
+
if directory:
|
|
362
|
+
dialog.set_current_folder(directory)
|
|
363
|
+
if file_types:
|
|
364
|
+
for ft in file_types:
|
|
365
|
+
filt = Gtk.FileFilter()
|
|
366
|
+
filt.set_name(ft)
|
|
367
|
+
filt.add_pattern(ft)
|
|
368
|
+
dialog.add_filter(filt)
|
|
369
|
+
if allow_multiple:
|
|
370
|
+
dialog.set_select_multiple(True)
|
|
371
|
+
|
|
372
|
+
response = dialog.run()
|
|
373
|
+
if response == Gtk.ResponseType.ACCEPT:
|
|
374
|
+
if dialog_type == "folder":
|
|
375
|
+
result = dialog.get_filename()
|
|
376
|
+
elif allow_multiple:
|
|
377
|
+
result = list(dialog.get_filenames())
|
|
378
|
+
else:
|
|
379
|
+
result = dialog.get_filename()
|
|
380
|
+
else:
|
|
381
|
+
result = None
|
|
382
|
+
|
|
383
|
+
dialog.destroy()
|
|
384
|
+
return result
|
|
385
|
+
|
|
386
|
+
def alert(self, message: str) -> None:
|
|
387
|
+
"""Show a browser-style alert dialog."""
|
|
388
|
+
self._eval_js(f"alert({json.dumps(message)})")
|
|
389
|
+
|
|
390
|
+
def confirm(self, message: str) -> None:
|
|
391
|
+
"""Show a browser-style confirm dialog."""
|
|
392
|
+
self._eval_js(f"confirm({json.dumps(message)})")
|
|
393
|
+
|
|
394
|
+
def _js_eval(self, js: str) -> Any | None:
|
|
395
|
+
"""Evaluate JS and return the result, or None on error."""
|
|
396
|
+
if self._window and self._running:
|
|
397
|
+
try:
|
|
398
|
+
return self._window.evaluate_js(js)
|
|
399
|
+
except Exception:
|
|
400
|
+
pass
|
|
401
|
+
return None
|
|
402
|
+
|
|
403
|
+
def color_dialog(
|
|
404
|
+
self,
|
|
405
|
+
title: str = "Choose Color",
|
|
406
|
+
initial_color: str = "#000000",
|
|
407
|
+
) -> str | None:
|
|
408
|
+
"""Open a color picker dialog.
|
|
409
|
+
|
|
410
|
+
Uses GTK on Linux, falls back to a browser ``<input type="color">``
|
|
411
|
+
on Windows/macOS.
|
|
412
|
+
|
|
413
|
+
Returns a hex string (e.g. ``"#ff8800"``) or ``None``.
|
|
414
|
+
"""
|
|
415
|
+
try:
|
|
416
|
+
import gi # type: ignore[import-untyped]
|
|
417
|
+
|
|
418
|
+
gi.require_version("Gtk", "3.0")
|
|
419
|
+
from gi.repository import Gdk, Gtk # type: ignore[import-untyped]
|
|
420
|
+
except (ImportError, ValueError):
|
|
421
|
+
js = (
|
|
422
|
+
f"(function(){{"
|
|
423
|
+
f"var i=document.createElement('input');i.type='color';"
|
|
424
|
+
f"i.value={json.dumps(initial_color)};"
|
|
425
|
+
f"i.style.position='fixed';i.style.left='-9999px';"
|
|
426
|
+
f"document.body.appendChild(i);"
|
|
427
|
+
f"var p=new Promise(function(r){{"
|
|
428
|
+
f"i.addEventListener('change',function(){{r(i.value);document.body.removeChild(i);}});"
|
|
429
|
+
f"i.addEventListener('blur',function(){{setTimeout(function(){{"
|
|
430
|
+
f"if(document.body.contains(i)){{r(null);document.body.removeChild(i);}}"
|
|
431
|
+
f"}},300);}});"
|
|
432
|
+
f"}});i.click();return p;"
|
|
433
|
+
f"}})()"
|
|
434
|
+
)
|
|
435
|
+
result = self._js_eval(js)
|
|
436
|
+
return result if result else None
|
|
437
|
+
|
|
438
|
+
rgba = Gdk.RGBA()
|
|
439
|
+
rgba.parse(initial_color)
|
|
440
|
+
dialog = Gtk.ColorChooserDialog(title=title, parent=None)
|
|
441
|
+
dialog.set_rgba(rgba)
|
|
442
|
+
dialog.set_use_alpha(False)
|
|
443
|
+
|
|
444
|
+
result: str | None = None
|
|
445
|
+
if dialog.run() == Gtk.ResponseType.OK:
|
|
446
|
+
c = dialog.get_rgba()
|
|
447
|
+
result = f"#{int(c.red * 255):02x}{int(c.green * 255):02x}{int(c.blue * 255):02x}"
|
|
448
|
+
dialog.destroy()
|
|
449
|
+
return result
|
|
450
|
+
|
|
451
|
+
def font_dialog(
|
|
452
|
+
self,
|
|
453
|
+
title: str = "Choose Font",
|
|
454
|
+
initial_font: str = "",
|
|
455
|
+
) -> dict[str, Any] | None:
|
|
456
|
+
"""Open a font picker dialog.
|
|
457
|
+
|
|
458
|
+
Uses GTK on Linux, falls back to a browser prompt on Windows/macOS.
|
|
459
|
+
|
|
460
|
+
Returns a dict with keys ``family``, ``size``, ``weight``, ``style``,
|
|
461
|
+
or ``None``.
|
|
462
|
+
"""
|
|
463
|
+
try:
|
|
464
|
+
import gi # type: ignore[import-untyped]
|
|
465
|
+
|
|
466
|
+
gi.require_version("Gtk", "3.0")
|
|
467
|
+
from gi.repository import Gtk # type: ignore[import-untyped]
|
|
468
|
+
except (ImportError, ValueError):
|
|
469
|
+
js = (
|
|
470
|
+
f"(function(){{"
|
|
471
|
+
f"var r=prompt({json.dumps(title)},{json.dumps(initial_font)});"
|
|
472
|
+
f"if(!r)return null;"
|
|
473
|
+
f"var parts=r.trim().split(/(\\d+)/);"
|
|
474
|
+
f"var family=parts[0].trim()||'Sans';"
|
|
475
|
+
f"var size=parseInt(parts[1],10)||12;"
|
|
476
|
+
f"return JSON.stringify({{family:family,size:size,weight:'normal',style:'normal',_full_name:r}});"
|
|
477
|
+
f"}})()"
|
|
478
|
+
)
|
|
479
|
+
raw = self._js_eval(js)
|
|
480
|
+
if raw:
|
|
481
|
+
return json.loads(raw)
|
|
482
|
+
return None
|
|
483
|
+
|
|
484
|
+
dialog = Gtk.FontChooserDialog(title=title, parent=None)
|
|
485
|
+
if initial_font:
|
|
486
|
+
dialog.set_font_name(initial_font)
|
|
487
|
+
|
|
488
|
+
result: dict[str, Any] | None = None
|
|
489
|
+
if dialog.run() == Gtk.ResponseType.OK:
|
|
490
|
+
font_name = dialog.get_font_name()
|
|
491
|
+
parts = font_name.split()
|
|
492
|
+
family = parts[0] if parts else "Sans"
|
|
493
|
+
size = 12
|
|
494
|
+
weight = "normal"
|
|
495
|
+
style = "normal"
|
|
496
|
+
if len(parts) > 1:
|
|
497
|
+
with contextlib.suppress(ValueError):
|
|
498
|
+
size = int(parts[-1])
|
|
499
|
+
result = {
|
|
500
|
+
"family": family,
|
|
501
|
+
"size": size,
|
|
502
|
+
"weight": weight,
|
|
503
|
+
"style": style,
|
|
504
|
+
"_full_name": font_name,
|
|
505
|
+
}
|
|
506
|
+
dialog.destroy()
|
|
507
|
+
return result
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
Window = Application
|