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.
Files changed (69) hide show
  1. vp6/__init__.py +37 -0
  2. vp6/_props.py +108 -0
  3. vp6/app.py +300 -0
  4. vp6/appearance.py +248 -0
  5. vp6/colors.py +62 -0
  6. vp6/constants.py +214 -0
  7. vp6/controls.py +5145 -0
  8. vp6/dialogs.py +72 -0
  9. vp6/form.py +924 -0
  10. vp6/formfile.py +444 -0
  11. vp6/ide/__init__.py +1 -0
  12. vp6/ide/__main__.py +5 -0
  13. vp6/ide/chrome.py +385 -0
  14. vp6/ide/codeeditor.py +812 -0
  15. vp6/ide/designer.py +1414 -0
  16. vp6/ide/dialogs.py +321 -0
  17. vp6/ide/documents.py +138 -0
  18. vp6/ide/findreplace.py +362 -0
  19. vp6/ide/icons.py +470 -0
  20. vp6/ide/images/vp6logo.png +0 -0
  21. vp6/ide/kitchensink.py +215 -0
  22. vp6/ide/mainwindow.py +1738 -0
  23. vp6/ide/menueditor.py +350 -0
  24. vp6/ide/options.py +330 -0
  25. vp6/ide/outline.py +307 -0
  26. vp6/ide/outputcapture.py +125 -0
  27. vp6/ide/panels.py +1024 -0
  28. vp6/ide/projectprops.py +250 -0
  29. vp6/ide/properties.py +387 -0
  30. vp6/ide/splash.py +95 -0
  31. vp6/ide/templates/kitchensink/Form1.py +254 -0
  32. vp6/ide/templates/kitchensink/Module1.py +14 -0
  33. vp6/ide/templates/kitchensink/frmDialog.py +40 -0
  34. vp6/ide/templates/kitchensink/pgArrays.py +41 -0
  35. vp6/ide/templates/kitchensink/pgButtons.py +110 -0
  36. vp6/ide/templates/kitchensink/pgDialogs.py +51 -0
  37. vp6/ide/templates/kitchensink/pgEmbedded.py +67 -0
  38. vp6/ide/templates/kitchensink/pgGlobals.py +65 -0
  39. vp6/ide/templates/kitchensink/pgIntro.py +29 -0
  40. vp6/ide/templates/kitchensink/pgKeyboard.py +58 -0
  41. vp6/ide/templates/kitchensink/pgLayout.py +36 -0
  42. vp6/ide/templates/kitchensink/pgListView.py +92 -0
  43. vp6/ide/templates/kitchensink/pgLists.py +121 -0
  44. vp6/ide/templates/kitchensink/pgMenus.py +43 -0
  45. vp6/ide/templates/kitchensink/pgMouse.py +45 -0
  46. vp6/ide/templates/kitchensink/pgPictures.py +50 -0
  47. vp6/ide/templates/kitchensink/pgSchemes.py +43 -0
  48. vp6/ide/templates/kitchensink/pgScrollBars.py +41 -0
  49. vp6/ide/templates/kitchensink/pgScrolling.py +35 -0
  50. vp6/ide/templates/kitchensink/pgTabs.py +96 -0
  51. vp6/ide/templates/kitchensink/pgText.py +75 -0
  52. vp6/ide/templates/kitchensink/pgTimer.py +46 -0
  53. vp6/ide/templates/kitchensink/pgTree.py +67 -0
  54. vp6/ide/templates/kitchensink/pgValues.py +108 -0
  55. vp6/ide/templates/kitchensink/pgZOrder.py +61 -0
  56. vp6/ide/theme.py +336 -0
  57. vp6/images/vp6icon-128x128.png +0 -0
  58. vp6/images/vp6icon-256x256.png +0 -0
  59. vp6/images/vp6icon-32x32.png +0 -0
  60. vp6/images/vp6icon-64x64.png +0 -0
  61. vp6/make.py +219 -0
  62. vp6/project.py +442 -0
  63. vp6/runner.py +99 -0
  64. vp6-0.4.3.dist-info/METADATA +279 -0
  65. vp6-0.4.3.dist-info/RECORD +69 -0
  66. vp6-0.4.3.dist-info/WHEEL +5 -0
  67. vp6-0.4.3.dist-info/entry_points.txt +4 -0
  68. vp6-0.4.3.dist-info/licenses/LICENSE +21 -0
  69. 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"]