vp6 0.4.3__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.
- vp6/__init__.py +37 -0
- vp6/_props.py +108 -0
- vp6/app.py +300 -0
- vp6/appearance.py +248 -0
- vp6/colors.py +62 -0
- vp6/constants.py +214 -0
- vp6/controls.py +5145 -0
- vp6/dialogs.py +72 -0
- vp6/form.py +924 -0
- vp6/formfile.py +444 -0
- vp6/ide/__init__.py +1 -0
- vp6/ide/__main__.py +5 -0
- vp6/ide/chrome.py +385 -0
- vp6/ide/codeeditor.py +812 -0
- vp6/ide/designer.py +1414 -0
- vp6/ide/dialogs.py +321 -0
- vp6/ide/documents.py +138 -0
- vp6/ide/findreplace.py +362 -0
- vp6/ide/icons.py +470 -0
- vp6/ide/images/vp6logo.png +0 -0
- vp6/ide/kitchensink.py +215 -0
- vp6/ide/mainwindow.py +1738 -0
- vp6/ide/menueditor.py +350 -0
- vp6/ide/options.py +330 -0
- vp6/ide/outline.py +307 -0
- vp6/ide/outputcapture.py +125 -0
- vp6/ide/panels.py +1024 -0
- vp6/ide/projectprops.py +250 -0
- vp6/ide/properties.py +387 -0
- vp6/ide/splash.py +95 -0
- vp6/ide/templates/kitchensink/Form1.py +254 -0
- vp6/ide/templates/kitchensink/Module1.py +14 -0
- vp6/ide/templates/kitchensink/frmDialog.py +40 -0
- vp6/ide/templates/kitchensink/pgArrays.py +41 -0
- vp6/ide/templates/kitchensink/pgButtons.py +110 -0
- vp6/ide/templates/kitchensink/pgDialogs.py +51 -0
- vp6/ide/templates/kitchensink/pgEmbedded.py +67 -0
- vp6/ide/templates/kitchensink/pgGlobals.py +65 -0
- vp6/ide/templates/kitchensink/pgIntro.py +29 -0
- vp6/ide/templates/kitchensink/pgKeyboard.py +58 -0
- vp6/ide/templates/kitchensink/pgLayout.py +36 -0
- vp6/ide/templates/kitchensink/pgListView.py +92 -0
- vp6/ide/templates/kitchensink/pgLists.py +121 -0
- vp6/ide/templates/kitchensink/pgMenus.py +43 -0
- vp6/ide/templates/kitchensink/pgMouse.py +45 -0
- vp6/ide/templates/kitchensink/pgPictures.py +50 -0
- vp6/ide/templates/kitchensink/pgSchemes.py +43 -0
- vp6/ide/templates/kitchensink/pgScrollBars.py +41 -0
- vp6/ide/templates/kitchensink/pgScrolling.py +35 -0
- vp6/ide/templates/kitchensink/pgTabs.py +96 -0
- vp6/ide/templates/kitchensink/pgText.py +75 -0
- vp6/ide/templates/kitchensink/pgTimer.py +46 -0
- vp6/ide/templates/kitchensink/pgTree.py +67 -0
- vp6/ide/templates/kitchensink/pgValues.py +108 -0
- vp6/ide/templates/kitchensink/pgZOrder.py +61 -0
- vp6/ide/theme.py +336 -0
- vp6/images/vp6icon-128x128.png +0 -0
- vp6/images/vp6icon-256x256.png +0 -0
- vp6/images/vp6icon-32x32.png +0 -0
- vp6/images/vp6icon-64x64.png +0 -0
- vp6/make.py +219 -0
- vp6/project.py +442 -0
- vp6/runner.py +99 -0
- vp6-0.4.3.dist-info/METADATA +279 -0
- vp6-0.4.3.dist-info/RECORD +69 -0
- vp6-0.4.3.dist-info/WHEEL +5 -0
- vp6-0.4.3.dist-info/entry_points.txt +4 -0
- vp6-0.4.3.dist-info/licenses/LICENSE +21 -0
- vp6-0.4.3.dist-info/top_level.txt +1 -0
vp6/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""VP6 - VB6-style GUI programming for Python.
|
|
2
|
+
|
|
3
|
+
from vp6 import *
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .appearance import (vpSchemeDark, vpSchemeIDE, vpSchemeLight, vpSchemeProjectDefault,
|
|
7
|
+
vpSchemeSystem)
|
|
8
|
+
from .app import App, Beep, Clipboard, Debug, DoEvents, End, Screen
|
|
9
|
+
from .colors import (RGB, QBColor, vpBlack, vpBlue, vpCyan, vpGreen, vpMagenta, vpRed,
|
|
10
|
+
vpWhite, vpYellow)
|
|
11
|
+
from .constants import * # noqa: F403
|
|
12
|
+
from .constants import __all__ as _constants_all
|
|
13
|
+
from .controls import (Button, CheckBox, ColumnHeader, ComboBox, CommandButton, Control,
|
|
14
|
+
ControlArray, Frame, HScrollBar, Image, ImageList, Label, Line, ListBox,
|
|
15
|
+
ListImage, ListItem, ListView, Menu, Node, OptionButton,
|
|
16
|
+
Panel, PictureBox, ProgressBar, Slider, Splitter, StatusBar, Tab,
|
|
17
|
+
TabStrip, TextBox, Timer, Toolbar, TreeView, UpDown, VScrollBar)
|
|
18
|
+
from .dialogs import InputBox, MsgBox
|
|
19
|
+
from .form import Form, Forms, Load, Unload, run
|
|
20
|
+
|
|
21
|
+
__version__ = "0.4.3"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"App", "Beep", "Clipboard", "Debug", "DoEvents", "End", "Screen",
|
|
25
|
+
"RGB", "QBColor", "vpBlack", "vpBlue", "vpCyan", "vpGreen", "vpMagenta", "vpRed",
|
|
26
|
+
"vpWhite", "vpYellow",
|
|
27
|
+
"Button", "CheckBox", "ColumnHeader", "ComboBox", "CommandButton", "Control", "ControlArray", "Frame", "HScrollBar",
|
|
28
|
+
"Image", "ImageList", "Label", "Line", "ListBox", "ListImage", "ListItem", "ListView",
|
|
29
|
+
"Menu", "Node",
|
|
30
|
+
"OptionButton", "PictureBox",
|
|
31
|
+
"Panel", "ProgressBar", "Slider", "Splitter", "StatusBar", "Tab", "TabStrip", "TextBox",
|
|
32
|
+
"Timer", "Toolbar", "TreeView", "UpDown", "VScrollBar",
|
|
33
|
+
"InputBox", "MsgBox",
|
|
34
|
+
"Form", "Forms", "Load", "Unload", "run",
|
|
35
|
+
"vpSchemeProjectDefault", "vpSchemeSystem", "vpSchemeLight", "vpSchemeDark", "vpSchemeIDE",
|
|
36
|
+
*_constants_all,
|
|
37
|
+
]
|
vp6/_props.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Declarative VB-style properties.
|
|
2
|
+
|
|
3
|
+
Every form/control class lists its designable properties as ``PropSpec``
|
|
4
|
+
entries. For each one a Python property is generated that:
|
|
5
|
+
|
|
6
|
+
* normalizes the assigned value (``kind``),
|
|
7
|
+
* stores it in ``self._values``,
|
|
8
|
+
* calls ``self._apply_<Name>(value)`` so the class can update the Qt widget.
|
|
9
|
+
|
|
10
|
+
A class can define ``_read_<Name>()`` when the live value comes from the
|
|
11
|
+
widget (e.g. the text the user typed into a TextBox).
|
|
12
|
+
|
|
13
|
+
The same metadata drives the IDE: the property grid, the designer region
|
|
14
|
+
serializer and default-value elision.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
|
|
21
|
+
from . import colors
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class PropSpec:
|
|
26
|
+
name: str
|
|
27
|
+
# str, text, int, bool, enum, color, font, file, shortcut; list and outline (lines
|
|
28
|
+
# of text); panels, tabs, images, buttons, columns and listitems (lines of
|
|
29
|
+
# text that are a collection at run time: StatusBar.Panels, TabStrip.Tabs, ...)
|
|
30
|
+
kind: str = "str"
|
|
31
|
+
default: object = None
|
|
32
|
+
choices: tuple = () # enum: ((value, "label"), ...)
|
|
33
|
+
always: bool = False # always written to the designer region
|
|
34
|
+
description: str = ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def P(name, kind="str", default=None, choices=(), always=False, description=""):
|
|
38
|
+
return PropSpec(name, kind, default, tuple(choices), always, description)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def enum_choices(*labels: str) -> tuple:
|
|
42
|
+
"""enum_choices("None", "Fixed Single") -> ((0, "0 - None"), (1, "1 - Fixed Single"))"""
|
|
43
|
+
return tuple((i, f"{i} - {label}") for i, label in enumerate(labels))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def normalize(kind: str, value):
|
|
47
|
+
if value is None:
|
|
48
|
+
return None
|
|
49
|
+
if kind in ("str", "text", "file", "font", "shortcut"):
|
|
50
|
+
return str(value)
|
|
51
|
+
if kind in ("int", "enum"):
|
|
52
|
+
return int(value)
|
|
53
|
+
if kind == "bool":
|
|
54
|
+
return bool(value)
|
|
55
|
+
if kind == "color":
|
|
56
|
+
return colors.normalize(value)
|
|
57
|
+
if kind in ("list", "outline", "panels", "tabs", "images", "buttons", "columns",
|
|
58
|
+
"listitems"):
|
|
59
|
+
if isinstance(value, str):
|
|
60
|
+
value = value.splitlines()
|
|
61
|
+
return [str(v) for v in value]
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _make_property(name: str) -> property:
|
|
66
|
+
def fget(self):
|
|
67
|
+
reader = getattr(self, "_read_" + name, None)
|
|
68
|
+
if reader is not None:
|
|
69
|
+
return reader()
|
|
70
|
+
return self._values.get(name, self._specs[name].default)
|
|
71
|
+
|
|
72
|
+
def fset(self, value):
|
|
73
|
+
self._set_prop(name, value)
|
|
74
|
+
|
|
75
|
+
return property(fget, fset, doc=f"VB property {name}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class PropertyHost:
|
|
79
|
+
Properties: tuple[PropSpec, ...] = ()
|
|
80
|
+
_specs: dict[str, PropSpec] = {}
|
|
81
|
+
|
|
82
|
+
def __init_subclass__(cls, **kwargs):
|
|
83
|
+
super().__init_subclass__(**kwargs)
|
|
84
|
+
cls._specs = {spec.name: spec for spec in cls.Properties}
|
|
85
|
+
for spec in cls.Properties:
|
|
86
|
+
if not isinstance(getattr(cls, spec.name, None), property):
|
|
87
|
+
setattr(cls, spec.name, _make_property(spec.name))
|
|
88
|
+
|
|
89
|
+
def _init_values(self, props: dict) -> None:
|
|
90
|
+
"""Apply defaults, then the given property values, in spec order."""
|
|
91
|
+
unknown = set(props) - set(self._specs)
|
|
92
|
+
if unknown:
|
|
93
|
+
raise AttributeError(
|
|
94
|
+
f"{type(self).__name__} has no properties: {', '.join(sorted(unknown))}")
|
|
95
|
+
for spec in self.Properties:
|
|
96
|
+
self._set_prop(spec.name, props.get(spec.name, spec.default))
|
|
97
|
+
|
|
98
|
+
def _set_prop(self, name: str, value) -> None:
|
|
99
|
+
spec = self._specs[name]
|
|
100
|
+
value = normalize(spec.kind, value)
|
|
101
|
+
self._values[name] = value
|
|
102
|
+
applier = getattr(self, "_apply_" + name, None)
|
|
103
|
+
if applier is not None:
|
|
104
|
+
applier(value)
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def properties(cls) -> tuple[PropSpec, ...]:
|
|
108
|
+
return cls.Properties
|
vp6/app.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Application level services: the Qt application, App/Screen/Clipboard
|
|
2
|
+
objects, DoEvents, End and the VB style run-time error handling."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import inspect
|
|
7
|
+
import os
|
|
8
|
+
import signal
|
|
9
|
+
import socket
|
|
10
|
+
import sys
|
|
11
|
+
import threading
|
|
12
|
+
import traceback
|
|
13
|
+
|
|
14
|
+
from PySide6.QtCore import QEventLoop, QObject, QSocketNotifier, QTimer
|
|
15
|
+
from PySide6.QtGui import QGuiApplication, QIcon
|
|
16
|
+
from PySide6.QtWidgets import QApplication, QDialog, QMessageBox
|
|
17
|
+
|
|
18
|
+
_interrupt_handler = None # installed by ensure_app() for VP6 programs
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def ensure_app() -> QApplication:
|
|
22
|
+
"""Return the QApplication, creating it on first use. A VP6 program
|
|
23
|
+
creates it here, so this is also where Ctrl+C gets wired to closing the
|
|
24
|
+
program's forms, and where it gets the VP6 icon (until the project's own
|
|
25
|
+
icon replaces it, see set_program_icon). (The IDE and the tests create
|
|
26
|
+
their own QApplication and keep their own Ctrl+C behavior.)"""
|
|
27
|
+
global _interrupt_handler
|
|
28
|
+
app = QApplication.instance()
|
|
29
|
+
if app is None:
|
|
30
|
+
app = QApplication(sys.argv[:1])
|
|
31
|
+
_interrupt_handler = install_interrupt_handler(close_all_windows)
|
|
32
|
+
app.setWindowIcon(vp6_icon())
|
|
33
|
+
return app
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# --- icons -----------------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
# The VP6 icon (project.VP6_ICON_FILES, several sizes): the IDE's icon, and
|
|
39
|
+
# every program's until its project names its own
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def icon_from(paths) -> QIcon:
|
|
43
|
+
"""An icon from image files (the sizes of one picture); missing files are
|
|
44
|
+
skipped, so the icon may be null."""
|
|
45
|
+
icon = QIcon()
|
|
46
|
+
for path in paths:
|
|
47
|
+
if os.path.isfile(path):
|
|
48
|
+
icon.addFile(path)
|
|
49
|
+
return icon
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def vp6_icon() -> QIcon:
|
|
53
|
+
from .project import VP6_ICON_FILES
|
|
54
|
+
|
|
55
|
+
return icon_from(VP6_ICON_FILES)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def set_program_icon(paths) -> bool:
|
|
59
|
+
"""Make these image files the program's icon (its windows, and the Dock or
|
|
60
|
+
taskbar). Returns False, keeping the icon it had, if none can be read."""
|
|
61
|
+
app = ensure_app() # first: Qt can't read images before there is an application
|
|
62
|
+
icon = icon_from(paths)
|
|
63
|
+
if icon.isNull():
|
|
64
|
+
return False
|
|
65
|
+
app.setWindowIcon(icon)
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# --- Ctrl+C -----------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
class InterruptHandler(QObject):
|
|
72
|
+
"""Makes Ctrl+C (SIGINT) in the terminal run ``action`` in a Qt program.
|
|
73
|
+
|
|
74
|
+
Python runs signal handlers only when it gets control back, and Qt's
|
|
75
|
+
event loop can stay in C++ indefinitely, so SIGINT would be ignored. The
|
|
76
|
+
signal module writes to a socket on every signal (``set_wakeup_fd``); a
|
|
77
|
+
socket notifier on it returns control to Python, which then runs the
|
|
78
|
+
handler. Before ``action``, any open modal window (a MsgBox, a dialog, a
|
|
79
|
+
form shown with vpModal) is closed, since it would block. A repeated
|
|
80
|
+
Ctrl+C while ``action`` is still running (e.g. a Form_Unload asking
|
|
81
|
+
"Close?") is ignored."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, action, parent=None):
|
|
84
|
+
super().__init__(parent)
|
|
85
|
+
self._action = action
|
|
86
|
+
self._busy = False
|
|
87
|
+
self._receive, self._send = socket.socketpair()
|
|
88
|
+
for sock in (self._receive, self._send):
|
|
89
|
+
sock.setblocking(False)
|
|
90
|
+
signal.set_wakeup_fd(self._send.fileno())
|
|
91
|
+
signal.signal(signal.SIGINT, self._on_interrupt)
|
|
92
|
+
self._notifier = QSocketNotifier(self._receive.fileno(), QSocketNotifier.Read, self)
|
|
93
|
+
self._notifier.activated.connect(self._drain)
|
|
94
|
+
|
|
95
|
+
def _drain(self, *_):
|
|
96
|
+
try:
|
|
97
|
+
self._receive.recv(64)
|
|
98
|
+
except OSError:
|
|
99
|
+
pass
|
|
100
|
+
# Back in Python: the pending SIGINT handler runs now
|
|
101
|
+
|
|
102
|
+
def _on_interrupt(self, signum, frame):
|
|
103
|
+
QTimer.singleShot(0, self, self._run)
|
|
104
|
+
|
|
105
|
+
def _run(self):
|
|
106
|
+
if self._busy:
|
|
107
|
+
return
|
|
108
|
+
modal = QApplication.activeModalWidget()
|
|
109
|
+
if modal is not None:
|
|
110
|
+
# Close it, then continue once its event loop has returned
|
|
111
|
+
modal.reject() if isinstance(modal, QDialog) else modal.close()
|
|
112
|
+
QTimer.singleShot(0, self, self._run)
|
|
113
|
+
return
|
|
114
|
+
self._busy = True
|
|
115
|
+
try:
|
|
116
|
+
self._action()
|
|
117
|
+
finally:
|
|
118
|
+
self._busy = False
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def install_interrupt_handler(action, parent=None) -> InterruptHandler | None:
|
|
122
|
+
"""Run ``action`` on Ctrl+C. Returns None where signals can't be handled
|
|
123
|
+
(not the main thread)."""
|
|
124
|
+
if threading.current_thread() is not threading.main_thread():
|
|
125
|
+
return None
|
|
126
|
+
try:
|
|
127
|
+
return InterruptHandler(action, parent)
|
|
128
|
+
except (ValueError, OSError):
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def close_all_windows() -> None:
|
|
133
|
+
"""Ctrl+C in a VP6 program: close its windows like their close buttons
|
|
134
|
+
would, so each Form_Unload runs (and may cancel). When the last one
|
|
135
|
+
closes, the program ends."""
|
|
136
|
+
app = QApplication.instance()
|
|
137
|
+
windows = [w for w in app.topLevelWidgets() if w.isVisible()]
|
|
138
|
+
if not windows:
|
|
139
|
+
app.quit() # an event loop without windows: just stop it
|
|
140
|
+
return
|
|
141
|
+
for window in windows:
|
|
142
|
+
window.close()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def DoEvents() -> None:
|
|
146
|
+
"""Process pending GUI events, like VB6's DoEvents."""
|
|
147
|
+
app = QApplication.instance()
|
|
148
|
+
if app is not None:
|
|
149
|
+
app.processEvents(QEventLoop.AllEvents)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def Beep() -> None:
|
|
153
|
+
ensure_app()
|
|
154
|
+
QApplication.beep()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def End() -> None:
|
|
158
|
+
"""Terminate the program immediately, like VB6's End statement: no form
|
|
159
|
+
gets Form_Unload (closing the windows would run it, so they aren't
|
|
160
|
+
closed; the process just ends)."""
|
|
161
|
+
sys.stdout.flush()
|
|
162
|
+
sys.stderr.flush()
|
|
163
|
+
os._exit(0)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class _App:
|
|
167
|
+
"""The VB ``App`` object."""
|
|
168
|
+
|
|
169
|
+
Title = ""
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def Path(self) -> str:
|
|
173
|
+
main = sys.modules.get("__main__")
|
|
174
|
+
path = getattr(main, "__file__", None)
|
|
175
|
+
return os.path.dirname(os.path.abspath(path)) if path else os.getcwd()
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def EXEName(self) -> str:
|
|
179
|
+
return self.Title or os.path.splitext(os.path.basename(sys.argv[0] or "app"))[0]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class _Screen:
|
|
183
|
+
"""The VB ``Screen`` object (sizes in pixels)."""
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def Width(self) -> int:
|
|
187
|
+
ensure_app()
|
|
188
|
+
return QGuiApplication.primaryScreen().size().width()
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def Height(self) -> int:
|
|
192
|
+
ensure_app()
|
|
193
|
+
return QGuiApplication.primaryScreen().size().height()
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def ActiveForm(self):
|
|
197
|
+
widget = QApplication.activeWindow()
|
|
198
|
+
return getattr(widget, "_vp_form", None)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class _Clipboard:
|
|
202
|
+
"""The VB ``Clipboard`` object."""
|
|
203
|
+
|
|
204
|
+
def GetText(self) -> str:
|
|
205
|
+
ensure_app()
|
|
206
|
+
return QGuiApplication.clipboard().text()
|
|
207
|
+
|
|
208
|
+
def SetText(self, text: str) -> None:
|
|
209
|
+
ensure_app()
|
|
210
|
+
QGuiApplication.clipboard().setText(str(text))
|
|
211
|
+
|
|
212
|
+
def Clear(self) -> None:
|
|
213
|
+
ensure_app()
|
|
214
|
+
QGuiApplication.clipboard().clear()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class _Debug:
|
|
218
|
+
"""``Debug.Print`` writes to the IDE's Immediate window (stdout)."""
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def Print(*values) -> None:
|
|
222
|
+
print(*values, flush=True)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
App = _App()
|
|
226
|
+
Screen = _Screen()
|
|
227
|
+
Clipboard = _Clipboard()
|
|
228
|
+
Debug = _Debug()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# --- event handler invocation -------------------------------------------------
|
|
232
|
+
|
|
233
|
+
_arity_cache: dict = {}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _positional_capacity(func) -> int | None:
|
|
237
|
+
"""How many positional arguments ``func`` (a bound method) accepts;
|
|
238
|
+
None means unlimited."""
|
|
239
|
+
key = getattr(func, "__func__", func)
|
|
240
|
+
if key in _arity_cache:
|
|
241
|
+
return _arity_cache[key]
|
|
242
|
+
try:
|
|
243
|
+
params = inspect.signature(func).parameters.values()
|
|
244
|
+
except (TypeError, ValueError):
|
|
245
|
+
result = None
|
|
246
|
+
else:
|
|
247
|
+
if any(p.kind == p.VAR_POSITIONAL for p in params):
|
|
248
|
+
result = None
|
|
249
|
+
else:
|
|
250
|
+
result = sum(1 for p in params
|
|
251
|
+
if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD))
|
|
252
|
+
_arity_cache[key] = result
|
|
253
|
+
return result
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def call_handler(handler, *args):
|
|
257
|
+
"""Call an event handler, passing only as many arguments as it declares.
|
|
258
|
+
|
|
259
|
+
``def Command1_MouseDown(self, Button, Shift, X, Y)`` and
|
|
260
|
+
``def Command1_MouseDown(self)`` both work. Exceptions are reported as VB
|
|
261
|
+
style run-time errors instead of silently vanishing inside Qt.
|
|
262
|
+
"""
|
|
263
|
+
capacity = _positional_capacity(handler)
|
|
264
|
+
if capacity is not None:
|
|
265
|
+
args = args[:capacity]
|
|
266
|
+
try:
|
|
267
|
+
return handler(*args)
|
|
268
|
+
except SystemExit:
|
|
269
|
+
raise
|
|
270
|
+
except Exception as exc: # noqa: BLE001 - this is the top-level error trap
|
|
271
|
+
report_runtime_error(exc)
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def report_runtime_error(exc: BaseException) -> None:
|
|
276
|
+
traceback.print_exception(exc, file=sys.stderr)
|
|
277
|
+
sys.stderr.flush()
|
|
278
|
+
if os.environ.get("VP6_NO_ERROR_DIALOG"):
|
|
279
|
+
return
|
|
280
|
+
box = QMessageBox()
|
|
281
|
+
box.setIcon(QMessageBox.Critical)
|
|
282
|
+
box.setWindowTitle(App.EXEName or "VP6")
|
|
283
|
+
box.setText(f"Run-time error:\n\n{type(exc).__name__}: {exc}")
|
|
284
|
+
box.setDetailedText("".join(traceback.format_exception(exc)))
|
|
285
|
+
end_button = box.addButton("End", QMessageBox.DestructiveRole)
|
|
286
|
+
box.addButton("Continue", QMessageBox.AcceptRole)
|
|
287
|
+
box.exec()
|
|
288
|
+
if box.clickedButton() is end_button:
|
|
289
|
+
End()
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def run_event_loop() -> int:
|
|
293
|
+
"""Run the GUI event loop while any window is open."""
|
|
294
|
+
app = QApplication.instance()
|
|
295
|
+
if app is None or not any(w.isVisible() for w in app.topLevelWidgets()):
|
|
296
|
+
return 0
|
|
297
|
+
return app.exec()
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
__all__ = ["App", "Screen", "Clipboard", "Debug", "DoEvents", "Beep", "End"]
|
vp6/appearance.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Light / dark color schemes for forms.
|
|
2
|
+
|
|
3
|
+
``Form.ColorScheme`` is one of:
|
|
4
|
+
|
|
5
|
+
* ``vpSchemeProjectDefault`` (0) - use the project's scheme (the default),
|
|
6
|
+
* ``vpSchemeSystem`` (1) - native look, follows the OS appearance live,
|
|
7
|
+
* ``vpSchemeLight`` (2) / ``vpSchemeDark`` (3) - forced light or dark,
|
|
8
|
+
* ``vpSchemeIDE`` (4) - follow the VP6 IDE's appearance: live in the
|
|
9
|
+
designer; when run from the IDE, the IDE's appearance at launch (passed in
|
|
10
|
+
the ``VP6_IDE_SCHEME`` environment variable); otherwise System.
|
|
11
|
+
|
|
12
|
+
Native styles (notably macOS) ignore widget palettes, so forced schemes use
|
|
13
|
+
Qt's Fusion style with a fixed light or dark palette on the form, its
|
|
14
|
+
controls and the message boxes shown over it.
|
|
15
|
+
|
|
16
|
+
The project scheme is set by the runner from the ``.vp6p`` file. When a
|
|
17
|
+
form runs on its own (``python Form1.py``) it is read from a ``.vp6p`` next
|
|
18
|
+
to the form's file, if there is one.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import glob
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
|
|
27
|
+
import shiboken6
|
|
28
|
+
from PySide6.QtGui import QColor, QGuiApplication, QPalette
|
|
29
|
+
from PySide6.QtWidgets import QStyle, QStyleFactory, QWidget
|
|
30
|
+
|
|
31
|
+
vpSchemeProjectDefault = 0
|
|
32
|
+
vpSchemeSystem = 1
|
|
33
|
+
vpSchemeLight = 2
|
|
34
|
+
vpSchemeDark = 3
|
|
35
|
+
vpSchemeIDE = 4
|
|
36
|
+
|
|
37
|
+
PROJECT_SCHEMES = {"system": vpSchemeSystem, "light": vpSchemeLight, "dark": vpSchemeDark,
|
|
38
|
+
"ide": vpSchemeIDE}
|
|
39
|
+
SCHEME_NAMES = {v: k for k, v in PROJECT_SCHEMES.items()}
|
|
40
|
+
IDE_SCHEME_ENV = "VP6_IDE_SCHEME"
|
|
41
|
+
|
|
42
|
+
# Set by the IDE (inside the IDE process): returns System, Light or Dark
|
|
43
|
+
ide_scheme_provider = None
|
|
44
|
+
|
|
45
|
+
# Set by the project runner; None = look for a .vp6p next to the form
|
|
46
|
+
project_scheme: int | None = None
|
|
47
|
+
_found_schemes: dict[str, int] = {}
|
|
48
|
+
_fusion = None # see fusion_style
|
|
49
|
+
_fusion_address = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def scheme_from_name(name: str | None) -> int:
|
|
53
|
+
return PROJECT_SCHEMES.get((name or "system").lower(), vpSchemeSystem)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def ide_scheme() -> int:
|
|
57
|
+
"""What vpSchemeIDE currently means: System, Light or Dark."""
|
|
58
|
+
if ide_scheme_provider is not None:
|
|
59
|
+
return ide_scheme_provider()
|
|
60
|
+
scheme = PROJECT_SCHEMES.get(os.environ.get(IDE_SCHEME_ENV, "").lower(), vpSchemeSystem)
|
|
61
|
+
return scheme if scheme != vpSchemeIDE else vpSchemeSystem
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def resolve(scheme: int) -> int:
|
|
65
|
+
"""Turn any scheme except Project Default into System, Light or Dark."""
|
|
66
|
+
if scheme == vpSchemeIDE:
|
|
67
|
+
scheme = ide_scheme()
|
|
68
|
+
return scheme if scheme in (vpSchemeLight, vpSchemeDark) else vpSchemeSystem
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def project_scheme_for(directory: str) -> int:
|
|
72
|
+
"""The project scheme for a form in ``directory``."""
|
|
73
|
+
if project_scheme is not None:
|
|
74
|
+
return project_scheme
|
|
75
|
+
if directory not in _found_schemes:
|
|
76
|
+
scheme = vpSchemeSystem
|
|
77
|
+
from .project import Project
|
|
78
|
+
|
|
79
|
+
for path in sorted(glob.glob(os.path.join(directory, "*.vp6p"))):
|
|
80
|
+
try:
|
|
81
|
+
scheme = scheme_from_name(Project.load(path).color_scheme)
|
|
82
|
+
break
|
|
83
|
+
except (OSError, ValueError):
|
|
84
|
+
continue
|
|
85
|
+
_found_schemes[directory] = scheme
|
|
86
|
+
return _found_schemes[directory]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _qt_scheme_is_dark() -> bool:
|
|
90
|
+
from PySide6.QtCore import Qt
|
|
91
|
+
|
|
92
|
+
scheme = QGuiApplication.styleHints().colorScheme()
|
|
93
|
+
if scheme == Qt.ColorScheme.Dark:
|
|
94
|
+
return True
|
|
95
|
+
if scheme == Qt.ColorScheme.Light:
|
|
96
|
+
return False
|
|
97
|
+
return QGuiApplication.palette().color(QPalette.Window).lightness() < 128
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# The IDE can force the whole application light or dark. Qt then reports the
|
|
101
|
+
# forced scheme, so the real OS appearance is queried from the OS instead.
|
|
102
|
+
_override_active = False
|
|
103
|
+
_os_dark_before_override = False
|
|
104
|
+
_os_query_cache: tuple[float, bool] | None = None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def set_app_override(active: bool) -> None:
|
|
108
|
+
"""Called by the IDE before it forces (or stops forcing) the app scheme."""
|
|
109
|
+
global _override_active, _os_dark_before_override, _os_query_cache
|
|
110
|
+
if active and not _override_active:
|
|
111
|
+
_os_dark_before_override = _qt_scheme_is_dark()
|
|
112
|
+
_override_active = active
|
|
113
|
+
_os_query_cache = None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def app_override_active() -> bool:
|
|
117
|
+
return _override_active
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _query_os_dark() -> bool:
|
|
121
|
+
global _os_query_cache
|
|
122
|
+
import time
|
|
123
|
+
|
|
124
|
+
now = time.monotonic()
|
|
125
|
+
if _os_query_cache and now - _os_query_cache[0] < 2:
|
|
126
|
+
return _os_query_cache[1]
|
|
127
|
+
dark = _os_dark_before_override
|
|
128
|
+
try:
|
|
129
|
+
if sys.platform == "darwin":
|
|
130
|
+
import subprocess
|
|
131
|
+
|
|
132
|
+
result = subprocess.run(["defaults", "read", "-g", "AppleInterfaceStyle"],
|
|
133
|
+
capture_output=True, text=True, timeout=1)
|
|
134
|
+
dark = result.stdout.strip().lower() == "dark"
|
|
135
|
+
elif sys.platform == "win32":
|
|
136
|
+
import winreg
|
|
137
|
+
|
|
138
|
+
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows"
|
|
139
|
+
r"\CurrentVersion\Themes\Personalize")
|
|
140
|
+
dark = winreg.QueryValueEx(key, "AppsUseLightTheme")[0] == 0
|
|
141
|
+
except Exception: # noqa: BLE001 - fall back to the last known appearance
|
|
142
|
+
pass
|
|
143
|
+
_os_query_cache = (now, dark)
|
|
144
|
+
return dark
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def system_is_dark() -> bool:
|
|
148
|
+
"""Whether the operating system appearance is dark."""
|
|
149
|
+
return _query_os_dark() if _override_active else _qt_scheme_is_dark()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def is_dark(scheme: int) -> bool:
|
|
153
|
+
"""Whether a resolved scheme (System/Light/Dark) renders dark right now."""
|
|
154
|
+
return scheme == vpSchemeDark or (scheme == vpSchemeSystem and system_is_dark())
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def fusion_style():
|
|
158
|
+
"""One shared Fusion style, a child of the application so it outlives
|
|
159
|
+
every widget using it. Don't keep what this returns: call it again.
|
|
160
|
+
|
|
161
|
+
PySide treats a style as belonging to the widgets it is set on, and when
|
|
162
|
+
such a widget is destroyed (e.g. a Toolbar's buttons being rebuilt) it
|
|
163
|
+
invalidates the style's Python wrapper, although the C++ style lives on
|
|
164
|
+
and other widgets still use it. So the style is remembered by its C++
|
|
165
|
+
address, and a fresh wrapper is found among the application's children
|
|
166
|
+
when the old one was invalidated."""
|
|
167
|
+
global _fusion, _fusion_address
|
|
168
|
+
if _fusion is not None and shiboken6.isValid(_fusion):
|
|
169
|
+
return _fusion
|
|
170
|
+
app = QGuiApplication.instance()
|
|
171
|
+
if _fusion_address is not None:
|
|
172
|
+
for child in app.children(): # the same style: a new wrapper
|
|
173
|
+
if isinstance(child, QStyle) and shiboken6.getCppPointer(child)[0] == \
|
|
174
|
+
_fusion_address:
|
|
175
|
+
_fusion = child
|
|
176
|
+
return _fusion
|
|
177
|
+
_fusion = QStyleFactory.create("Fusion")
|
|
178
|
+
_fusion.setParent(app)
|
|
179
|
+
_fusion_address = shiboken6.getCppPointer(_fusion)[0]
|
|
180
|
+
return _fusion
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _palette(colors: dict, disabled: dict) -> QPalette:
|
|
184
|
+
palette = QPalette()
|
|
185
|
+
for role, color in colors.items():
|
|
186
|
+
palette.setColor(role, QColor(color))
|
|
187
|
+
for role, color in disabled.items():
|
|
188
|
+
palette.setColor(QPalette.Disabled, role, QColor(color))
|
|
189
|
+
return palette
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def scheme_palette(dark: bool) -> QPalette:
|
|
193
|
+
"""A complete palette for a forced light or dark scheme."""
|
|
194
|
+
if dark:
|
|
195
|
+
return _palette({
|
|
196
|
+
QPalette.Window: "#353535", QPalette.WindowText: "#f0f0f0",
|
|
197
|
+
QPalette.Base: "#252525", QPalette.AlternateBase: "#303030",
|
|
198
|
+
QPalette.Text: "#f0f0f0", QPalette.Button: "#3c3c3c",
|
|
199
|
+
QPalette.ButtonText: "#f0f0f0", QPalette.BrightText: "#ff5555",
|
|
200
|
+
QPalette.Light: "#505050", QPalette.Midlight: "#454545", QPalette.Mid: "#2a2a2a",
|
|
201
|
+
QPalette.Dark: "#1e1e1e", QPalette.Shadow: "#141414",
|
|
202
|
+
QPalette.Highlight: "#2a82da", QPalette.HighlightedText: "#ffffff",
|
|
203
|
+
QPalette.Link: "#5aa9f0", QPalette.LinkVisited: "#b48ef0",
|
|
204
|
+
QPalette.ToolTipBase: "#454545", QPalette.ToolTipText: "#f0f0f0",
|
|
205
|
+
QPalette.PlaceholderText: "#8a8a8a",
|
|
206
|
+
}, {QPalette.WindowText: "#7f7f7f", QPalette.Text: "#7f7f7f",
|
|
207
|
+
QPalette.ButtonText: "#7f7f7f", QPalette.Base: "#2d2d2d"})
|
|
208
|
+
return _palette({
|
|
209
|
+
QPalette.Window: "#efefef", QPalette.WindowText: "#000000",
|
|
210
|
+
QPalette.Base: "#ffffff", QPalette.AlternateBase: "#f7f7f7",
|
|
211
|
+
QPalette.Text: "#000000", QPalette.Button: "#efefef",
|
|
212
|
+
QPalette.ButtonText: "#000000", QPalette.BrightText: "#ffffff",
|
|
213
|
+
QPalette.Light: "#ffffff", QPalette.Midlight: "#cacaca", QPalette.Mid: "#b8b8b8",
|
|
214
|
+
QPalette.Dark: "#9f9f9f", QPalette.Shadow: "#767676",
|
|
215
|
+
QPalette.Highlight: "#308cc6", QPalette.HighlightedText: "#ffffff",
|
|
216
|
+
QPalette.Link: "#0000ff", QPalette.LinkVisited: "#ff00ff",
|
|
217
|
+
QPalette.ToolTipBase: "#ffffdc", QPalette.ToolTipText: "#000000",
|
|
218
|
+
QPalette.PlaceholderText: "#7f7f7f",
|
|
219
|
+
}, {QPalette.WindowText: "#bebebe", QPalette.Text: "#bebebe",
|
|
220
|
+
QPalette.ButtonText: "#bebebe", QPalette.Base: "#efefef"})
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def style_tree(widget: QWidget, style) -> None:
|
|
224
|
+
"""Apply ``style`` (None = the application style) to a widget and all its
|
|
225
|
+
descendants; Qt doesn't propagate styles to children."""
|
|
226
|
+
from PySide6.QtCore import Qt
|
|
227
|
+
|
|
228
|
+
for w in [widget, *widget.findChildren(QWidget)]:
|
|
229
|
+
if style is None:
|
|
230
|
+
if w.testAttribute(Qt.WA_SetStyle):
|
|
231
|
+
w.setStyle(None)
|
|
232
|
+
else:
|
|
233
|
+
w.setStyle(style)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def match_dialog(dialog: QWidget, parent: QWidget | None) -> None:
|
|
237
|
+
"""Give a message box the color scheme of the form it is shown over."""
|
|
238
|
+
form = getattr(parent.window(), "_vp_form", None) if parent is not None else None
|
|
239
|
+
if form is None:
|
|
240
|
+
return
|
|
241
|
+
scheme = form._effective_scheme()
|
|
242
|
+
if scheme in (vpSchemeLight, vpSchemeDark):
|
|
243
|
+
dialog.setPalette(scheme_palette(scheme == vpSchemeDark))
|
|
244
|
+
style_tree(dialog, fusion_style())
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
__all__ = ["vpSchemeProjectDefault", "vpSchemeSystem", "vpSchemeLight", "vpSchemeDark",
|
|
248
|
+
"vpSchemeIDE"]
|