sciwidgets 0.1.2__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.
- sciwidgets/__init__.py +8 -0
- sciwidgets/dialogs/__init__.py +31 -0
- sciwidgets/dialogs/__main__.py +128 -0
- sciwidgets/dialogs/message_dialogs.py +553 -0
- sciwidgets/formatting.py +161 -0
- sciwidgets/labels/__init__.py +3 -0
- sciwidgets/labels/__main__.py +124 -0
- sciwidgets/labels/math_label.py +505 -0
- sciwidgets/params/__init__.py +18 -0
- sciwidgets/params/__main__.py +81 -0
- sciwidgets/params/base.py +220 -0
- sciwidgets/params/combo_box.py +106 -0
- sciwidgets/params/line_edit.py +132 -0
- sciwidgets/params/numeric_line_edit.py +680 -0
- sciwidgets/params/sliders/__init__.py +36 -0
- sciwidgets/params/sliders/__main__.py +215 -0
- sciwidgets/params/sliders/_defaults.py +36 -0
- sciwidgets/params/sliders/_dialogs.py +174 -0
- sciwidgets/params/sliders/_right_click_overlay.py +115 -0
- sciwidgets/params/sliders/base.py +592 -0
- sciwidgets/params/sliders/multi.py +660 -0
- sciwidgets/params/sliders/range.py +1841 -0
- sciwidgets/params/sliders/single.py +2042 -0
- sciwidgets/params/toggle.py +116 -0
- sciwidgets/style.py +175 -0
- sciwidgets-0.1.2.dist-info/METADATA +173 -0
- sciwidgets-0.1.2.dist-info/RECORD +30 -0
- sciwidgets-0.1.2.dist-info/WHEEL +5 -0
- sciwidgets-0.1.2.dist-info/licenses/LICENSE +21 -0
- sciwidgets-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Demo for the labels subpackage: ``python -m sciwidgets.labels``."""
|
|
2
|
+
|
|
3
|
+
from PySide6.QtCore import Qt
|
|
4
|
+
|
|
5
|
+
from sciwidgets.labels import math_label
|
|
6
|
+
from sciwidgets.labels.math_label import MathLabel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _demo_main() -> int:
|
|
10
|
+
"""Run this module as a standalone demo application.
|
|
11
|
+
|
|
12
|
+
Shows LaTeX vs mathtext rendering, alignment and stretching, colors and
|
|
13
|
+
the disabled state (toggled from a context menu), and a live editor for
|
|
14
|
+
trying arbitrary expressions.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
int: Qt application exit code.
|
|
18
|
+
"""
|
|
19
|
+
from PySide6.QtWidgets import (
|
|
20
|
+
QApplication, QDoubleSpinBox, QGridLayout, QGroupBox, QHBoxLayout,
|
|
21
|
+
QLabel, QLineEdit, QVBoxLayout, QWidget,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
app = QApplication([])
|
|
25
|
+
win = QWidget()
|
|
26
|
+
win.setWindowTitle("MathLabel demo")
|
|
27
|
+
root = QVBoxLayout(win)
|
|
28
|
+
|
|
29
|
+
status = "on" if math_label._latex_enabled else "off (mathtext fallback)"
|
|
30
|
+
root.addWidget(QLabel(f"External LaTeX: {status}"))
|
|
31
|
+
|
|
32
|
+
# --- 1. LaTeX vs mathtext -------------------------------------------
|
|
33
|
+
box = QGroupBox("use_tex=True vs use_tex=False")
|
|
34
|
+
grid = QGridLayout(box)
|
|
35
|
+
samples = [
|
|
36
|
+
r"T_1",
|
|
37
|
+
r"E = mc^2",
|
|
38
|
+
r"\Omega_{\mathrm{R}} = \frac{d\,E}{\hbar}",
|
|
39
|
+
r"\mathrm{Population}\ \Delta N",
|
|
40
|
+
]
|
|
41
|
+
for row, s in enumerate(samples):
|
|
42
|
+
grid.addWidget(MathLabel(s, font_size=16, parent=box), row, 0)
|
|
43
|
+
grid.addWidget(MathLabel(s, font_size=16, use_tex=False, parent=box), row, 1)
|
|
44
|
+
root.addWidget(box)
|
|
45
|
+
|
|
46
|
+
# --- 2. Alignment and stretching ------------------------------------
|
|
47
|
+
box = QGroupBox("Alignment (aspect kept) and keep_aspect_ratio=False")
|
|
48
|
+
row = QHBoxLayout(box)
|
|
49
|
+
for align in (Qt.AlignLeft, Qt.AlignCenter, Qt.AlignRight):
|
|
50
|
+
lbl = MathLabel(r"\eta", font_size=14, alignment=align, parent=box)
|
|
51
|
+
lbl.setMinimumSize(90, 50)
|
|
52
|
+
lbl.setAutoFillBackground(True) # makes the label's area visible
|
|
53
|
+
lbl.setStyleSheet("background: #ffffff;")
|
|
54
|
+
row.addWidget(lbl)
|
|
55
|
+
stretched = MathLabel(r"\omega_0", font_size=14, keep_aspect_ratio=False, parent=box)
|
|
56
|
+
stretched.setMinimumSize(90, 50)
|
|
57
|
+
stretched.setAutoFillBackground(True)
|
|
58
|
+
stretched.setStyleSheet("background: #ffffff;")
|
|
59
|
+
row.addWidget(stretched)
|
|
60
|
+
root.addWidget(box)
|
|
61
|
+
|
|
62
|
+
# --- 3. Colors, disabled state, context menu ------------------------
|
|
63
|
+
box = QGroupBox("Colors (right-click the first label)")
|
|
64
|
+
row = QHBoxLayout(box)
|
|
65
|
+
controller = MathLabel(r"\mathrm{Menu}", font_size=16, parent=box)
|
|
66
|
+
colored = [
|
|
67
|
+
MathLabel(r"^4_2\mathrm{He}", font_size=16, text_color="red", parent=box),
|
|
68
|
+
MathLabel(r"A'", font_size=16, text_color="green", parent=box),
|
|
69
|
+
MathLabel(r"w", font_size=16, text_color="blue", parent=box),
|
|
70
|
+
]
|
|
71
|
+
controller.set_menu_items([
|
|
72
|
+
{"id": "enable", "text": "Colors enabled", "checkable": True, "checked": True},
|
|
73
|
+
{"id": "sep"},
|
|
74
|
+
{"id": "hello", "text": "Print hello"},
|
|
75
|
+
])
|
|
76
|
+
|
|
77
|
+
def on_action(act_id: str, checked: bool) -> None:
|
|
78
|
+
print(f"action={act_id!r} checked={checked}")
|
|
79
|
+
if act_id == "enable":
|
|
80
|
+
for lbl in colored:
|
|
81
|
+
lbl.setEnabled(checked)
|
|
82
|
+
|
|
83
|
+
controller.rightClickRequested.connect(on_action)
|
|
84
|
+
row.addWidget(controller)
|
|
85
|
+
for lbl in colored:
|
|
86
|
+
row.addWidget(lbl)
|
|
87
|
+
root.addWidget(box)
|
|
88
|
+
|
|
89
|
+
# --- 4. Live editor ---------------------------------------------------
|
|
90
|
+
box = QGroupBox("Live editor (press Enter to render)")
|
|
91
|
+
col = QVBoxLayout(box)
|
|
92
|
+
controls = QHBoxLayout()
|
|
93
|
+
editor = QLineEdit(r"\frac{\partial E}{\partial z} = -\frac{1}{c}\frac{\partial E}{\partial t}")
|
|
94
|
+
size = QDoubleSpinBox()
|
|
95
|
+
size.setRange(6.0, 48.0)
|
|
96
|
+
size.setValue(18.0)
|
|
97
|
+
controls.addWidget(editor, 1)
|
|
98
|
+
controls.addWidget(size)
|
|
99
|
+
preview = MathLabel(font_size=size.value(), parent=box)
|
|
100
|
+
error = QLabel()
|
|
101
|
+
error.setStyleSheet("color: #b00020;")
|
|
102
|
+
col.addLayout(controls)
|
|
103
|
+
col.addWidget(preview)
|
|
104
|
+
col.addWidget(error)
|
|
105
|
+
|
|
106
|
+
def render() -> None:
|
|
107
|
+
try:
|
|
108
|
+
preview.set_text(editor.text(), font_size=size.value())
|
|
109
|
+
error.clear()
|
|
110
|
+
except Exception as exc: # bad LaTeX/mathtext raises here
|
|
111
|
+
error.setText(f"{type(exc).__name__}: {str(exc).splitlines()[0]}")
|
|
112
|
+
|
|
113
|
+
editor.returnPressed.connect(render)
|
|
114
|
+
size.valueChanged.connect(lambda _: render())
|
|
115
|
+
render()
|
|
116
|
+
root.addWidget(box)
|
|
117
|
+
|
|
118
|
+
win.resize(520, 560)
|
|
119
|
+
win.show()
|
|
120
|
+
return app.exec()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
raise SystemExit(_demo_main())
|
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
"""Math label widget rendered through Matplotlib (LaTeX or mathtext)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import shutil
|
|
7
|
+
from typing import Optional, List, Dict, Any
|
|
8
|
+
|
|
9
|
+
from matplotlib.figure import Figure
|
|
10
|
+
|
|
11
|
+
from PySide6.QtCore import QSize, QRectF, QByteArray, Signal, Qt, QEvent
|
|
12
|
+
from PySide6.QtGui import QPainter, QPalette, QColor
|
|
13
|
+
from PySide6.QtWidgets import QLabel, QWidget, QMenu
|
|
14
|
+
from PySide6.QtSvg import QSvgRenderer
|
|
15
|
+
|
|
16
|
+
_latex_enabled: bool = shutil.which("latex") is not None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def set_latex_enabled(enabled: bool) -> None:
|
|
20
|
+
"""Globally allow or forbid external LaTeX rendering for SvgLabel.
|
|
21
|
+
|
|
22
|
+
Affects labels created afterwards; existing labels keep their setting.
|
|
23
|
+
"""
|
|
24
|
+
global _latex_enabled
|
|
25
|
+
_latex_enabled = bool(enabled)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MathLabel(QLabel):
|
|
29
|
+
"""Display math text rendered by Matplotlib.
|
|
30
|
+
|
|
31
|
+
This widget behaves similarly to :class:`QLabel`, but its text is always
|
|
32
|
+
interpreted in math mode and rendered through Matplotlib (external LaTeX
|
|
33
|
+
when enabled, otherwise mathtext). Internally the result is converted to
|
|
34
|
+
SVG and drawn with :class:`QSvgRenderer`, so it scales cleanly.
|
|
35
|
+
|
|
36
|
+
The rendering pipeline is:
|
|
37
|
+
|
|
38
|
+
text -> Matplotlib (optionally external LaTeX) -> SVG bytes -> QSvgRenderer
|
|
39
|
+
|
|
40
|
+
Notes:
|
|
41
|
+
- The rendered SVG is rescaled when the widget size changes.
|
|
42
|
+
- By default, scaling preserves the SVG aspect ratio.
|
|
43
|
+
- The natural SVG size is cached and used as the widget's minimum size.
|
|
44
|
+
- The label is regenerated when the enabled state changes so disabled
|
|
45
|
+
text appearance stays in sync with the palette.
|
|
46
|
+
- If LaTeX rendering is enabled and available, Matplotlib may use the
|
|
47
|
+
system LaTeX installation for text rendering.
|
|
48
|
+
|
|
49
|
+
Signals:
|
|
50
|
+
rightClickRequested: Emitted when a context-menu action is triggered.
|
|
51
|
+
The signal arguments are the action id and the resulting checked
|
|
52
|
+
state.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
rightClickRequested = Signal(str, bool)
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
text: str = "",
|
|
60
|
+
font_size: float = 18.0,
|
|
61
|
+
text_color: str | QColor = "black",
|
|
62
|
+
pad_inches: float = 0.06,
|
|
63
|
+
alignment: Qt.Alignment = Qt.AlignCenter,
|
|
64
|
+
fix_size: bool = False,
|
|
65
|
+
keep_aspect_ratio: bool = True,
|
|
66
|
+
use_tex: bool = True,
|
|
67
|
+
parent: Optional[QWidget] = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Initialize the SVG label.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
text: Initial text to render. When non-empty, it is wrapped in
|
|
73
|
+
``$...$`` before rendering so it is interpreted in math mode.
|
|
74
|
+
font_size: Font size in points used by Matplotlib.
|
|
75
|
+
text_color: Text color used for rendering.
|
|
76
|
+
pad_inches: Extra padding, in inches, added around the rendered text
|
|
77
|
+
when the SVG is saved with a tight bounding box.
|
|
78
|
+
alignment: Alignment of the rendered SVG inside the widget.
|
|
79
|
+
fix_size: If ``True``, the widget size is fixed to the SVG's natural
|
|
80
|
+
size after rendering. Otherwise, the natural size is applied as
|
|
81
|
+
the minimum size.
|
|
82
|
+
keep_aspect_ratio: If ``True``, scale the SVG uniformly to preserve
|
|
83
|
+
aspect ratio. If ``False``, stretch it to fill the widget.
|
|
84
|
+
use_tex: If ``True``, allow Matplotlib to use external LaTeX
|
|
85
|
+
rendering, provided it is also enabled globally (see
|
|
86
|
+
:func:`set_latex_enabled`; on by default when ``latex`` is
|
|
87
|
+
found on the system). Otherwise Matplotlib's mathtext is used.
|
|
88
|
+
parent: Parent widget.
|
|
89
|
+
"""
|
|
90
|
+
super().__init__(parent)
|
|
91
|
+
|
|
92
|
+
self._editable = False
|
|
93
|
+
self._menu_items = None
|
|
94
|
+
self._renderer = QSvgRenderer(self)
|
|
95
|
+
self._keep_aspect_ratio = keep_aspect_ratio
|
|
96
|
+
self._natural_size: QSize = QSize()
|
|
97
|
+
|
|
98
|
+
# Store current text settings
|
|
99
|
+
self._text: str = f"${text}$" if text else ""
|
|
100
|
+
self._font_size: float = font_size
|
|
101
|
+
self._text_color = QColor(text_color)
|
|
102
|
+
self._use_tex: bool = _latex_enabled and use_tex
|
|
103
|
+
self._pad_inches: float = pad_inches
|
|
104
|
+
self._alignment: Qt.Alignment = alignment
|
|
105
|
+
self._fix_size: bool = fix_size
|
|
106
|
+
|
|
107
|
+
# We do not use QLabel's text painting
|
|
108
|
+
super().setText("")
|
|
109
|
+
|
|
110
|
+
if text:
|
|
111
|
+
self._update_from_text()
|
|
112
|
+
|
|
113
|
+
# ---------- Public API ----------
|
|
114
|
+
def set_menu_items(self, items: List[Dict[str, Any]]) -> None:
|
|
115
|
+
"""Set the context-menu item specifications for the label.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
items: List of menu item specification dictionaries. Each dictionary
|
|
119
|
+
may define keys such as ``id``, ``text``, ``enabled``,
|
|
120
|
+
``checkable``, and ``checked``. A separator is represented by
|
|
121
|
+
``{"id": "sep"}``.
|
|
122
|
+
"""
|
|
123
|
+
self._editable = True
|
|
124
|
+
self._menu_items = items
|
|
125
|
+
|
|
126
|
+
def set_menu_item_checked(self, act_id, checked: bool) -> bool:
|
|
127
|
+
"""Update the checked state of a context-menu item.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
act_id: Identifier of the target menu item.
|
|
131
|
+
checked: Checked state to store for the item.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
bool: ``True`` if the item was found and updated, otherwise
|
|
135
|
+
``False``.
|
|
136
|
+
"""
|
|
137
|
+
if not self._menu_items:
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
for item in self._menu_items:
|
|
141
|
+
if item.get("id") == "sep":
|
|
142
|
+
continue
|
|
143
|
+
if item.get("id") == act_id:
|
|
144
|
+
# ensure it's checkable (optional; remove if you want strict behavior)
|
|
145
|
+
item["checkable"] = bool(item.get("checkable", True))
|
|
146
|
+
item["checked"] = bool(checked)
|
|
147
|
+
return True
|
|
148
|
+
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
def set_editable(self, editable: bool) -> None:
|
|
152
|
+
"""Enable or disable the custom context menu.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
editable: If ``True``, the widget accepts the configured context menu.
|
|
156
|
+
If ``False``, right clicks do not open the custom menu.
|
|
157
|
+
"""
|
|
158
|
+
self._editable = editable
|
|
159
|
+
|
|
160
|
+
def set_menu_item_enabled(self, act_id, enabled: bool) -> bool:
|
|
161
|
+
"""Update the enabled state of a context-menu item.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
act_id: Identifier of the target menu item.
|
|
165
|
+
enabled: Enabled state to store for the item.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
bool: ``True`` if the item was found and updated, otherwise
|
|
169
|
+
``False``.
|
|
170
|
+
"""
|
|
171
|
+
if not self._menu_items:
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
for item in self._menu_items:
|
|
175
|
+
if item.get("id") == "sep":
|
|
176
|
+
continue
|
|
177
|
+
if item.get("id") == act_id:
|
|
178
|
+
item["enabled"] = bool(enabled)
|
|
179
|
+
return True
|
|
180
|
+
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
def set_text(
|
|
184
|
+
self,
|
|
185
|
+
text: str,
|
|
186
|
+
font_size: Optional[float] = None,
|
|
187
|
+
pad_inches: Optional[float] = None,
|
|
188
|
+
) -> None:
|
|
189
|
+
"""Update the label text and optional rendering settings.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
text: New text to display. The text is wrapped in ``$...$`` and
|
|
193
|
+
rendered in math mode, so plain words should be written using
|
|
194
|
+
LaTeX text commands such as ``\\mathrm{...}`` when needed.
|
|
195
|
+
font_size: New font size in points. If ``None``, the current value is
|
|
196
|
+
kept.
|
|
197
|
+
pad_inches: New padding value in inches. If ``None``, the current
|
|
198
|
+
value is kept.
|
|
199
|
+
"""
|
|
200
|
+
self._text = f"${text}$" if text else ""
|
|
201
|
+
if font_size is not None:
|
|
202
|
+
self._font_size = font_size
|
|
203
|
+
if pad_inches is not None:
|
|
204
|
+
self._pad_inches = pad_inches
|
|
205
|
+
|
|
206
|
+
self._update_from_text()
|
|
207
|
+
|
|
208
|
+
def setAlignment(self, alignment: Qt.Alignment) -> None:
|
|
209
|
+
"""Set how the SVG is positioned inside the widget rectangle.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
alignment: Qt alignment flags controlling horizontal and vertical
|
|
213
|
+
positioning of the rendered SVG.
|
|
214
|
+
"""
|
|
215
|
+
self._alignment = alignment
|
|
216
|
+
self.update()
|
|
217
|
+
|
|
218
|
+
def alignment(self) -> Qt.Alignment:
|
|
219
|
+
"""Return the alignment used for rendering.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
Qt.Alignment: Current alignment flags for the rendered SVG.
|
|
223
|
+
"""
|
|
224
|
+
return self._alignment
|
|
225
|
+
|
|
226
|
+
# ---------- Internal helpers ----------
|
|
227
|
+
def _update_from_text(self) -> None:
|
|
228
|
+
"""Regenerate and load the SVG from the current text settings.
|
|
229
|
+
|
|
230
|
+
The SVG is rebuilt using the current text, font size, padding, and
|
|
231
|
+
effective text color. If loading succeeds, the widget's cached natural
|
|
232
|
+
size, layout geometry, and display are refreshed.
|
|
233
|
+
"""
|
|
234
|
+
svg_bytes = self._create_text_svg(
|
|
235
|
+
text=self._text,
|
|
236
|
+
font_size=self._font_size,
|
|
237
|
+
pad_inches=self._pad_inches,
|
|
238
|
+
color=self._current_text_color(),
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
if self._renderer.load(QByteArray(svg_bytes)):
|
|
242
|
+
self._update_natural_size()
|
|
243
|
+
self.updateGeometry()
|
|
244
|
+
self.update()
|
|
245
|
+
|
|
246
|
+
def _create_text_svg(
|
|
247
|
+
self,
|
|
248
|
+
text: str,
|
|
249
|
+
font_size: float,
|
|
250
|
+
pad_inches: float = 0.06,
|
|
251
|
+
color: str | tuple[float, float, float, float] = "black",
|
|
252
|
+
) -> bytes:
|
|
253
|
+
"""Render text to SVG bytes using Matplotlib.
|
|
254
|
+
|
|
255
|
+
Uses a standalone :class:`matplotlib.figure.Figure` rather than
|
|
256
|
+
``pyplot``, so no global Matplotlib state or GUI backend is touched.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
text: Text passed to Matplotlib for rendering. This may be plain
|
|
260
|
+
text, mathtext, or a LaTeX-style string depending on how it was
|
|
261
|
+
prepared before calling this method.
|
|
262
|
+
font_size: Font size in points.
|
|
263
|
+
pad_inches: Padding in inches passed to Matplotlib when saving with
|
|
264
|
+
``bbox_inches="tight"``.
|
|
265
|
+
color: Text color passed to Matplotlib, as a color name or an
|
|
266
|
+
RGBA tuple of floats in ``[0, 1]``.
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
bytes: Raw SVG data.
|
|
270
|
+
"""
|
|
271
|
+
fig = Figure()
|
|
272
|
+
fig.patch.set_alpha(0.0) # Transparent background
|
|
273
|
+
|
|
274
|
+
# Centered text
|
|
275
|
+
fig.text(
|
|
276
|
+
0.5,
|
|
277
|
+
0.5,
|
|
278
|
+
text,
|
|
279
|
+
fontsize=font_size,
|
|
280
|
+
ha="center",
|
|
281
|
+
va="center",
|
|
282
|
+
usetex=self._use_tex,
|
|
283
|
+
color=color,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
buf = io.BytesIO()
|
|
287
|
+
fig.savefig(
|
|
288
|
+
buf,
|
|
289
|
+
format="svg",
|
|
290
|
+
bbox_inches="tight",
|
|
291
|
+
pad_inches=pad_inches,
|
|
292
|
+
transparent=True,
|
|
293
|
+
)
|
|
294
|
+
return buf.getvalue()
|
|
295
|
+
|
|
296
|
+
def _update_natural_size(self) -> None:
|
|
297
|
+
"""Update the cached natural size from the loaded SVG.
|
|
298
|
+
|
|
299
|
+
If the renderer reports a valid default size, that size is cached and
|
|
300
|
+
applied either as the widget's fixed size or minimum size, depending on
|
|
301
|
+
``self._fix_size``. If the SVG size is invalid, the cached size is
|
|
302
|
+
cleared.
|
|
303
|
+
"""
|
|
304
|
+
size = self._renderer.defaultSize()
|
|
305
|
+
if size.isValid():
|
|
306
|
+
self._natural_size = size
|
|
307
|
+
if self._fix_size:
|
|
308
|
+
self.setFixedSize(size)
|
|
309
|
+
else:
|
|
310
|
+
self.setMinimumSize(size)
|
|
311
|
+
else:
|
|
312
|
+
self._natural_size = QSize()
|
|
313
|
+
|
|
314
|
+
def _current_text_color(self):
|
|
315
|
+
"""Return the effective text color for the current widget state.
|
|
316
|
+
|
|
317
|
+
When the widget is disabled, the base text color keeps its RGB values
|
|
318
|
+
but adopts the alpha channel of the disabled ``WindowText`` palette
|
|
319
|
+
color.
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
tuple[float, float, float, float]: RGBA color components in the
|
|
323
|
+
normalized format returned by :meth:`QColor.getRgbF`.
|
|
324
|
+
"""
|
|
325
|
+
base = QColor(self._text_color)
|
|
326
|
+
|
|
327
|
+
if not self.isEnabled():
|
|
328
|
+
ref = self.palette().color(QPalette.Disabled, QPalette.WindowText)
|
|
329
|
+
base.setAlphaF(ref.alphaF())
|
|
330
|
+
|
|
331
|
+
return base.getRgbF()
|
|
332
|
+
|
|
333
|
+
# ---------- QWidget overrides ----------
|
|
334
|
+
def changeEvent(self, event) -> None:
|
|
335
|
+
"""Handle widget state changes.
|
|
336
|
+
|
|
337
|
+
When the enabled or disabled state changes, the SVG is regenerated so
|
|
338
|
+
the text appearance matches the current palette state.
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
event: Qt change event delivered to the widget.
|
|
342
|
+
"""
|
|
343
|
+
super().changeEvent(event)
|
|
344
|
+
if event.type() == QEvent.EnabledChange:
|
|
345
|
+
self._update_from_text()
|
|
346
|
+
|
|
347
|
+
def contextMenuEvent(self, event):
|
|
348
|
+
"""Show the configured context menu and emit the selected action.
|
|
349
|
+
|
|
350
|
+
The menu is built from the stored menu item specifications. For
|
|
351
|
+
checkable actions, the method updates the stored checked state and emits
|
|
352
|
+
:attr:`rightClickRequested` with the action id and the resulting checked
|
|
353
|
+
state.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
event: Qt context-menu event.
|
|
357
|
+
"""
|
|
358
|
+
parent = self.parentWidget()
|
|
359
|
+
if parent is not None:
|
|
360
|
+
parent.contextMenuEvent(event)
|
|
361
|
+
event.accept()
|
|
362
|
+
if not self._editable:
|
|
363
|
+
return
|
|
364
|
+
if not self._menu_items:
|
|
365
|
+
return
|
|
366
|
+
|
|
367
|
+
menu = QMenu(self)
|
|
368
|
+
|
|
369
|
+
# map id -> underlying dict so we can update checked state
|
|
370
|
+
item_by_id = {}
|
|
371
|
+
|
|
372
|
+
for item in self._menu_items:
|
|
373
|
+
act_id = item.get("id")
|
|
374
|
+
if act_id == "sep":
|
|
375
|
+
menu.addSeparator()
|
|
376
|
+
continue
|
|
377
|
+
|
|
378
|
+
text = str(item.get("text", act_id if act_id is not None else "None"))
|
|
379
|
+
enabled = bool(item.get("enabled", True))
|
|
380
|
+
checkable = bool(item.get("checkable", False))
|
|
381
|
+
checked = bool(item.get("checked", False))
|
|
382
|
+
|
|
383
|
+
act = menu.addAction(text)
|
|
384
|
+
act.setEnabled(enabled)
|
|
385
|
+
act.setCheckable(checkable)
|
|
386
|
+
if checkable:
|
|
387
|
+
act.setChecked(checked)
|
|
388
|
+
act.setData(act_id)
|
|
389
|
+
item_by_id[act_id] = item
|
|
390
|
+
|
|
391
|
+
action = menu.exec(event.globalPos())
|
|
392
|
+
if action is None:
|
|
393
|
+
return
|
|
394
|
+
|
|
395
|
+
act_id = action.data()
|
|
396
|
+
if act_id is None:
|
|
397
|
+
act_id = action.text()
|
|
398
|
+
|
|
399
|
+
# Determine (and ensure) the new checked state
|
|
400
|
+
new_checked = False
|
|
401
|
+
if action.isCheckable():
|
|
402
|
+
old_checked = bool(item_by_id.get(act_id, {}).get("checked", action.isChecked()))
|
|
403
|
+
new_checked = bool(action.isChecked())
|
|
404
|
+
|
|
405
|
+
# Safety: if Qt didn't toggle for some reason, toggle ourselves once
|
|
406
|
+
if new_checked == old_checked:
|
|
407
|
+
new_checked = not old_checked
|
|
408
|
+
action.setChecked(new_checked)
|
|
409
|
+
|
|
410
|
+
# Persist back to spec
|
|
411
|
+
if act_id in item_by_id:
|
|
412
|
+
item_by_id[act_id]["checked"] = new_checked
|
|
413
|
+
|
|
414
|
+
self.rightClickRequested.emit(act_id, new_checked)
|
|
415
|
+
|
|
416
|
+
def sizeHint(self) -> QSize:
|
|
417
|
+
"""Return the preferred size for layouts.
|
|
418
|
+
|
|
419
|
+
Returns:
|
|
420
|
+
QSize: The cached natural SVG size when available, otherwise the
|
|
421
|
+
renderer default size, otherwise the base :class:`QLabel` size hint.
|
|
422
|
+
"""
|
|
423
|
+
if self._natural_size.isValid():
|
|
424
|
+
return self._natural_size
|
|
425
|
+
default = self._renderer.defaultSize()
|
|
426
|
+
if default.isValid():
|
|
427
|
+
return default
|
|
428
|
+
return super().sizeHint()
|
|
429
|
+
|
|
430
|
+
def minimumSizeHint(self) -> QSize:
|
|
431
|
+
"""Return the minimum size hint for layouts.
|
|
432
|
+
|
|
433
|
+
Returns:
|
|
434
|
+
QSize: The cached natural SVG size when available, otherwise the
|
|
435
|
+
renderer default size, otherwise the base :class:`QLabel` minimum
|
|
436
|
+
size hint.
|
|
437
|
+
"""
|
|
438
|
+
if self._natural_size.isValid():
|
|
439
|
+
return self._natural_size
|
|
440
|
+
size = self._renderer.defaultSize()
|
|
441
|
+
if size.isValid():
|
|
442
|
+
return size
|
|
443
|
+
return super().minimumSizeHint()
|
|
444
|
+
|
|
445
|
+
def paintEvent(self, event) -> None:
|
|
446
|
+
"""Paint the rendered SVG into the widget.
|
|
447
|
+
|
|
448
|
+
If aspect-ratio preservation is enabled, the SVG is scaled uniformly and
|
|
449
|
+
positioned according to the current alignment. Otherwise, it is stretched
|
|
450
|
+
to fill the widget rectangle.
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
event: Qt paint event.
|
|
454
|
+
"""
|
|
455
|
+
if not self._renderer.isValid():
|
|
456
|
+
return super().paintEvent(event)
|
|
457
|
+
|
|
458
|
+
painter = QPainter(self)
|
|
459
|
+
widget_rect = self.rect()
|
|
460
|
+
|
|
461
|
+
if not self._keep_aspect_ratio:
|
|
462
|
+
self._renderer.render(painter, widget_rect)
|
|
463
|
+
return
|
|
464
|
+
|
|
465
|
+
svg_size = self._renderer.defaultSize()
|
|
466
|
+
if not svg_size.isValid():
|
|
467
|
+
self._renderer.render(painter, widget_rect)
|
|
468
|
+
return
|
|
469
|
+
|
|
470
|
+
svg_w = svg_size.width()
|
|
471
|
+
svg_h = svg_size.height()
|
|
472
|
+
if svg_w <= 0 or svg_h <= 0:
|
|
473
|
+
self._renderer.render(painter, widget_rect)
|
|
474
|
+
return
|
|
475
|
+
|
|
476
|
+
# Uniform scale factor
|
|
477
|
+
scale = min(
|
|
478
|
+
widget_rect.width() / svg_w,
|
|
479
|
+
widget_rect.height() / svg_h,
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
target_w = svg_w * scale
|
|
483
|
+
target_h = svg_h * scale
|
|
484
|
+
|
|
485
|
+
align = self._alignment
|
|
486
|
+
|
|
487
|
+
# horizontal
|
|
488
|
+
if align & Qt.AlignLeft:
|
|
489
|
+
x = widget_rect.left()
|
|
490
|
+
elif align & Qt.AlignRight:
|
|
491
|
+
x = widget_rect.right() - target_w
|
|
492
|
+
else: # center (default)
|
|
493
|
+
x = widget_rect.left() + (widget_rect.width() - target_w) / 2.0
|
|
494
|
+
|
|
495
|
+
# vertical
|
|
496
|
+
if align & Qt.AlignTop:
|
|
497
|
+
y = widget_rect.top()
|
|
498
|
+
elif align & Qt.AlignBottom:
|
|
499
|
+
y = widget_rect.bottom() - target_h
|
|
500
|
+
else: # center (default)
|
|
501
|
+
y = widget_rect.top() + (widget_rect.height() - target_h) / 2.0
|
|
502
|
+
|
|
503
|
+
target_rect = QRectF(x, y, target_w, target_h)
|
|
504
|
+
self._renderer.render(painter, target_rect)
|
|
505
|
+
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Parameter input widgets: line edits, toggles, combo boxes, and sliders."""
|
|
2
|
+
|
|
3
|
+
from .base import ParameterWidgetBase
|
|
4
|
+
from .combo_box import ParameterComboBox
|
|
5
|
+
from .line_edit import ParameterLineEdit
|
|
6
|
+
from .numeric_line_edit import NumericLineEdit
|
|
7
|
+
from .sliders import * # noqa: F401,F403
|
|
8
|
+
from .sliders import __all__ as _sliders_all
|
|
9
|
+
from .toggle import ParameterToggle
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"NumericLineEdit",
|
|
13
|
+
"ParameterComboBox",
|
|
14
|
+
"ParameterLineEdit",
|
|
15
|
+
"ParameterToggle",
|
|
16
|
+
"ParameterWidgetBase",
|
|
17
|
+
*_sliders_all,
|
|
18
|
+
]
|