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