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,2042 @@
|
|
|
1
|
+
"""Reusable slider widgets for numeric and discrete parameter controls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import numbers
|
|
7
|
+
from typing import Any, Callable, Dict, Generic, List, Literal, Optional, Tuple, TypeVar
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from PySide6.QtCore import QEvent, QObject, Qt
|
|
11
|
+
from PySide6.QtGui import QColor, QFontMetrics, QPainter, QPalette, QPen, QShowEvent, QTextDocument
|
|
12
|
+
from PySide6.QtWidgets import (
|
|
13
|
+
QDialog,
|
|
14
|
+
QDialogButtonBox,
|
|
15
|
+
QFormLayout,
|
|
16
|
+
QHBoxLayout,
|
|
17
|
+
QLabel,
|
|
18
|
+
QLineEdit,
|
|
19
|
+
QSlider,
|
|
20
|
+
QSpinBox,
|
|
21
|
+
QStyle,
|
|
22
|
+
QStyleOptionSlider,
|
|
23
|
+
QWidget,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from ...dialogs import AskResult, show_warning
|
|
27
|
+
from ...formatting import format_is_valid, get_numeric_format_field, pretty_sci_text
|
|
28
|
+
from ...labels import MathLabel
|
|
29
|
+
from ..numeric_line_edit import NumericLineEdit
|
|
30
|
+
from ._defaults import UNIT_LABEL_SIZE, SliderBorderState
|
|
31
|
+
from ._dialogs import slider_ask_clamp_value, slider_ask_extend_range
|
|
32
|
+
from .base import SliderFeature, SliderWidgetBase
|
|
33
|
+
|
|
34
|
+
SpecialValue = Literal["+inf", "-inf"]
|
|
35
|
+
TNum = TypeVar("TNum", int, float)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _map_val_to_pos(v: float, v_min: float, v_max: float, p_min: int, p_max: int) -> int:
|
|
39
|
+
"""Map a value linearly onto an integer slider position.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
v: Numeric value to map.
|
|
43
|
+
v_min: Minimum numeric value of the source interval.
|
|
44
|
+
v_max: Maximum numeric value of the source interval.
|
|
45
|
+
p_min: Minimum integer position of the destination interval.
|
|
46
|
+
p_max: Maximum integer position of the destination interval.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Rounded integer slider position.
|
|
50
|
+
|
|
51
|
+
Note:
|
|
52
|
+
If ``v_min == v_max``, the mapping is degenerate and ``p_min`` is
|
|
53
|
+
returned.
|
|
54
|
+
"""
|
|
55
|
+
if v_max == v_min:
|
|
56
|
+
return p_min
|
|
57
|
+
t = (v - v_min) / (v_max - v_min)
|
|
58
|
+
return int(round(p_min + t * (p_max - p_min)))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _map_pos_to_val(p: int, p_min: int, p_max: int, v_min: float, v_max: float) -> float:
|
|
62
|
+
"""Map an integer slider position linearly onto a numeric value.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
p: Integer position to map.
|
|
66
|
+
p_min: Minimum integer position of the source interval.
|
|
67
|
+
p_max: Maximum integer position of the source interval.
|
|
68
|
+
v_min: Minimum numeric value of the destination interval.
|
|
69
|
+
v_max: Maximum numeric value of the destination interval.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Interpolated numeric value.
|
|
73
|
+
|
|
74
|
+
Note:
|
|
75
|
+
If ``p_min == p_max``, the mapping is degenerate and ``v_min`` is
|
|
76
|
+
returned.
|
|
77
|
+
"""
|
|
78
|
+
if p_max == p_min:
|
|
79
|
+
return v_min
|
|
80
|
+
t = (p - p_min) / (p_max - p_min)
|
|
81
|
+
return v_min + t * (v_max - v_min)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def make_line_edit(
|
|
85
|
+
value_is_int: bool,
|
|
86
|
+
init_value: TNum,
|
|
87
|
+
fmt: str,
|
|
88
|
+
min_limit: TNum,
|
|
89
|
+
max_limit: TNum,
|
|
90
|
+
min_limit_inclusive: bool,
|
|
91
|
+
max_limit_inclusive: bool,
|
|
92
|
+
) -> NumericLineEdit:
|
|
93
|
+
"""Create a numeric line edit configured for a slider-editing dialog.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
value_is_int: Whether the editor should parse integer values.
|
|
97
|
+
init_value: Initial value shown in the editor.
|
|
98
|
+
fmt: Display format string associated with the slider.
|
|
99
|
+
min_limit: Lower allowed limit.
|
|
100
|
+
max_limit: Upper allowed limit.
|
|
101
|
+
min_limit_inclusive: Whether the lower limit is inclusive.
|
|
102
|
+
max_limit_inclusive: Whether the upper limit is inclusive.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Configured numeric line edit widget.
|
|
106
|
+
"""
|
|
107
|
+
val_fmt = get_numeric_format_field(fmt)
|
|
108
|
+
return NumericLineEdit(
|
|
109
|
+
init_val=init_value,
|
|
110
|
+
value_is_int=value_is_int,
|
|
111
|
+
val_fmt=val_fmt,
|
|
112
|
+
min_limit=min_limit,
|
|
113
|
+
max_limit=max_limit,
|
|
114
|
+
min_limit_inclusive=min_limit_inclusive,
|
|
115
|
+
max_limit_inclusive=max_limit_inclusive,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def normalize_special_value(value: Any) -> SpecialValue | None:
|
|
120
|
+
"""Normalize special infinite values to a canonical string representation.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
value: Candidate value supplied by the caller.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
``"+inf"`` or ``"-inf"`` when the input represents positive or negative
|
|
127
|
+
infinity, otherwise ``None``.
|
|
128
|
+
"""
|
|
129
|
+
if isinstance(value, str):
|
|
130
|
+
s = value.strip().lower()
|
|
131
|
+
if s in {"inf", "+inf", "infinity", "+infinity"}:
|
|
132
|
+
return "+inf"
|
|
133
|
+
if s in {"-inf", "-infinity"}:
|
|
134
|
+
return "-inf"
|
|
135
|
+
|
|
136
|
+
if isinstance(value, (int, float)) and math.isinf(value):
|
|
137
|
+
return "+inf" if value > 0 else "-inf"
|
|
138
|
+
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class MarkerOverlay(QWidget):
|
|
143
|
+
"""Draw a marker aligned to a slider groove.
|
|
144
|
+
|
|
145
|
+
The overlay is parented to a slider and paints a single line at the logical
|
|
146
|
+
slider position returned by ``value_fn``. It is typically used to visualize
|
|
147
|
+
a stored default value.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
def __init__(
|
|
151
|
+
self,
|
|
152
|
+
slider: QSlider,
|
|
153
|
+
value_fn: Callable[[], int],
|
|
154
|
+
*,
|
|
155
|
+
thickness: int = 2,
|
|
156
|
+
color: QColor | str = Qt.red,
|
|
157
|
+
):
|
|
158
|
+
"""Initialize the marker overlay.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
slider: Slider that owns the overlay.
|
|
162
|
+
value_fn: Callable returning the logical slider position to mark.
|
|
163
|
+
thickness: Marker line thickness in pixels.
|
|
164
|
+
color: Marker color.
|
|
165
|
+
"""
|
|
166
|
+
super().__init__(slider)
|
|
167
|
+
self._slider = slider
|
|
168
|
+
self._value_fn = value_fn
|
|
169
|
+
self._thickness = thickness
|
|
170
|
+
self._color = QColor(color)
|
|
171
|
+
|
|
172
|
+
self.setAttribute(Qt.WA_TransparentForMouseEvents, True)
|
|
173
|
+
self.setAttribute(Qt.WA_NoSystemBackground, True)
|
|
174
|
+
self.setAttribute(Qt.WA_TranslucentBackground, True)
|
|
175
|
+
|
|
176
|
+
slider.installEventFilter(self)
|
|
177
|
+
self._sync_geometry()
|
|
178
|
+
self.show()
|
|
179
|
+
|
|
180
|
+
def _current_color(self) -> QColor:
|
|
181
|
+
"""Return the effective marker color for the current enabled state.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Marker color, possibly dimmed when the slider is disabled.
|
|
185
|
+
"""
|
|
186
|
+
c = QColor(self._color)
|
|
187
|
+
if not self._slider.isEnabled():
|
|
188
|
+
ref = self.palette().color(QPalette.Disabled, QPalette.WindowText)
|
|
189
|
+
c.setAlphaF(ref.alphaF())
|
|
190
|
+
return c
|
|
191
|
+
|
|
192
|
+
def eventFilter(self, obj: QObject, ev: QEvent) -> bool:
|
|
193
|
+
"""Keep the overlay synchronized with slider state changes.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
obj: Watched object.
|
|
197
|
+
ev: Event delivered to the watched object.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Result of the base-class event filter.
|
|
201
|
+
"""
|
|
202
|
+
if obj is self._slider and ev.type() in (
|
|
203
|
+
QEvent.Resize,
|
|
204
|
+
QEvent.Show,
|
|
205
|
+
QEvent.Hide,
|
|
206
|
+
QEvent.EnabledChange,
|
|
207
|
+
):
|
|
208
|
+
self._sync_geometry()
|
|
209
|
+
self.update()
|
|
210
|
+
return super().eventFilter(obj, ev)
|
|
211
|
+
|
|
212
|
+
def _sync_geometry(self) -> None:
|
|
213
|
+
"""Match the overlay geometry and visibility to the slider."""
|
|
214
|
+
self.setGeometry(self._slider.rect())
|
|
215
|
+
self.setVisible(self._slider.isVisible())
|
|
216
|
+
|
|
217
|
+
def paintEvent(self, _ev) -> None:
|
|
218
|
+
"""Paint the marker line at the current logical slider position.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
_ev: Paint event supplied by Qt.
|
|
222
|
+
"""
|
|
223
|
+
opt = QStyleOptionSlider()
|
|
224
|
+
self._slider.initStyleOption(opt)
|
|
225
|
+
|
|
226
|
+
style = self._slider.style()
|
|
227
|
+
groove = style.subControlRect(QStyle.CC_Slider, opt, QStyle.SC_SliderGroove, self._slider)
|
|
228
|
+
handle = style.subControlRect(QStyle.CC_Slider, opt, QStyle.SC_SliderHandle, self._slider)
|
|
229
|
+
if groove.isNull() or handle.isNull():
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
vmin, vmax = opt.minimum, opt.maximum
|
|
233
|
+
if vmin == vmax:
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
v = int(self._value_fn())
|
|
237
|
+
v = max(vmin, min(vmax, v))
|
|
238
|
+
|
|
239
|
+
p = QPainter(self)
|
|
240
|
+
p.setRenderHint(QPainter.Antialiasing, False)
|
|
241
|
+
|
|
242
|
+
pen = QPen(self._current_color())
|
|
243
|
+
pen.setWidth(self._thickness)
|
|
244
|
+
p.setPen(pen)
|
|
245
|
+
|
|
246
|
+
if opt.orientation == Qt.Horizontal:
|
|
247
|
+
span = max(0, groove.width() - handle.width())
|
|
248
|
+
dx = QStyle.sliderPositionFromValue(vmin, vmax, v, span, opt.upsideDown)
|
|
249
|
+
x = groove.x() + dx + handle.width() // 2
|
|
250
|
+
p.drawLine(x, groove.top(), x, groove.bottom())
|
|
251
|
+
else:
|
|
252
|
+
span = max(0, groove.height() - handle.height())
|
|
253
|
+
dy = QStyle.sliderPositionFromValue(vmin, vmax, v, span, opt.upsideDown)
|
|
254
|
+
y = groove.y() + dy + handle.height() // 2
|
|
255
|
+
p.drawLine(groove.left(), y, groove.right(), y)
|
|
256
|
+
|
|
257
|
+
p.end()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class EditConfigDialog(QDialog, Generic[TNum]):
|
|
261
|
+
"""Dialog for editing slider range and format."""
|
|
262
|
+
|
|
263
|
+
def __init__(
|
|
264
|
+
self,
|
|
265
|
+
slider_name: str,
|
|
266
|
+
value_is_int: bool,
|
|
267
|
+
min_val: TNum,
|
|
268
|
+
max_val: TNum,
|
|
269
|
+
val_fmt: str,
|
|
270
|
+
min_limit: TNum | None,
|
|
271
|
+
max_limit: TNum | None,
|
|
272
|
+
min_limit_inclusive: bool,
|
|
273
|
+
max_limit_inclusive: bool,
|
|
274
|
+
validate: Callable[[TNum, TNum, str], str | None],
|
|
275
|
+
parent: Optional[QWidget] = None,
|
|
276
|
+
):
|
|
277
|
+
"""Initialize the configuration-editing dialog.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
slider_name: Display name of the slider.
|
|
281
|
+
value_is_int: Whether the slider uses integer values.
|
|
282
|
+
min_val: Current minimum value.
|
|
283
|
+
max_val: Current maximum value.
|
|
284
|
+
val_fmt: Current display format string.
|
|
285
|
+
min_limit: Optional lower hard limit.
|
|
286
|
+
max_limit: Optional upper hard limit.
|
|
287
|
+
min_limit_inclusive: Whether the lower hard limit is inclusive.
|
|
288
|
+
max_limit_inclusive: Whether the upper hard limit is inclusive.
|
|
289
|
+
validate: Callable checking (min, max, format); returns an error
|
|
290
|
+
message or None.
|
|
291
|
+
parent: Optional parent widget.
|
|
292
|
+
"""
|
|
293
|
+
super().__init__(parent)
|
|
294
|
+
self.setWindowTitle("Edit Configuration")
|
|
295
|
+
|
|
296
|
+
self._validate = validate
|
|
297
|
+
self._edit_min = make_line_edit(
|
|
298
|
+
value_is_int, min_val, val_fmt, min_limit, max_limit, min_limit_inclusive, max_limit_inclusive
|
|
299
|
+
)
|
|
300
|
+
self._edit_max = make_line_edit(
|
|
301
|
+
value_is_int, max_val, val_fmt, min_limit, max_limit, min_limit_inclusive, max_limit_inclusive
|
|
302
|
+
)
|
|
303
|
+
self._edit_fmt = QLineEdit(val_fmt)
|
|
304
|
+
self._edit_fmt.editingFinished.connect(self._fmt_changed)
|
|
305
|
+
|
|
306
|
+
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=self)
|
|
307
|
+
buttons.accepted.connect(self.accept)
|
|
308
|
+
buttons.rejected.connect(self.reject)
|
|
309
|
+
|
|
310
|
+
form = QFormLayout(self)
|
|
311
|
+
form.addRow(
|
|
312
|
+
"Int Slider:" if value_is_int else "Float Slider:",
|
|
313
|
+
MathLabel(slider_name, alignment=Qt.AlignLeft | Qt.AlignVCenter),
|
|
314
|
+
)
|
|
315
|
+
form.addRow("Min:", self._edit_min)
|
|
316
|
+
form.addRow("Max:", self._edit_max)
|
|
317
|
+
form.addRow("Format:", self._edit_fmt)
|
|
318
|
+
form.addRow(buttons)
|
|
319
|
+
|
|
320
|
+
self.setSizeGripEnabled(False)
|
|
321
|
+
self.adjustSize()
|
|
322
|
+
self.setFixedSize(self.size())
|
|
323
|
+
|
|
324
|
+
def _fmt_changed(self) -> None:
|
|
325
|
+
"""Update numeric editor formats after the format field changes."""
|
|
326
|
+
fmt = get_numeric_format_field(self._edit_fmt.text())
|
|
327
|
+
self._edit_min.set_fmt(fmt)
|
|
328
|
+
self._edit_max.set_fmt(fmt)
|
|
329
|
+
|
|
330
|
+
def accept(self) -> None:
|
|
331
|
+
"""Validate the dialog state before accepting it."""
|
|
332
|
+
msg = self._validate(
|
|
333
|
+
self._edit_min.get_value(),
|
|
334
|
+
self._edit_max.get_value(),
|
|
335
|
+
self._edit_fmt.text(),
|
|
336
|
+
)
|
|
337
|
+
if msg:
|
|
338
|
+
show_warning("Invalid configuration", msg, parent=self)
|
|
339
|
+
return
|
|
340
|
+
super().accept()
|
|
341
|
+
|
|
342
|
+
def get_values(self) -> Tuple[TNum, TNum, str]:
|
|
343
|
+
"""Return the validated dialog values.
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
Tuple containing minimum value, maximum value, and format string.
|
|
347
|
+
"""
|
|
348
|
+
return (
|
|
349
|
+
self._edit_min.get_value(),
|
|
350
|
+
self._edit_max.get_value(),
|
|
351
|
+
self._edit_fmt.text()
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
def showEvent(self, event: QShowEvent) -> None:
|
|
355
|
+
"""Focus the minimum-value editor when the dialog is shown.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
event: Qt show event.
|
|
359
|
+
"""
|
|
360
|
+
super().showEvent(event)
|
|
361
|
+
self._edit_min.setFocus()
|
|
362
|
+
self._edit_min.selectAll()
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
class SetValueDialog(QDialog, Generic[TNum]):
|
|
366
|
+
"""Dialog for entering a new slider value."""
|
|
367
|
+
|
|
368
|
+
def __init__(
|
|
369
|
+
self,
|
|
370
|
+
slider_name: str,
|
|
371
|
+
value_is_int: bool,
|
|
372
|
+
current_value: TNum,
|
|
373
|
+
val_fmt: str,
|
|
374
|
+
min_limit: TNum | None,
|
|
375
|
+
max_limit: TNum | None,
|
|
376
|
+
min_limit_inclusive: bool,
|
|
377
|
+
max_limit_inclusive: bool,
|
|
378
|
+
parent: Optional[QWidget] = None,
|
|
379
|
+
):
|
|
380
|
+
"""Initialize the value-entry dialog.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
slider_name: Display name of the slider.
|
|
384
|
+
value_is_int: Whether the slider uses integer values.
|
|
385
|
+
current_value: Current value shown in the editor.
|
|
386
|
+
val_fmt: Current display format string.
|
|
387
|
+
min_limit: Optional lower hard limit.
|
|
388
|
+
max_limit: Optional upper hard limit.
|
|
389
|
+
min_limit_inclusive: Whether the lower hard limit is inclusive.
|
|
390
|
+
max_limit_inclusive: Whether the upper hard limit is inclusive.
|
|
391
|
+
parent: Optional parent widget.
|
|
392
|
+
"""
|
|
393
|
+
super().__init__(parent)
|
|
394
|
+
self.setWindowTitle("Set Value")
|
|
395
|
+
|
|
396
|
+
self._edit_value = make_line_edit(
|
|
397
|
+
value_is_int,
|
|
398
|
+
current_value,
|
|
399
|
+
val_fmt,
|
|
400
|
+
min_limit,
|
|
401
|
+
max_limit,
|
|
402
|
+
min_limit_inclusive,
|
|
403
|
+
max_limit_inclusive,
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=self)
|
|
407
|
+
buttons.accepted.connect(self.accept)
|
|
408
|
+
buttons.rejected.connect(self.reject)
|
|
409
|
+
|
|
410
|
+
form = QFormLayout(self)
|
|
411
|
+
form.addRow(
|
|
412
|
+
"Int Slider:" if value_is_int else "Float Slider:",
|
|
413
|
+
MathLabel(slider_name, alignment=Qt.AlignLeft | Qt.AlignVCenter),
|
|
414
|
+
)
|
|
415
|
+
form.addRow("Value:", self._edit_value)
|
|
416
|
+
form.addRow(buttons)
|
|
417
|
+
|
|
418
|
+
self.setSizeGripEnabled(False)
|
|
419
|
+
self.adjustSize()
|
|
420
|
+
self.setFixedSize(self.size())
|
|
421
|
+
|
|
422
|
+
def accept(self) -> None:
|
|
423
|
+
"""Validate the edited value and accept the dialog."""
|
|
424
|
+
if not self._edit_value.hasAcceptableInput():
|
|
425
|
+
self._edit_value._reject_exit_ui()
|
|
426
|
+
return
|
|
427
|
+
super().accept()
|
|
428
|
+
|
|
429
|
+
def get_values(self) -> TNum:
|
|
430
|
+
"""Return the validated slider value.
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
Value entered in the numeric line edit.
|
|
434
|
+
"""
|
|
435
|
+
return self._edit_value.get_value()
|
|
436
|
+
|
|
437
|
+
def showEvent(self, event: QShowEvent) -> None:
|
|
438
|
+
"""Focus the value editor when the dialog is shown.
|
|
439
|
+
|
|
440
|
+
Args:
|
|
441
|
+
event: Qt show event.
|
|
442
|
+
"""
|
|
443
|
+
super().showEvent(event)
|
|
444
|
+
self._edit_value.setFocus()
|
|
445
|
+
self._edit_value.selectAll()
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
class LabeledSliderBase(SliderWidgetBase[TNum], Generic[TNum]):
|
|
449
|
+
"""Base widget combining a label, a ``QSlider``, a value label, and range labels.
|
|
450
|
+
|
|
451
|
+
Frame, menu, layout, widths and events come from ``SliderWidgetBase``; this
|
|
452
|
+
class adds the single-value display and the ``QSlider``-specific hooks.
|
|
453
|
+
|
|
454
|
+
Signals ``valueChanged``, ``defaultChanged`` and ``configChanged`` are
|
|
455
|
+
emitted only for user actions (dragging, context menu, dialogs).
|
|
456
|
+
Programmatic setters such as ``set_value``, ``set_range``,
|
|
457
|
+
``set_format`` and ``set_default_value`` never emit them.
|
|
458
|
+
"""
|
|
459
|
+
|
|
460
|
+
def __init__(
|
|
461
|
+
self,
|
|
462
|
+
label: str,
|
|
463
|
+
unit: str,
|
|
464
|
+
min_val: TNum,
|
|
465
|
+
max_val: TNum,
|
|
466
|
+
init_val: TNum,
|
|
467
|
+
orientation: Qt.Orientation,
|
|
468
|
+
val_fmt: str,
|
|
469
|
+
show_range: bool,
|
|
470
|
+
features: SliderFeature,
|
|
471
|
+
parent: Optional[QWidget],
|
|
472
|
+
):
|
|
473
|
+
"""Initialize the labeled slider base widget.
|
|
474
|
+
|
|
475
|
+
Args:
|
|
476
|
+
label: Slider label shown next to the control.
|
|
477
|
+
unit: Optional unit label displayed beside the value.
|
|
478
|
+
min_val: Initial minimum value.
|
|
479
|
+
max_val: Initial maximum value.
|
|
480
|
+
init_val: Initial current value.
|
|
481
|
+
orientation: Slider orientation.
|
|
482
|
+
val_fmt: Value display format string.
|
|
483
|
+
show_range: Initial visibility of range labels and ticks; change
|
|
484
|
+
later with ``set_show_range``.
|
|
485
|
+
features: Context-menu features offered by the slider; also
|
|
486
|
+
controls the default marker.
|
|
487
|
+
parent: Optional parent widget.
|
|
488
|
+
"""
|
|
489
|
+
super().__init__(
|
|
490
|
+
label=label,
|
|
491
|
+
orientation=orientation,
|
|
492
|
+
val_fmt=val_fmt,
|
|
493
|
+
show_range=show_range,
|
|
494
|
+
features=features,
|
|
495
|
+
parent=parent,
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
self._unit = unit
|
|
499
|
+
self._min_value = min_val
|
|
500
|
+
self._max_value = max_val
|
|
501
|
+
self._current_value = init_val
|
|
502
|
+
self._default_value = init_val
|
|
503
|
+
|
|
504
|
+
self._value_label = QLabel()
|
|
505
|
+
self._value_label.setObjectName("slider_value")
|
|
506
|
+
w = QWidget()
|
|
507
|
+
l = QHBoxLayout(w)
|
|
508
|
+
l.addWidget(self._value_label)
|
|
509
|
+
l.setContentsMargins(0, 0, 0, 0)
|
|
510
|
+
l.setSpacing(0)
|
|
511
|
+
if unit:
|
|
512
|
+
self._unit_label = MathLabel(f"({unit})", parent=self, fix_size=True, font_size=UNIT_LABEL_SIZE)
|
|
513
|
+
l.addWidget(self._unit_label)
|
|
514
|
+
l.addStretch(1)
|
|
515
|
+
self._value_and_unit = w
|
|
516
|
+
|
|
517
|
+
self._attach_slider(QSlider(self._orient))
|
|
518
|
+
self.set_format(val_fmt)
|
|
519
|
+
self._make_layout()
|
|
520
|
+
|
|
521
|
+
# ----- SliderWidgetBase hooks -----
|
|
522
|
+
def _make_default_marker(self) -> QWidget:
|
|
523
|
+
"""Create the default-value marker on the ``QSlider``."""
|
|
524
|
+
return MarkerOverlay(self._slider, value_fn=self._get_default_pos, thickness=2)
|
|
525
|
+
|
|
526
|
+
def _value_widget(self) -> QWidget:
|
|
527
|
+
"""Return the value label with its unit."""
|
|
528
|
+
return self._value_and_unit
|
|
529
|
+
|
|
530
|
+
def _align_value_labels(self, horizontal: bool) -> None:
|
|
531
|
+
"""Align the value label for the orientation.
|
|
532
|
+
|
|
533
|
+
Args:
|
|
534
|
+
horizontal: Whether the layout is horizontal.
|
|
535
|
+
"""
|
|
536
|
+
if horizontal:
|
|
537
|
+
self._value_label.setAlignment(Qt.AlignVCenter | Qt.AlignRight)
|
|
538
|
+
else:
|
|
539
|
+
self._value_label.setAlignment(Qt.AlignCenter)
|
|
540
|
+
|
|
541
|
+
def _value_text_labels(self) -> List[QLabel]:
|
|
542
|
+
"""Return the labels whose text format follows the value format."""
|
|
543
|
+
return [self._value_label, self._min_label, self._max_label]
|
|
544
|
+
|
|
545
|
+
def _round_values_to_format(self) -> None:
|
|
546
|
+
"""Round current, default, min and max to the current format."""
|
|
547
|
+
self._current_value = self.evaluate_value(self._current_value)
|
|
548
|
+
self._default_value = self.evaluate_value(self._default_value)
|
|
549
|
+
self._min_value = self.evaluate_value(self._min_value)
|
|
550
|
+
self._max_value = self.evaluate_value(self._max_value)
|
|
551
|
+
|
|
552
|
+
def _apply_range_visibility(self, show: bool) -> None:
|
|
553
|
+
"""Show or hide the ``QSlider`` ticks.
|
|
554
|
+
|
|
555
|
+
Args:
|
|
556
|
+
show: Whether range ticks should be shown.
|
|
557
|
+
"""
|
|
558
|
+
if show:
|
|
559
|
+
horizontal = self._orient == Qt.Horizontal
|
|
560
|
+
self._slider.setTickPosition(QSlider.TicksBelow if horizontal else QSlider.TicksRight)
|
|
561
|
+
mn, mx = self._slider.minimum(), self._slider.maximum()
|
|
562
|
+
if mx > mn:
|
|
563
|
+
self._slider.setTickInterval(max(1, mx - mn))
|
|
564
|
+
else:
|
|
565
|
+
self._slider.setTickPosition(QSlider.NoTicks)
|
|
566
|
+
|
|
567
|
+
def _update_value_width(self) -> None:
|
|
568
|
+
"""Measure the width required for the value display."""
|
|
569
|
+
l = self._value_label
|
|
570
|
+
l.ensurePolished()
|
|
571
|
+
positions = self._iter_width_positions(max_samples=200)
|
|
572
|
+
|
|
573
|
+
new_w = 0
|
|
574
|
+
if l.textFormat() == Qt.PlainText:
|
|
575
|
+
fm = QFontMetrics(l.font())
|
|
576
|
+
for i in positions:
|
|
577
|
+
text = self._value_to_text(self._pos_to_value(i))
|
|
578
|
+
new_w = max(new_w, fm.horizontalAdvance(text))
|
|
579
|
+
new_w += 5
|
|
580
|
+
else:
|
|
581
|
+
doc = QTextDocument()
|
|
582
|
+
doc.setDefaultFont(l.font())
|
|
583
|
+
doc.setTextWidth(-1)
|
|
584
|
+
for i in positions:
|
|
585
|
+
doc.setHtml(self._value_to_text(self._pos_to_value(i)))
|
|
586
|
+
new_w = max(new_w, int(doc.idealWidth()))
|
|
587
|
+
|
|
588
|
+
if self._unit:
|
|
589
|
+
new_w += self._unit_label.sizeHint().width()
|
|
590
|
+
|
|
591
|
+
self._set_measured_value_width(new_w)
|
|
592
|
+
|
|
593
|
+
# ----- value handling -----
|
|
594
|
+
def evaluate_value(self, value: TNum, eval_fmt: str | None = None) -> TNum:
|
|
595
|
+
"""Normalize a numeric value using the current evaluation format.
|
|
596
|
+
|
|
597
|
+
Args:
|
|
598
|
+
value: Value to normalize.
|
|
599
|
+
eval_fmt: Format to use instead of the current one.
|
|
600
|
+
|
|
601
|
+
Returns:
|
|
602
|
+
Normalized typed value.
|
|
603
|
+
"""
|
|
604
|
+
raise NotImplementedError
|
|
605
|
+
|
|
606
|
+
def _pos_to_value(self, pos: int) -> TNum:
|
|
607
|
+
"""Convert a slider position to a typed value.
|
|
608
|
+
|
|
609
|
+
Args:
|
|
610
|
+
pos: Integer slider position.
|
|
611
|
+
|
|
612
|
+
Returns:
|
|
613
|
+
Typed slider value.
|
|
614
|
+
"""
|
|
615
|
+
raise NotImplementedError
|
|
616
|
+
|
|
617
|
+
def _get_default_pos(self) -> int:
|
|
618
|
+
"""Return the default marker position in slider coordinates.
|
|
619
|
+
|
|
620
|
+
Returns:
|
|
621
|
+
Integer slider position for the default value.
|
|
622
|
+
"""
|
|
623
|
+
raise NotImplementedError
|
|
624
|
+
|
|
625
|
+
def _update_mapping_anchors(self) -> None:
|
|
626
|
+
"""Update mapping anchors used by subclasses with adaptive mappings."""
|
|
627
|
+
|
|
628
|
+
def _update_value_label(self) -> None:
|
|
629
|
+
"""Refresh the main value label from the current value."""
|
|
630
|
+
self._value_label.setText(self._value_to_text(self._current_value))
|
|
631
|
+
|
|
632
|
+
def _update_all_labels(self) -> None:
|
|
633
|
+
"""Refresh the main, minimum, and maximum labels."""
|
|
634
|
+
self._value_label.setText(self._value_to_text(self._current_value))
|
|
635
|
+
self._min_label.setText(self._value_to_text(self._min_value))
|
|
636
|
+
self._max_label.setText(self._value_to_text(self._max_value))
|
|
637
|
+
|
|
638
|
+
def _iter_width_positions(self, max_samples: int = 200) -> list[int]:
|
|
639
|
+
"""Return representative slider positions for width measurement.
|
|
640
|
+
|
|
641
|
+
Args:
|
|
642
|
+
max_samples: Maximum number of sample positions to consider.
|
|
643
|
+
|
|
644
|
+
Returns:
|
|
645
|
+
Sorted list of slider positions.
|
|
646
|
+
"""
|
|
647
|
+
mn = self._slider.minimum()
|
|
648
|
+
mx = self._slider.maximum()
|
|
649
|
+
span = mx - mn
|
|
650
|
+
|
|
651
|
+
if span <= max_samples:
|
|
652
|
+
return list(range(mn, mx + 1))
|
|
653
|
+
|
|
654
|
+
cand: set[int] = {mn, mx, int(self._slider.value()), int(self._get_default_pos())}
|
|
655
|
+
n_lin = max(0, max_samples - len(cand))
|
|
656
|
+
for k in range(1, n_lin + 1):
|
|
657
|
+
cand.add(mn + (span * k) // (n_lin + 1))
|
|
658
|
+
|
|
659
|
+
return sorted(cand)
|
|
660
|
+
|
|
661
|
+
def _on_slider_value_changed(self) -> None:
|
|
662
|
+
"""Update the typed value and emit ``valueChanged`` after slider movement."""
|
|
663
|
+
val = self._pos_to_value(self._slider.value())
|
|
664
|
+
new_val = self.evaluate_value(val)
|
|
665
|
+
if new_val != self._current_value:
|
|
666
|
+
self._current_value = new_val
|
|
667
|
+
self._update_value_label()
|
|
668
|
+
if not self._quiet:
|
|
669
|
+
self.valueChanged.emit(self.get_value())
|
|
670
|
+
|
|
671
|
+
def get_value(self) -> int | float:
|
|
672
|
+
"""Return the current typed value.
|
|
673
|
+
|
|
674
|
+
Returns:
|
|
675
|
+
Current slider value.
|
|
676
|
+
"""
|
|
677
|
+
return self._current_value
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
class NumericSliderBase(LabeledSliderBase[TNum], Generic[TNum]):
|
|
681
|
+
"""Base class for editable numeric sliders."""
|
|
682
|
+
|
|
683
|
+
_SPECIAL_ITEM_IDS = {"+inf": "set_to_pos_inf", "-inf": "set_to_neg_inf"}
|
|
684
|
+
|
|
685
|
+
def __init__(
|
|
686
|
+
self,
|
|
687
|
+
label: str,
|
|
688
|
+
unit: str,
|
|
689
|
+
value_is_int: bool,
|
|
690
|
+
min_val: TNum,
|
|
691
|
+
max_val: TNum,
|
|
692
|
+
init_val: float | int | str,
|
|
693
|
+
val_fmt: str,
|
|
694
|
+
min_limit: TNum | None,
|
|
695
|
+
max_limit: TNum | None,
|
|
696
|
+
min_limit_inclusive: bool,
|
|
697
|
+
max_limit_inclusive: bool,
|
|
698
|
+
orientation: Qt.Orientation,
|
|
699
|
+
features: SliderFeature,
|
|
700
|
+
show_range: bool,
|
|
701
|
+
parent: Optional[QWidget],
|
|
702
|
+
):
|
|
703
|
+
"""Initialize the numeric slider base class.
|
|
704
|
+
|
|
705
|
+
Args:
|
|
706
|
+
label: Slider label text.
|
|
707
|
+
unit: Optional unit label.
|
|
708
|
+
value_is_int: Whether the slider uses integer values.
|
|
709
|
+
min_val: Initial minimum value.
|
|
710
|
+
max_val: Initial maximum value.
|
|
711
|
+
init_val: Initial current value or a special infinity marker.
|
|
712
|
+
val_fmt: Value display format string.
|
|
713
|
+
min_limit: Optional lower hard limit.
|
|
714
|
+
max_limit: Optional upper hard limit.
|
|
715
|
+
min_limit_inclusive: Whether the lower hard limit is inclusive.
|
|
716
|
+
max_limit_inclusive: Whether the upper hard limit is inclusive.
|
|
717
|
+
orientation: Slider orientation.
|
|
718
|
+
features: Context-menu features offered by the slider.
|
|
719
|
+
show_range: Initial visibility of range labels and ticks; change
|
|
720
|
+
later with ``set_show_range``.
|
|
721
|
+
parent: Optional parent widget.
|
|
722
|
+
|
|
723
|
+
Raises:
|
|
724
|
+
ValueError: If the range or init_val violates the limits, or if
|
|
725
|
+
init_val is ±inf without ``SliderFeature.SPECIAL`` or with the
|
|
726
|
+
corresponding limit set.
|
|
727
|
+
"""
|
|
728
|
+
|
|
729
|
+
self._special_allowed = {"+inf": True, "-inf": True}
|
|
730
|
+
init_special = normalize_special_value(init_val)
|
|
731
|
+
if init_special is not None and SliderFeature.SPECIAL not in features:
|
|
732
|
+
raise ValueError(f"init_val {init_special!r} requires SliderFeature.SPECIAL in features")
|
|
733
|
+
if init_special is not None:
|
|
734
|
+
init_val = max_val if init_special == "+inf" else min_val
|
|
735
|
+
|
|
736
|
+
super().__init__(
|
|
737
|
+
label=label,
|
|
738
|
+
unit=unit,
|
|
739
|
+
min_val=min_val,
|
|
740
|
+
max_val=max_val,
|
|
741
|
+
init_val=init_val,
|
|
742
|
+
orientation=orientation,
|
|
743
|
+
val_fmt=val_fmt,
|
|
744
|
+
show_range=show_range,
|
|
745
|
+
features=features,
|
|
746
|
+
parent=parent,
|
|
747
|
+
)
|
|
748
|
+
self._min_limit = min_limit
|
|
749
|
+
self._max_limit = max_limit
|
|
750
|
+
self._min_limit_inclusive = min_limit_inclusive
|
|
751
|
+
self._max_limit_inclusive = max_limit_inclusive
|
|
752
|
+
|
|
753
|
+
self._add_right_click_items()
|
|
754
|
+
self.value_is_int = value_is_int
|
|
755
|
+
|
|
756
|
+
self._min_value, self._max_value = self._validate_range(min_val, max_val)
|
|
757
|
+
init_val = self._validate_value(init_val)
|
|
758
|
+
|
|
759
|
+
if init_special is None:
|
|
760
|
+
if not (min_val <= init_val <= max_val):
|
|
761
|
+
raise ValueError(f"init_val ({init_val}) must be within [{min_val}, {max_val}]")
|
|
762
|
+
else:
|
|
763
|
+
if init_special == "+inf" and self._max_limit is not None:
|
|
764
|
+
raise ValueError("init_val cannot be +inf when max_limit is set")
|
|
765
|
+
if init_special == "-inf" and self._min_limit is not None:
|
|
766
|
+
raise ValueError("init_val cannot be -inf when min_limit is set")
|
|
767
|
+
|
|
768
|
+
self._current_value = init_val
|
|
769
|
+
self._default_value = init_val
|
|
770
|
+
self._current_special_value: SpecialValue | None = init_special
|
|
771
|
+
|
|
772
|
+
self._v0, self._v1 = 0.0, 0.0
|
|
773
|
+
self._p0, self._p1 = 0, 0
|
|
774
|
+
|
|
775
|
+
self._setup_slider()
|
|
776
|
+
if init_special is not None:
|
|
777
|
+
self._enter_special_mode(init_special)
|
|
778
|
+
else:
|
|
779
|
+
self.set_value(self._default_value)
|
|
780
|
+
|
|
781
|
+
self._slider.valueChanged.connect(self._on_slider_value_changed)
|
|
782
|
+
|
|
783
|
+
self._update_value_width()
|
|
784
|
+
self._update_layout()
|
|
785
|
+
self._update_value_label()
|
|
786
|
+
|
|
787
|
+
def _setup_slider(self) -> None:
|
|
788
|
+
"""Configure the underlying QSlider."""
|
|
789
|
+
raise NotImplementedError
|
|
790
|
+
|
|
791
|
+
def _apply_range_to_slider(self) -> None:
|
|
792
|
+
"""Apply the current numeric range to the QSlider."""
|
|
793
|
+
pass
|
|
794
|
+
|
|
795
|
+
def _value_to_pos(self, value: TNum) -> int:
|
|
796
|
+
"""Map a typed numeric value to an integer slider position.
|
|
797
|
+
|
|
798
|
+
Args:
|
|
799
|
+
value: Typed numeric value.
|
|
800
|
+
|
|
801
|
+
Returns:
|
|
802
|
+
Integer slider position.
|
|
803
|
+
"""
|
|
804
|
+
raise NotImplementedError
|
|
805
|
+
|
|
806
|
+
def _get_default_pos(self) -> int:
|
|
807
|
+
"""Return the default marker position.
|
|
808
|
+
|
|
809
|
+
Returns:
|
|
810
|
+
Slider position corresponding to the default value.
|
|
811
|
+
"""
|
|
812
|
+
return self._value_to_pos(self._default_value)
|
|
813
|
+
|
|
814
|
+
def value_in_limit(self, value: TNum) -> bool:
|
|
815
|
+
"""Return whether a value lies within the configured hard limits.
|
|
816
|
+
|
|
817
|
+
Args:
|
|
818
|
+
value: Value to test.
|
|
819
|
+
|
|
820
|
+
Returns:
|
|
821
|
+
``True`` if the value is allowed, otherwise ``False``.
|
|
822
|
+
"""
|
|
823
|
+
if self._min_limit is not None:
|
|
824
|
+
if value < self._min_limit:
|
|
825
|
+
return False
|
|
826
|
+
elif value == self._min_limit:
|
|
827
|
+
return self._min_limit_inclusive
|
|
828
|
+
|
|
829
|
+
if self._max_limit is not None:
|
|
830
|
+
if value > self._max_limit:
|
|
831
|
+
return False
|
|
832
|
+
elif value == self._max_limit:
|
|
833
|
+
return self._max_limit_inclusive
|
|
834
|
+
|
|
835
|
+
return True
|
|
836
|
+
|
|
837
|
+
def _range_err_msg(self, value_text: str) -> str:
|
|
838
|
+
"""Build an error message describing the allowed numeric interval.
|
|
839
|
+
|
|
840
|
+
Args:
|
|
841
|
+
value_text: Label for the offending value.
|
|
842
|
+
|
|
843
|
+
Returns:
|
|
844
|
+
Human-readable error message.
|
|
845
|
+
"""
|
|
846
|
+
if self._min_limit is None and self._max_limit is None:
|
|
847
|
+
return f"{value_text} is invalid."
|
|
848
|
+
|
|
849
|
+
min_text = "-inf" if self._min_limit is None else pretty_sci_text(self._min_limit, sig_digits=3)
|
|
850
|
+
max_text = "+inf" if self._max_limit is None else pretty_sci_text(self._max_limit, sig_digits=3)
|
|
851
|
+
left = "[" if self._min_limit_inclusive else "("
|
|
852
|
+
right = "]" if self._max_limit_inclusive else ")"
|
|
853
|
+
return f"{value_text} must be in {left}{min_text}, {max_text}{right}"
|
|
854
|
+
|
|
855
|
+
def _update_all_labels(self) -> None:
|
|
856
|
+
"""Refresh the value and range labels for the current numeric range."""
|
|
857
|
+
self._update_value_label()
|
|
858
|
+
self._min_label.setText(self._value_to_text(self._min_value))
|
|
859
|
+
self._max_label.setText(self._value_to_text(self._max_value))
|
|
860
|
+
|
|
861
|
+
def _enter_special_mode(self, sv: SpecialValue) -> None:
|
|
862
|
+
"""Enter positive- or negative-infinity special mode.
|
|
863
|
+
|
|
864
|
+
Args:
|
|
865
|
+
sv: Special value marker, either ``"+inf"`` or ``"-inf"``.
|
|
866
|
+
"""
|
|
867
|
+
self._current_special_value = sv
|
|
868
|
+
self.set_slider_enabled(False)
|
|
869
|
+
self._sync_special_menu_state()
|
|
870
|
+
self._update_value_label()
|
|
871
|
+
|
|
872
|
+
def _exit_special_mode(self) -> None:
|
|
873
|
+
"""Leave special infinity mode and restore slider interaction."""
|
|
874
|
+
self._current_special_value = None
|
|
875
|
+
self.set_slider_enabled(True)
|
|
876
|
+
self._sync_special_menu_state()
|
|
877
|
+
self._update_value_label()
|
|
878
|
+
|
|
879
|
+
def _sync_special_menu_state(self) -> None:
|
|
880
|
+
"""Synchronize context-menu checks and enabled states for special mode."""
|
|
881
|
+
in_special = self._current_special_value is not None
|
|
882
|
+
|
|
883
|
+
self._set_right_click_item_checked("set_to_pos_inf", self._current_special_value == "+inf")
|
|
884
|
+
self._set_right_click_item_checked("set_to_neg_inf", self._current_special_value == "-inf")
|
|
885
|
+
|
|
886
|
+
for item_id in ("set_value", "edit_config", "reset_to_default", "save_as_default"):
|
|
887
|
+
self._set_right_click_item_enabled(item_id, not in_special)
|
|
888
|
+
|
|
889
|
+
self._set_right_click_item_enabled("set_to_pos_inf", self._special_allowed["+inf"])
|
|
890
|
+
self._set_right_click_item_enabled("set_to_neg_inf", self._special_allowed["-inf"])
|
|
891
|
+
|
|
892
|
+
def _check_special_permitted(self, sv: SpecialValue) -> None:
|
|
893
|
+
"""Raise if a special value is not valid for this slider.
|
|
894
|
+
|
|
895
|
+
Args:
|
|
896
|
+
sv: Special value marker, ``"+inf"`` or ``"-inf"``.
|
|
897
|
+
|
|
898
|
+
Raises:
|
|
899
|
+
ValueError: If the value is not permitted by features, limits,
|
|
900
|
+
or the runtime allow state.
|
|
901
|
+
"""
|
|
902
|
+
if SliderFeature.SPECIAL not in self._features:
|
|
903
|
+
raise ValueError(f"{sv!r} requires SliderFeature.SPECIAL")
|
|
904
|
+
if sv == "+inf" and self._max_limit is not None:
|
|
905
|
+
raise ValueError("+inf is not allowed when max_limit is set")
|
|
906
|
+
if sv == "-inf" and self._min_limit is not None:
|
|
907
|
+
raise ValueError("-inf is not allowed when min_limit is set")
|
|
908
|
+
if not self._special_allowed[sv]:
|
|
909
|
+
raise ValueError(f"{sv!r} is currently not allowed")
|
|
910
|
+
|
|
911
|
+
def _check_new_config(self, new_min: TNum, new_max: TNum, new_fmt: str) -> str | None:
|
|
912
|
+
"""Check a proposed range and format before applying them.
|
|
913
|
+
|
|
914
|
+
Args:
|
|
915
|
+
new_min: Proposed minimum value.
|
|
916
|
+
new_max: Proposed maximum value.
|
|
917
|
+
new_fmt: Proposed display format string.
|
|
918
|
+
|
|
919
|
+
Returns:
|
|
920
|
+
Error message, or ``None`` if the configuration is valid.
|
|
921
|
+
"""
|
|
922
|
+
eval_fmt = get_numeric_format_field(new_fmt)
|
|
923
|
+
mn = self.evaluate_value(new_min, eval_fmt)
|
|
924
|
+
mx = self.evaluate_value(new_max, eval_fmt)
|
|
925
|
+
if not format_is_valid(new_fmt, mn):
|
|
926
|
+
return f"Invalid format: {new_fmt!r}"
|
|
927
|
+
if mn >= mx:
|
|
928
|
+
return f"Min must be less than max (with this format: {mn} and {mx})."
|
|
929
|
+
if not self.value_in_limit(mn):
|
|
930
|
+
return self._range_err_msg("Min")
|
|
931
|
+
if not self.value_in_limit(mx):
|
|
932
|
+
return self._range_err_msg("Max")
|
|
933
|
+
return None
|
|
934
|
+
|
|
935
|
+
def _update_value_label(self) -> None:
|
|
936
|
+
"""Refresh the value label, including special infinity markers."""
|
|
937
|
+
if self._current_special_value == "+inf":
|
|
938
|
+
self._value_label.setText("+inf")
|
|
939
|
+
elif self._current_special_value == "-inf":
|
|
940
|
+
self._value_label.setText("-inf")
|
|
941
|
+
else:
|
|
942
|
+
self._value_label.setText(self._value_to_text(self._current_value))
|
|
943
|
+
|
|
944
|
+
def _add_right_click_items(self):
|
|
945
|
+
"""Append numeric-slider context-menu items allowed by ``features``."""
|
|
946
|
+
f = self._features
|
|
947
|
+
items: List[Dict[str, Any]] = [{"id": "sep"}]
|
|
948
|
+
|
|
949
|
+
if SliderFeature.SET_VALUE in f:
|
|
950
|
+
items.append({"id": "set_value", "text": "Set Value..."})
|
|
951
|
+
|
|
952
|
+
if SliderFeature.SPECIAL in f:
|
|
953
|
+
if self._max_limit is None and self._max_limit_inclusive:
|
|
954
|
+
items.append({"id": "set_to_pos_inf", "text": "Set Value to +inf", "checkable": True, "checked": False})
|
|
955
|
+
if self._min_limit is None and self._min_limit_inclusive:
|
|
956
|
+
items.append({"id": "set_to_neg_inf", "text": "Set Value to -inf", "checkable": True, "checked": False})
|
|
957
|
+
|
|
958
|
+
if SliderFeature.CONFIG in f:
|
|
959
|
+
items += [{"id": "sep"}, {"id": "edit_config", "text": "Edit Configuration..."}]
|
|
960
|
+
|
|
961
|
+
self._right_click_items.extend(items)
|
|
962
|
+
|
|
963
|
+
def _right_click_requested(self, item_id, checked: bool) -> None:
|
|
964
|
+
"""Dispatch numeric-slider context-menu actions.
|
|
965
|
+
|
|
966
|
+
Args:
|
|
967
|
+
item_id: Triggered menu item identifier.
|
|
968
|
+
checked: Checked state associated with the action.
|
|
969
|
+
"""
|
|
970
|
+
super()._right_click_requested(item_id, checked)
|
|
971
|
+
if item_id == "edit_config":
|
|
972
|
+
self._open_edit_config_dlg()
|
|
973
|
+
elif item_id == "set_value":
|
|
974
|
+
self._open_set_value_dlg()
|
|
975
|
+
elif item_id == "set_to_pos_inf":
|
|
976
|
+
if checked:
|
|
977
|
+
self._enter_special_mode("+inf")
|
|
978
|
+
else:
|
|
979
|
+
self._exit_special_mode()
|
|
980
|
+
self.valueChanged.emit(self.get_value())
|
|
981
|
+
elif item_id == "set_to_neg_inf":
|
|
982
|
+
if checked:
|
|
983
|
+
self._enter_special_mode("-inf")
|
|
984
|
+
else:
|
|
985
|
+
self._exit_special_mode()
|
|
986
|
+
self.valueChanged.emit(self.get_value())
|
|
987
|
+
|
|
988
|
+
def _open_edit_config_dlg(self) -> None:
|
|
989
|
+
"""Open the configuration dialog and apply accepted changes."""
|
|
990
|
+
dlg = EditConfigDialog[TNum](
|
|
991
|
+
slider_name=self._label_text,
|
|
992
|
+
value_is_int=self.value_is_int,
|
|
993
|
+
min_val=self._min_value,
|
|
994
|
+
max_val=self._max_value,
|
|
995
|
+
val_fmt=self._val_fmt,
|
|
996
|
+
min_limit=self._min_limit,
|
|
997
|
+
max_limit=self._max_limit,
|
|
998
|
+
min_limit_inclusive=self._min_limit_inclusive,
|
|
999
|
+
max_limit_inclusive=self._max_limit_inclusive,
|
|
1000
|
+
validate=self._check_new_config,
|
|
1001
|
+
parent=self,
|
|
1002
|
+
)
|
|
1003
|
+
if dlg.exec() != QDialog.Accepted:
|
|
1004
|
+
return
|
|
1005
|
+
new_min, new_max, new_fmt = dlg.get_values()
|
|
1006
|
+
|
|
1007
|
+
old_fmt = self._val_fmt
|
|
1008
|
+
old_min = self._min_value
|
|
1009
|
+
old_max = self._max_value
|
|
1010
|
+
saved_cur = self._current_value
|
|
1011
|
+
saved_dft = self._default_value
|
|
1012
|
+
old_value = self.get_value()
|
|
1013
|
+
|
|
1014
|
+
self.set_format(new_fmt)
|
|
1015
|
+
|
|
1016
|
+
new_min = self.evaluate_value(new_min)
|
|
1017
|
+
new_max = self.evaluate_value(new_max)
|
|
1018
|
+
old_cur = self.evaluate_value(self._current_value)
|
|
1019
|
+
old_dft = self.evaluate_value(self._default_value)
|
|
1020
|
+
|
|
1021
|
+
needs_clamp = not (new_min <= old_cur <= new_max and new_min <= old_dft <= new_max)
|
|
1022
|
+
|
|
1023
|
+
if needs_clamp:
|
|
1024
|
+
new_cur = max(new_min, min(old_cur, new_max))
|
|
1025
|
+
new_dft = max(new_min, min(old_dft, new_max))
|
|
1026
|
+
self.set_border(SliderBorderState.ERROR)
|
|
1027
|
+
try:
|
|
1028
|
+
result = slider_ask_clamp_value(
|
|
1029
|
+
proposed_range=f"[{self._value_to_text(new_min)}, {self._value_to_text(new_max)}]",
|
|
1030
|
+
old_cur=self._value_to_text(old_cur),
|
|
1031
|
+
new_cur=self._value_to_text(new_cur),
|
|
1032
|
+
old_dft=self._value_to_text(old_dft),
|
|
1033
|
+
new_dft=self._value_to_text(new_dft),
|
|
1034
|
+
parent=self,
|
|
1035
|
+
)
|
|
1036
|
+
finally:
|
|
1037
|
+
self.set_border(SliderBorderState.OFF)
|
|
1038
|
+
if result != AskResult.YES:
|
|
1039
|
+
self.set_format(old_fmt)
|
|
1040
|
+
self._current_value, self._default_value = saved_cur, saved_dft
|
|
1041
|
+
self._min_value, self._max_value = old_min, old_max
|
|
1042
|
+
return
|
|
1043
|
+
|
|
1044
|
+
self.set_range(new_min, new_max)
|
|
1045
|
+
self._update_layout()
|
|
1046
|
+
|
|
1047
|
+
if self.get_value() != old_value:
|
|
1048
|
+
self.valueChanged.emit(self.get_value())
|
|
1049
|
+
if self._default_value != saved_dft:
|
|
1050
|
+
self.defaultChanged.emit(self._default_value)
|
|
1051
|
+
if (old_min, old_max, old_fmt) != (self._min_value, self._max_value, self._val_fmt):
|
|
1052
|
+
self.configChanged.emit(self.get_config())
|
|
1053
|
+
|
|
1054
|
+
def _open_set_value_dlg(self) -> None:
|
|
1055
|
+
"""Open the value-entry dialog and apply the accepted value."""
|
|
1056
|
+
dlg = SetValueDialog[TNum](
|
|
1057
|
+
slider_name=self._label_text,
|
|
1058
|
+
value_is_int=self.value_is_int,
|
|
1059
|
+
current_value=self._current_value,
|
|
1060
|
+
val_fmt=self._val_fmt,
|
|
1061
|
+
min_limit=self._min_limit,
|
|
1062
|
+
max_limit=self._max_limit,
|
|
1063
|
+
min_limit_inclusive=self._min_limit_inclusive,
|
|
1064
|
+
max_limit_inclusive=self._max_limit_inclusive,
|
|
1065
|
+
parent=self,
|
|
1066
|
+
)
|
|
1067
|
+
if dlg.exec() != QDialog.Accepted:
|
|
1068
|
+
return
|
|
1069
|
+
|
|
1070
|
+
new_cur = self.evaluate_value(dlg.get_values())
|
|
1071
|
+
|
|
1072
|
+
old_cur = self._current_value
|
|
1073
|
+
old_dft = self._default_value
|
|
1074
|
+
old_range = (self._min_value, self._max_value)
|
|
1075
|
+
|
|
1076
|
+
if not (self._min_value <= new_cur <= self._max_value):
|
|
1077
|
+
new_min = min(new_cur, self._min_value)
|
|
1078
|
+
new_max = max(new_cur, self._max_value)
|
|
1079
|
+
self.set_border(SliderBorderState.ERROR)
|
|
1080
|
+
try:
|
|
1081
|
+
result = slider_ask_extend_range(
|
|
1082
|
+
proposed_val=self._value_to_text(new_cur),
|
|
1083
|
+
old_range=f"[{self._value_to_text(self._min_value)}, {self._value_to_text(self._max_value)}]",
|
|
1084
|
+
new_range=f"[{self._value_to_text(new_min)}, {self._value_to_text(new_max)}]",
|
|
1085
|
+
parent=self,
|
|
1086
|
+
)
|
|
1087
|
+
finally:
|
|
1088
|
+
self.set_border(SliderBorderState.OFF)
|
|
1089
|
+
if result != AskResult.YES:
|
|
1090
|
+
return
|
|
1091
|
+
|
|
1092
|
+
self.set_value(new_cur, keep_default_value=True)
|
|
1093
|
+
|
|
1094
|
+
if old_cur != new_cur:
|
|
1095
|
+
self.valueChanged.emit(self._current_value)
|
|
1096
|
+
if (self._min_value, self._max_value) != old_range:
|
|
1097
|
+
self.configChanged.emit(self.get_config())
|
|
1098
|
+
|
|
1099
|
+
def save_current_value_as_default_value(self):
|
|
1100
|
+
"""Store the current numeric value as the default value."""
|
|
1101
|
+
self._default_value = self._current_value
|
|
1102
|
+
self._update_mapping_anchors()
|
|
1103
|
+
with self._quiet_updates():
|
|
1104
|
+
self._reset_to_default_value()
|
|
1105
|
+
if self._default_red_marker is not None:
|
|
1106
|
+
self._default_red_marker.update()
|
|
1107
|
+
self.defaultChanged.emit(self._default_value)
|
|
1108
|
+
|
|
1109
|
+
def _reset_to_default_value(self) -> None:
|
|
1110
|
+
"""Reset the slider to its stored default value."""
|
|
1111
|
+
self._slider.setValue(self._value_to_pos(self._default_value))
|
|
1112
|
+
|
|
1113
|
+
def get_config(self) -> dict:
|
|
1114
|
+
"""Return a serializable configuration snapshot.
|
|
1115
|
+
|
|
1116
|
+
Returns:
|
|
1117
|
+
Mapping containing the current range and format.
|
|
1118
|
+
"""
|
|
1119
|
+
return {
|
|
1120
|
+
"min_val": self._min_value,
|
|
1121
|
+
"max_val": self._max_value,
|
|
1122
|
+
"val_fmt": self._val_fmt
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
def _validate_config(self, config: dict[str, Any]) -> dict[str, Any]:
|
|
1126
|
+
"""Merge and check a configuration mapping before applying it.
|
|
1127
|
+
|
|
1128
|
+
Args:
|
|
1129
|
+
config: Partial or complete configuration mapping.
|
|
1130
|
+
|
|
1131
|
+
Returns:
|
|
1132
|
+
Complete, validated configuration mapping.
|
|
1133
|
+
|
|
1134
|
+
Raises:
|
|
1135
|
+
ValueError: If the range or format is invalid.
|
|
1136
|
+
"""
|
|
1137
|
+
config = super()._validate_config(config)
|
|
1138
|
+
msg = self._check_new_config(config["min_val"], config["max_val"], config["val_fmt"])
|
|
1139
|
+
if msg:
|
|
1140
|
+
raise ValueError(msg)
|
|
1141
|
+
return config
|
|
1142
|
+
|
|
1143
|
+
def _apply_config(self, config: dict) -> None:
|
|
1144
|
+
"""Apply a validated configuration mapping.
|
|
1145
|
+
|
|
1146
|
+
Args:
|
|
1147
|
+
config: Configuration mapping to apply.
|
|
1148
|
+
"""
|
|
1149
|
+
self.set_format(config["val_fmt"])
|
|
1150
|
+
self.set_range(config["min_val"], config["max_val"])
|
|
1151
|
+
|
|
1152
|
+
def get_value(self):
|
|
1153
|
+
"""Return the current value, including special infinity states.
|
|
1154
|
+
|
|
1155
|
+
Returns:
|
|
1156
|
+
Current numeric value, ``math.inf``, or ``-math.inf``.
|
|
1157
|
+
"""
|
|
1158
|
+
if self._current_special_value == "+inf":
|
|
1159
|
+
return math.inf
|
|
1160
|
+
if self._current_special_value == "-inf":
|
|
1161
|
+
return -math.inf
|
|
1162
|
+
return self._current_value
|
|
1163
|
+
|
|
1164
|
+
def get_current_value(self) -> TNum:
|
|
1165
|
+
"""Return the stored current finite value.
|
|
1166
|
+
|
|
1167
|
+
Returns:
|
|
1168
|
+
Current finite typed value.
|
|
1169
|
+
"""
|
|
1170
|
+
return self._current_value
|
|
1171
|
+
|
|
1172
|
+
def get_default_value(self) -> TNum:
|
|
1173
|
+
"""Return the stored default finite value.
|
|
1174
|
+
|
|
1175
|
+
Returns:
|
|
1176
|
+
Default typed value.
|
|
1177
|
+
"""
|
|
1178
|
+
return self._default_value
|
|
1179
|
+
|
|
1180
|
+
def get_min_value(self) -> TNum:
|
|
1181
|
+
"""Return the current minimum finite value.
|
|
1182
|
+
|
|
1183
|
+
Returns:
|
|
1184
|
+
Minimum typed value.
|
|
1185
|
+
"""
|
|
1186
|
+
return self._min_value
|
|
1187
|
+
|
|
1188
|
+
def get_max_value(self) -> TNum:
|
|
1189
|
+
"""Return the current maximum finite value.
|
|
1190
|
+
|
|
1191
|
+
Returns:
|
|
1192
|
+
Maximum typed value.
|
|
1193
|
+
"""
|
|
1194
|
+
return self._max_value
|
|
1195
|
+
|
|
1196
|
+
def _validate_value(self, value: TNum) -> TNum:
|
|
1197
|
+
"""Validate a numeric value against hard limits.
|
|
1198
|
+
|
|
1199
|
+
Args:
|
|
1200
|
+
value: Value to validate.
|
|
1201
|
+
|
|
1202
|
+
Returns:
|
|
1203
|
+
Normalized and validated typed value.
|
|
1204
|
+
"""
|
|
1205
|
+
value = self.evaluate_value(value)
|
|
1206
|
+
if not self.value_in_limit(value):
|
|
1207
|
+
raise ValueError(self._range_err_msg("value"))
|
|
1208
|
+
return value
|
|
1209
|
+
|
|
1210
|
+
def _apply_value(self, value: TNum, keep_default_value: bool = False) -> None:
|
|
1211
|
+
"""Apply a validated value and extend the visible range if needed.
|
|
1212
|
+
|
|
1213
|
+
Args:
|
|
1214
|
+
value: Validated value to apply.
|
|
1215
|
+
keep_default_value: Whether to preserve the current default value.
|
|
1216
|
+
"""
|
|
1217
|
+
self._current_value = value
|
|
1218
|
+
if not keep_default_value:
|
|
1219
|
+
self._default_value = self._current_value
|
|
1220
|
+
|
|
1221
|
+
self._min_value = min(self._min_value, value)
|
|
1222
|
+
self._max_value = max(self._max_value, value)
|
|
1223
|
+
|
|
1224
|
+
self._update_mapping_anchors()
|
|
1225
|
+
with self._quiet_updates():
|
|
1226
|
+
self._apply_range_to_slider()
|
|
1227
|
+
self._slider.setValue(self._value_to_pos(self._current_value))
|
|
1228
|
+
|
|
1229
|
+
self._update_all_labels()
|
|
1230
|
+
self._update_value_width()
|
|
1231
|
+
|
|
1232
|
+
def set_value(self, value: Any, keep_default_value: bool = False) -> None:
|
|
1233
|
+
"""Set the current value, supporting special infinity markers.
|
|
1234
|
+
|
|
1235
|
+
Args:
|
|
1236
|
+
value: New value or special infinity marker.
|
|
1237
|
+
keep_default_value: Whether to leave the stored default unchanged.
|
|
1238
|
+
|
|
1239
|
+
Raises:
|
|
1240
|
+
ValueError: If a ±inf value is not permitted (features, limits, or
|
|
1241
|
+
runtime allow state) or a finite value violates the limits.
|
|
1242
|
+
TypeError: If the value has the wrong type (see subclasses).
|
|
1243
|
+
"""
|
|
1244
|
+
sv = normalize_special_value(value)
|
|
1245
|
+
if sv is not None:
|
|
1246
|
+
self._check_special_permitted(sv)
|
|
1247
|
+
self._enter_special_mode(sv)
|
|
1248
|
+
return
|
|
1249
|
+
|
|
1250
|
+
self._exit_special_mode()
|
|
1251
|
+
validated = self._validate_value(value)
|
|
1252
|
+
self._apply_value(validated, keep_default_value)
|
|
1253
|
+
|
|
1254
|
+
def set_default_value(self, value: TNum) -> None:
|
|
1255
|
+
"""Set the default finite value.
|
|
1256
|
+
|
|
1257
|
+
Args:
|
|
1258
|
+
value: New default value.
|
|
1259
|
+
"""
|
|
1260
|
+
self._default_value = self._validate_value(value)
|
|
1261
|
+
self._min_value = min(self._min_value, self._default_value)
|
|
1262
|
+
self._max_value = max(self._max_value, self._default_value)
|
|
1263
|
+
|
|
1264
|
+
self._update_mapping_anchors()
|
|
1265
|
+
with self._quiet_updates():
|
|
1266
|
+
self._apply_range_to_slider()
|
|
1267
|
+
self._slider.setValue(self._value_to_pos(self._current_value))
|
|
1268
|
+
if self._default_red_marker is not None:
|
|
1269
|
+
self._default_red_marker.update()
|
|
1270
|
+
|
|
1271
|
+
self._update_all_labels()
|
|
1272
|
+
self._update_value_width()
|
|
1273
|
+
|
|
1274
|
+
def _validate_range(self, min_val: TNum, max_val: TNum) -> Tuple[TNum, TNum]:
|
|
1275
|
+
"""Validate a numeric range.
|
|
1276
|
+
|
|
1277
|
+
Args:
|
|
1278
|
+
min_val: Proposed minimum value.
|
|
1279
|
+
max_val: Proposed maximum value.
|
|
1280
|
+
|
|
1281
|
+
Returns:
|
|
1282
|
+
Tuple of normalized minimum and maximum values.
|
|
1283
|
+
"""
|
|
1284
|
+
min_val = self.evaluate_value(min_val)
|
|
1285
|
+
max_val = self.evaluate_value(max_val)
|
|
1286
|
+
if min_val >= max_val:
|
|
1287
|
+
raise ValueError(f"min_val ({min_val}) must be less than max_val ({max_val})")
|
|
1288
|
+
|
|
1289
|
+
if not self.value_in_limit(min_val):
|
|
1290
|
+
raise ValueError(self._range_err_msg("min_val"))
|
|
1291
|
+
if not self.value_in_limit(max_val):
|
|
1292
|
+
raise ValueError(self._range_err_msg("max_val"))
|
|
1293
|
+
return min_val, max_val
|
|
1294
|
+
|
|
1295
|
+
def set_range(self, min_val: TNum, max_val: TNum) -> None:
|
|
1296
|
+
"""Set the numeric range and clamp current/default values as needed.
|
|
1297
|
+
|
|
1298
|
+
Args:
|
|
1299
|
+
min_val: New minimum value.
|
|
1300
|
+
max_val: New maximum value.
|
|
1301
|
+
"""
|
|
1302
|
+
new_min_val, new_max_val = self._validate_range(min_val, max_val)
|
|
1303
|
+
self._min_value = new_min_val
|
|
1304
|
+
self._max_value = new_max_val
|
|
1305
|
+
|
|
1306
|
+
if self._current_value < new_min_val:
|
|
1307
|
+
self._current_value = new_min_val
|
|
1308
|
+
elif self._current_value > new_max_val:
|
|
1309
|
+
self._current_value = new_max_val
|
|
1310
|
+
|
|
1311
|
+
if self._default_value < new_min_val:
|
|
1312
|
+
self._default_value = new_min_val
|
|
1313
|
+
elif self._default_value > new_max_val:
|
|
1314
|
+
self._default_value = new_max_val
|
|
1315
|
+
|
|
1316
|
+
self._update_mapping_anchors()
|
|
1317
|
+
with self._quiet_updates():
|
|
1318
|
+
self._apply_range_to_slider()
|
|
1319
|
+
self._slider.setValue(self._value_to_pos(self._current_value))
|
|
1320
|
+
|
|
1321
|
+
self._update_all_labels()
|
|
1322
|
+
self._update_value_width()
|
|
1323
|
+
|
|
1324
|
+
def set_min_value(self, min_val: TNum) -> None:
|
|
1325
|
+
"""Update only the minimum bound.
|
|
1326
|
+
|
|
1327
|
+
Args:
|
|
1328
|
+
min_val: New minimum value.
|
|
1329
|
+
"""
|
|
1330
|
+
self.set_range(min_val, self._max_value)
|
|
1331
|
+
|
|
1332
|
+
def set_max_value(self, max_val: TNum) -> None:
|
|
1333
|
+
"""Update only the maximum bound.
|
|
1334
|
+
|
|
1335
|
+
Args:
|
|
1336
|
+
max_val: New maximum value.
|
|
1337
|
+
"""
|
|
1338
|
+
self.set_range(self._min_value, max_val)
|
|
1339
|
+
|
|
1340
|
+
def set_special_value_allowed(self, mode: str, allowed: bool) -> None:
|
|
1341
|
+
"""Allow or forbid a special-value context-menu action.
|
|
1342
|
+
|
|
1343
|
+
If the slider is currently in the forbidden mode, it leaves it.
|
|
1344
|
+
While a mode is forbidden, ``set_value`` raises ``ValueError`` for it.
|
|
1345
|
+
|
|
1346
|
+
Args:
|
|
1347
|
+
mode: Special mode, ``"+inf"`` or ``"-inf"``.
|
|
1348
|
+
allowed: Whether the action may be enabled.
|
|
1349
|
+
"""
|
|
1350
|
+
if mode not in self._SPECIAL_ITEM_IDS:
|
|
1351
|
+
raise ValueError(f"unknown special mode: {mode!r}")
|
|
1352
|
+
self._special_allowed[mode] = bool(allowed)
|
|
1353
|
+
if not allowed and self._current_special_value == mode:
|
|
1354
|
+
self._exit_special_mode()
|
|
1355
|
+
self._sync_special_menu_state()
|
|
1356
|
+
|
|
1357
|
+
def is_special_value_allowed(self, mode: str) -> bool:
|
|
1358
|
+
"""Return whether a special-value action is allowed.
|
|
1359
|
+
|
|
1360
|
+
Args:
|
|
1361
|
+
mode: Special mode, ``"+inf"`` or ``"-inf"``.
|
|
1362
|
+
|
|
1363
|
+
Returns:
|
|
1364
|
+
``True`` if the action may be enabled.
|
|
1365
|
+
"""
|
|
1366
|
+
return self._special_allowed.get(mode, False)
|
|
1367
|
+
|
|
1368
|
+
def get_special_value(self) -> Optional[SpecialValue]:
|
|
1369
|
+
"""Return the active special value.
|
|
1370
|
+
|
|
1371
|
+
Returns:
|
|
1372
|
+
``"+inf"`` or ``"-inf"`` if the slider is in special mode,
|
|
1373
|
+
otherwise ``None``.
|
|
1374
|
+
"""
|
|
1375
|
+
return self._current_special_value
|
|
1376
|
+
|
|
1377
|
+
|
|
1378
|
+
class IntSlider(NumericSliderBase[int]):
|
|
1379
|
+
"""Integer-valued slider whose QSlider range matches the numeric range directly."""
|
|
1380
|
+
|
|
1381
|
+
def __init__(
|
|
1382
|
+
self,
|
|
1383
|
+
label: str,
|
|
1384
|
+
min_val: int,
|
|
1385
|
+
max_val: int,
|
|
1386
|
+
unit: str = "",
|
|
1387
|
+
init_val: Optional[int] = None,
|
|
1388
|
+
val_fmt: str = "{:d}",
|
|
1389
|
+
min_limit: int | None = None,
|
|
1390
|
+
max_limit: int | None = None,
|
|
1391
|
+
min_limit_inclusive: bool = True,
|
|
1392
|
+
max_limit_inclusive: bool = True,
|
|
1393
|
+
show_range: bool = True,
|
|
1394
|
+
orientation: Qt.Orientation = Qt.Horizontal,
|
|
1395
|
+
features: SliderFeature = SliderFeature.ALL,
|
|
1396
|
+
parent: Optional[QWidget] = None,
|
|
1397
|
+
):
|
|
1398
|
+
"""Initialize the integer slider.
|
|
1399
|
+
|
|
1400
|
+
Args:
|
|
1401
|
+
label: Slider label text.
|
|
1402
|
+
min_val: Minimum numeric value.
|
|
1403
|
+
max_val: Maximum numeric value.
|
|
1404
|
+
unit: Optional unit label.
|
|
1405
|
+
init_val: Initial value. Defaults to the midpoint of the range.
|
|
1406
|
+
val_fmt: Integer display format string.
|
|
1407
|
+
min_limit: Optional lower hard limit.
|
|
1408
|
+
max_limit: Optional upper hard limit.
|
|
1409
|
+
min_limit_inclusive: Whether the lower hard limit is inclusive.
|
|
1410
|
+
max_limit_inclusive: Whether the upper hard limit is inclusive.
|
|
1411
|
+
show_range: Initial visibility of range labels and ticks; change
|
|
1412
|
+
later with ``set_show_range``.
|
|
1413
|
+
orientation: Slider orientation.
|
|
1414
|
+
features: Context-menu features offered by the slider.
|
|
1415
|
+
Defaults to ``SliderFeature.ALL``.
|
|
1416
|
+
parent: Optional parent widget.
|
|
1417
|
+
"""
|
|
1418
|
+
if init_val is None:
|
|
1419
|
+
init_val = (min_val + max_val) // 2
|
|
1420
|
+
|
|
1421
|
+
super().__init__(
|
|
1422
|
+
label=label,
|
|
1423
|
+
unit=unit,
|
|
1424
|
+
value_is_int=True,
|
|
1425
|
+
min_val=min_val,
|
|
1426
|
+
max_val=max_val,
|
|
1427
|
+
init_val=init_val,
|
|
1428
|
+
val_fmt=val_fmt,
|
|
1429
|
+
min_limit=min_limit,
|
|
1430
|
+
max_limit=max_limit,
|
|
1431
|
+
min_limit_inclusive=min_limit_inclusive,
|
|
1432
|
+
max_limit_inclusive=max_limit_inclusive,
|
|
1433
|
+
orientation=orientation,
|
|
1434
|
+
show_range=show_range,
|
|
1435
|
+
features=features,
|
|
1436
|
+
parent=parent,
|
|
1437
|
+
)
|
|
1438
|
+
|
|
1439
|
+
def _setup_slider(self) -> None:
|
|
1440
|
+
"""Configure the integer slider range and initial position."""
|
|
1441
|
+
self._slider.setRange(self._min_value, self._max_value)
|
|
1442
|
+
self._slider.setValue(self._default_value)
|
|
1443
|
+
|
|
1444
|
+
def evaluate_value(self, value: int, eval_fmt: str | None = None) -> int:
|
|
1445
|
+
"""Normalize an integer value using the evaluation format.
|
|
1446
|
+
|
|
1447
|
+
Args:
|
|
1448
|
+
value: Integer value to normalize.
|
|
1449
|
+
eval_fmt: Format to use instead of the current one.
|
|
1450
|
+
|
|
1451
|
+
Returns:
|
|
1452
|
+
Normalized integer value.
|
|
1453
|
+
"""
|
|
1454
|
+
fmt = self._eval_fmt if eval_fmt is None else eval_fmt
|
|
1455
|
+
try:
|
|
1456
|
+
return int(fmt.format(value))
|
|
1457
|
+
except Exception:
|
|
1458
|
+
return int(value)
|
|
1459
|
+
|
|
1460
|
+
def _apply_range_to_slider(self) -> None:
|
|
1461
|
+
"""Apply numeric bounds directly to the underlying QSlider."""
|
|
1462
|
+
self._slider.setRange(int(self._min_value), int(self._max_value))
|
|
1463
|
+
|
|
1464
|
+
def _pos_to_value(self, pos: int) -> int:
|
|
1465
|
+
"""Convert an integer slider position to a typed value.
|
|
1466
|
+
|
|
1467
|
+
Args:
|
|
1468
|
+
pos: Slider position.
|
|
1469
|
+
|
|
1470
|
+
Returns:
|
|
1471
|
+
Same integer value.
|
|
1472
|
+
"""
|
|
1473
|
+
return pos
|
|
1474
|
+
|
|
1475
|
+
def _value_to_pos(self, value: int) -> int:
|
|
1476
|
+
"""Convert an integer value to a slider position.
|
|
1477
|
+
|
|
1478
|
+
Args:
|
|
1479
|
+
value: Integer value.
|
|
1480
|
+
|
|
1481
|
+
Returns:
|
|
1482
|
+
Same integer position.
|
|
1483
|
+
"""
|
|
1484
|
+
return value
|
|
1485
|
+
|
|
1486
|
+
def _validate_value(self, value: int) -> int:
|
|
1487
|
+
"""Validate that the supplied value is an integer.
|
|
1488
|
+
|
|
1489
|
+
Args:
|
|
1490
|
+
value: Value to validate.
|
|
1491
|
+
|
|
1492
|
+
Returns:
|
|
1493
|
+
Validated integer value.
|
|
1494
|
+
|
|
1495
|
+
Raises:
|
|
1496
|
+
TypeError: If the value is not an integer (``bool`` is rejected).
|
|
1497
|
+
"""
|
|
1498
|
+
if isinstance(value, bool) or not isinstance(value, numbers.Integral):
|
|
1499
|
+
raise TypeError(f"value must be int, not {type(value).__name__}")
|
|
1500
|
+
return super()._validate_value(int(value))
|
|
1501
|
+
|
|
1502
|
+
|
|
1503
|
+
class FloatSlider(NumericSliderBase[float]):
|
|
1504
|
+
"""Float-valued slider backed by an integer QSlider with piecewise mapping."""
|
|
1505
|
+
|
|
1506
|
+
def __init__(
|
|
1507
|
+
self,
|
|
1508
|
+
label: str,
|
|
1509
|
+
min_val: float,
|
|
1510
|
+
max_val: float,
|
|
1511
|
+
unit: str = "",
|
|
1512
|
+
init_val: Optional[float | str] = None,
|
|
1513
|
+
val_fmt: str = "{:.3f}",
|
|
1514
|
+
min_limit: float | None = None,
|
|
1515
|
+
max_limit: float | None = None,
|
|
1516
|
+
min_limit_inclusive: bool = True,
|
|
1517
|
+
max_limit_inclusive: bool = True,
|
|
1518
|
+
steps: int = 1_000_000,
|
|
1519
|
+
show_range: bool = True,
|
|
1520
|
+
orientation: Qt.Orientation = Qt.Horizontal,
|
|
1521
|
+
features: SliderFeature = SliderFeature.ALL,
|
|
1522
|
+
parent: Optional[QWidget] = None,
|
|
1523
|
+
):
|
|
1524
|
+
"""Initialize the float slider.
|
|
1525
|
+
|
|
1526
|
+
Args:
|
|
1527
|
+
label: Slider label text.
|
|
1528
|
+
min_val: Minimum numeric value.
|
|
1529
|
+
max_val: Maximum numeric value.
|
|
1530
|
+
unit: Optional unit label.
|
|
1531
|
+
init_val: Initial value or special infinity marker.
|
|
1532
|
+
val_fmt: Float display format string.
|
|
1533
|
+
min_limit: Optional lower hard limit.
|
|
1534
|
+
max_limit: Optional upper hard limit.
|
|
1535
|
+
min_limit_inclusive: Whether the lower hard limit is inclusive.
|
|
1536
|
+
max_limit_inclusive: Whether the upper hard limit is inclusive.
|
|
1537
|
+
steps: Integer slider resolution.
|
|
1538
|
+
show_range: Initial visibility of range labels and ticks; change
|
|
1539
|
+
later with ``set_show_range``.
|
|
1540
|
+
orientation: Slider orientation.
|
|
1541
|
+
features: Context-menu features offered by the slider.
|
|
1542
|
+
Defaults to ``SliderFeature.ALL``.
|
|
1543
|
+
parent: Optional parent widget.
|
|
1544
|
+
"""
|
|
1545
|
+
if steps <= 0:
|
|
1546
|
+
raise ValueError("steps must be a positive integer")
|
|
1547
|
+
self._steps = steps
|
|
1548
|
+
|
|
1549
|
+
if init_val is None:
|
|
1550
|
+
init_val = 0.5 * (min_val + max_val)
|
|
1551
|
+
|
|
1552
|
+
super().__init__(
|
|
1553
|
+
label=label,
|
|
1554
|
+
unit=unit,
|
|
1555
|
+
value_is_int=False,
|
|
1556
|
+
min_val=min_val,
|
|
1557
|
+
max_val=max_val,
|
|
1558
|
+
init_val=init_val,
|
|
1559
|
+
val_fmt=val_fmt,
|
|
1560
|
+
min_limit=min_limit,
|
|
1561
|
+
max_limit=max_limit,
|
|
1562
|
+
min_limit_inclusive=min_limit_inclusive,
|
|
1563
|
+
max_limit_inclusive=max_limit_inclusive,
|
|
1564
|
+
show_range=show_range,
|
|
1565
|
+
orientation=orientation,
|
|
1566
|
+
features=features,
|
|
1567
|
+
parent=parent,
|
|
1568
|
+
)
|
|
1569
|
+
|
|
1570
|
+
def _setup_slider(self) -> None:
|
|
1571
|
+
"""Configure the integer-backed slider range and initial position."""
|
|
1572
|
+
self._slider.setRange(0, self._steps)
|
|
1573
|
+
self._slider.setValue(self._value_to_pos(self._default_value))
|
|
1574
|
+
|
|
1575
|
+
def evaluate_value(self, value: float, eval_fmt: str | None = None) -> float:
|
|
1576
|
+
"""Normalize a float value using the evaluation format.
|
|
1577
|
+
|
|
1578
|
+
Args:
|
|
1579
|
+
value: Float value to normalize.
|
|
1580
|
+
eval_fmt: Format to use instead of the current one.
|
|
1581
|
+
|
|
1582
|
+
Returns:
|
|
1583
|
+
Normalized float value.
|
|
1584
|
+
"""
|
|
1585
|
+
fmt = self._eval_fmt if eval_fmt is None else eval_fmt
|
|
1586
|
+
try:
|
|
1587
|
+
return float(fmt.format(value))
|
|
1588
|
+
except Exception:
|
|
1589
|
+
return float(value)
|
|
1590
|
+
|
|
1591
|
+
def _pos_to_value(self, pos: int) -> float:
|
|
1592
|
+
"""Convert a slider position to a float via piecewise linear interpolation.
|
|
1593
|
+
|
|
1594
|
+
Args:
|
|
1595
|
+
pos: Integer slider position.
|
|
1596
|
+
|
|
1597
|
+
Returns:
|
|
1598
|
+
Interpolated float value.
|
|
1599
|
+
"""
|
|
1600
|
+
v_min, v0, v1, v_max = self._min_value, self._v0, self._v1, self._max_value
|
|
1601
|
+
p_min, p0, p1, p_max = self._slider.minimum(), self._p0, self._p1, self._slider.maximum()
|
|
1602
|
+
|
|
1603
|
+
if pos <= p_min:
|
|
1604
|
+
return v_min
|
|
1605
|
+
if pos < p0:
|
|
1606
|
+
return _map_pos_to_val(pos, p_min, p0, v_min, v0)
|
|
1607
|
+
if pos == p0:
|
|
1608
|
+
return v0
|
|
1609
|
+
if pos < p1:
|
|
1610
|
+
return _map_pos_to_val(pos, p0, p1, v0, v1)
|
|
1611
|
+
if pos == p1:
|
|
1612
|
+
return v1
|
|
1613
|
+
if pos < p_max:
|
|
1614
|
+
return _map_pos_to_val(pos, p1, p_max, v1, v_max)
|
|
1615
|
+
return v_max
|
|
1616
|
+
|
|
1617
|
+
def _value_to_pos(self, value: float) -> int:
|
|
1618
|
+
"""Convert a float value to a slider position via piecewise interpolation.
|
|
1619
|
+
|
|
1620
|
+
Args:
|
|
1621
|
+
value: Float value to convert.
|
|
1622
|
+
|
|
1623
|
+
Returns:
|
|
1624
|
+
Integer slider position.
|
|
1625
|
+
"""
|
|
1626
|
+
v_min, v0, v1, v_max = self._min_value, self._v0, self._v1, self._max_value
|
|
1627
|
+
p_min, p0, p1, p_max = self._slider.minimum(), self._p0, self._p1, self._slider.maximum()
|
|
1628
|
+
|
|
1629
|
+
if value <= v_min:
|
|
1630
|
+
return p_min
|
|
1631
|
+
if value < v0:
|
|
1632
|
+
return _map_val_to_pos(value, v_min, v0, p_min, p0)
|
|
1633
|
+
if value == v0:
|
|
1634
|
+
return p0
|
|
1635
|
+
if value < v1:
|
|
1636
|
+
return _map_val_to_pos(value, v0, v1, p0, p1)
|
|
1637
|
+
if value == v1:
|
|
1638
|
+
return p1
|
|
1639
|
+
if value < v_max:
|
|
1640
|
+
return _map_val_to_pos(value, v1, v_max, p1, p_max)
|
|
1641
|
+
return p_max
|
|
1642
|
+
|
|
1643
|
+
def _update_mapping_anchors(self) -> None:
|
|
1644
|
+
"""Update mapping anchors based on the current and default values."""
|
|
1645
|
+
val1, val2 = self._current_value, self._default_value
|
|
1646
|
+
v_min, v_max = self._min_value, self._max_value
|
|
1647
|
+
p_min, p_max = self._slider.minimum(), self._slider.maximum()
|
|
1648
|
+
v0, v1 = (val1, val2) if val1 <= val2 else (val2, val1)
|
|
1649
|
+
|
|
1650
|
+
if p_max - p_min < 3:
|
|
1651
|
+
self._p0, self._p1 = p_min, p_max
|
|
1652
|
+
self._v0, self._v1 = v0, v1
|
|
1653
|
+
return
|
|
1654
|
+
|
|
1655
|
+
lo, hi = p_min + 1, p_max - 1
|
|
1656
|
+
|
|
1657
|
+
p0 = _map_val_to_pos(v0, v_min, v_max, p_min, p_max)
|
|
1658
|
+
p1 = _map_val_to_pos(v1, v_min, v_max, p_min, p_max)
|
|
1659
|
+
|
|
1660
|
+
if v0 > v_min:
|
|
1661
|
+
p0 = max(p0, lo)
|
|
1662
|
+
if v0 < v_max:
|
|
1663
|
+
p0 = min(p0, hi)
|
|
1664
|
+
|
|
1665
|
+
if v1 > v_min:
|
|
1666
|
+
p1 = max(p1, lo)
|
|
1667
|
+
if v1 < v_max:
|
|
1668
|
+
p1 = min(p1, hi)
|
|
1669
|
+
|
|
1670
|
+
if v1 > v0 and p1 == p0:
|
|
1671
|
+
if p0 < hi:
|
|
1672
|
+
p1 = p0 + 1
|
|
1673
|
+
else:
|
|
1674
|
+
p0 = p1 - 1
|
|
1675
|
+
|
|
1676
|
+
self._p0, self._p1 = p0, p1
|
|
1677
|
+
self._v0, self._v1 = v0, v1
|
|
1678
|
+
|
|
1679
|
+
def _validate_value(self, value: float) -> float:
|
|
1680
|
+
"""Validate that the supplied value is a real number.
|
|
1681
|
+
|
|
1682
|
+
Args:
|
|
1683
|
+
value: Value to validate. Python and numpy integer or float
|
|
1684
|
+
types are accepted.
|
|
1685
|
+
|
|
1686
|
+
Returns:
|
|
1687
|
+
Validated value converted to a Python ``float``.
|
|
1688
|
+
|
|
1689
|
+
Raises:
|
|
1690
|
+
TypeError: If the value is not a real number (``bool`` is rejected).
|
|
1691
|
+
"""
|
|
1692
|
+
if isinstance(value, bool) or not isinstance(value, numbers.Real):
|
|
1693
|
+
raise TypeError(f"value must be float, not {type(value).__name__}")
|
|
1694
|
+
return super()._validate_value(float(value))
|
|
1695
|
+
|
|
1696
|
+
|
|
1697
|
+
class ArrEditConfigDialog(QDialog):
|
|
1698
|
+
"""Dialog for editing array-slider length and format."""
|
|
1699
|
+
|
|
1700
|
+
def __init__(
|
|
1701
|
+
self,
|
|
1702
|
+
slider_name: str,
|
|
1703
|
+
arr_length: int,
|
|
1704
|
+
val_fmt: str,
|
|
1705
|
+
parent: Optional[QWidget] = None,
|
|
1706
|
+
):
|
|
1707
|
+
"""Initialize the array-slider configuration dialog.
|
|
1708
|
+
|
|
1709
|
+
Args:
|
|
1710
|
+
slider_name: Display name of the slider.
|
|
1711
|
+
arr_length: Current array length.
|
|
1712
|
+
val_fmt: Current display format string.
|
|
1713
|
+
parent: Optional parent widget.
|
|
1714
|
+
"""
|
|
1715
|
+
super().__init__(parent)
|
|
1716
|
+
self.setWindowTitle("Configuration")
|
|
1717
|
+
|
|
1718
|
+
sb = QSpinBox(self)
|
|
1719
|
+
sb.setMinimum(2)
|
|
1720
|
+
sb.setMaximum(100)
|
|
1721
|
+
sb.setValue(arr_length)
|
|
1722
|
+
sb.ensurePolished()
|
|
1723
|
+
sb.setFixedSize(sb.sizeHint())
|
|
1724
|
+
self._arr_length_sb = sb
|
|
1725
|
+
self._edit_fmt = QLineEdit(val_fmt, self)
|
|
1726
|
+
|
|
1727
|
+
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=self)
|
|
1728
|
+
buttons.accepted.connect(self.accept)
|
|
1729
|
+
buttons.rejected.connect(self.reject)
|
|
1730
|
+
|
|
1731
|
+
form = QFormLayout(self)
|
|
1732
|
+
form.setFormAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
|
1733
|
+
form.addRow("Array Slider:", MathLabel(slider_name, alignment=Qt.AlignLeft | Qt.AlignVCenter))
|
|
1734
|
+
form.addRow("Array Length:", self._arr_length_sb)
|
|
1735
|
+
form.addRow("Format:", self._edit_fmt)
|
|
1736
|
+
form.addRow(buttons)
|
|
1737
|
+
|
|
1738
|
+
self.setSizeGripEnabled(False)
|
|
1739
|
+
self.adjustSize()
|
|
1740
|
+
self.setFixedSize(self.size())
|
|
1741
|
+
|
|
1742
|
+
def accept(self) -> None:
|
|
1743
|
+
"""Validate the format before accepting the dialog."""
|
|
1744
|
+
fmt = self._edit_fmt.text()
|
|
1745
|
+
if not format_is_valid(fmt, 1.0):
|
|
1746
|
+
show_warning("Invalid configuration", f"Invalid format: {fmt!r}", parent=self)
|
|
1747
|
+
return
|
|
1748
|
+
super().accept()
|
|
1749
|
+
|
|
1750
|
+
def get_values(self) -> Tuple[int, str]:
|
|
1751
|
+
"""Return the validated dialog values.
|
|
1752
|
+
|
|
1753
|
+
Returns:
|
|
1754
|
+
Tuple containing array length and format string.
|
|
1755
|
+
"""
|
|
1756
|
+
return (
|
|
1757
|
+
self._arr_length_sb.value(),
|
|
1758
|
+
self._edit_fmt.text()
|
|
1759
|
+
)
|
|
1760
|
+
|
|
1761
|
+
def showEvent(self, event: QShowEvent) -> None:
|
|
1762
|
+
"""Focus the array-length control when the dialog is shown.
|
|
1763
|
+
|
|
1764
|
+
Args:
|
|
1765
|
+
event: Qt show event.
|
|
1766
|
+
"""
|
|
1767
|
+
super().showEvent(event)
|
|
1768
|
+
self._arr_length_sb.setFocus()
|
|
1769
|
+
self._arr_length_sb.selectAll()
|
|
1770
|
+
|
|
1771
|
+
|
|
1772
|
+
class ArraySlider(LabeledSliderBase[float]):
|
|
1773
|
+
"""Slider that selects an index from a stored numeric array."""
|
|
1774
|
+
|
|
1775
|
+
def __init__(
|
|
1776
|
+
self,
|
|
1777
|
+
label: str,
|
|
1778
|
+
arr_length: int,
|
|
1779
|
+
unit: str = "",
|
|
1780
|
+
min_val: float = 0.0,
|
|
1781
|
+
max_val: float = 1.0,
|
|
1782
|
+
init_index: int = 0,
|
|
1783
|
+
orientation: Qt.Orientation = Qt.Horizontal,
|
|
1784
|
+
val_fmt: str = "{:.2f}",
|
|
1785
|
+
show_range: bool = True,
|
|
1786
|
+
features: SliderFeature = SliderFeature.ALL,
|
|
1787
|
+
parent: Optional[QWidget] = None,
|
|
1788
|
+
) -> None:
|
|
1789
|
+
"""Initialize the array slider.
|
|
1790
|
+
|
|
1791
|
+
Args:
|
|
1792
|
+
label: Slider label text.
|
|
1793
|
+
arr_length: Number of array elements.
|
|
1794
|
+
unit: Optional unit label.
|
|
1795
|
+
min_val: Minimum array value.
|
|
1796
|
+
max_val: Maximum array value.
|
|
1797
|
+
init_index: Initial selected index.
|
|
1798
|
+
orientation: Slider orientation.
|
|
1799
|
+
val_fmt: Value display format string.
|
|
1800
|
+
show_range: Initial visibility of range labels and ticks; change
|
|
1801
|
+
later with ``set_show_range``.
|
|
1802
|
+
features: Context-menu features offered by the slider. Only
|
|
1803
|
+
``DEFAULTS`` and ``CONFIG`` apply; ``SET_VALUE`` and
|
|
1804
|
+
``SPECIAL`` are ignored.
|
|
1805
|
+
parent: Optional parent widget.
|
|
1806
|
+
"""
|
|
1807
|
+
if arr_length < 2:
|
|
1808
|
+
raise ValueError("ArraySlider: array_length must be greater than 1.")
|
|
1809
|
+
if max_val < min_val:
|
|
1810
|
+
raise ValueError("ArraySlider: max_val must be >= min_val.")
|
|
1811
|
+
|
|
1812
|
+
self._arr = np.linspace(min_val, max_val, arr_length, dtype=float)
|
|
1813
|
+
self._arr_length = arr_length
|
|
1814
|
+
self._default_index = init_index % arr_length
|
|
1815
|
+
|
|
1816
|
+
self._current_value = self._arr[self._default_index]
|
|
1817
|
+
self._min_value = float(self._arr[0])
|
|
1818
|
+
self._max_value = float(self._arr[-1])
|
|
1819
|
+
|
|
1820
|
+
super().__init__(
|
|
1821
|
+
label=label,
|
|
1822
|
+
unit=unit,
|
|
1823
|
+
min_val=self._min_value,
|
|
1824
|
+
max_val=self._max_value,
|
|
1825
|
+
init_val=self._current_value,
|
|
1826
|
+
orientation=orientation,
|
|
1827
|
+
val_fmt=val_fmt,
|
|
1828
|
+
show_range=show_range,
|
|
1829
|
+
features=features,
|
|
1830
|
+
parent=parent,
|
|
1831
|
+
)
|
|
1832
|
+
|
|
1833
|
+
if SliderFeature.CONFIG in self._features:
|
|
1834
|
+
self._right_click_items.extend(
|
|
1835
|
+
[
|
|
1836
|
+
{"id": "sep"},
|
|
1837
|
+
{"id": "edit_config", "text": "Edit Configuration..."},
|
|
1838
|
+
]
|
|
1839
|
+
)
|
|
1840
|
+
|
|
1841
|
+
self._slider.setRange(0, len(self._arr) - 1)
|
|
1842
|
+
self._slider.setValue(self._default_index)
|
|
1843
|
+
self.set_format(val_fmt)
|
|
1844
|
+
|
|
1845
|
+
self._slider.valueChanged.connect(self._on_slider_value_changed)
|
|
1846
|
+
|
|
1847
|
+
self._update_all_labels()
|
|
1848
|
+
self._update_value_width()
|
|
1849
|
+
self._update_layout()
|
|
1850
|
+
|
|
1851
|
+
def evaluate_value(self, value: float, eval_fmt: str | None = None) -> float:
|
|
1852
|
+
"""Normalize an array value using the evaluation format.
|
|
1853
|
+
|
|
1854
|
+
Args:
|
|
1855
|
+
value: Value to normalize.
|
|
1856
|
+
eval_fmt: Format to use instead of the current one.
|
|
1857
|
+
|
|
1858
|
+
Returns:
|
|
1859
|
+
Normalized float value.
|
|
1860
|
+
"""
|
|
1861
|
+
fmt = self._eval_fmt if eval_fmt is None else eval_fmt
|
|
1862
|
+
try:
|
|
1863
|
+
return float(fmt.format(value))
|
|
1864
|
+
except Exception:
|
|
1865
|
+
return value
|
|
1866
|
+
|
|
1867
|
+
def _pos_to_value(self, pos: int) -> float:
|
|
1868
|
+
"""Return the array value at a slider position.
|
|
1869
|
+
|
|
1870
|
+
Args:
|
|
1871
|
+
pos: Slider index.
|
|
1872
|
+
|
|
1873
|
+
Returns:
|
|
1874
|
+
Array value at that index.
|
|
1875
|
+
"""
|
|
1876
|
+
return float(self._arr[pos])
|
|
1877
|
+
|
|
1878
|
+
def _get_default_pos(self) -> int:
|
|
1879
|
+
"""Return the default slider index.
|
|
1880
|
+
|
|
1881
|
+
Returns:
|
|
1882
|
+
Default array index.
|
|
1883
|
+
"""
|
|
1884
|
+
return self._default_index
|
|
1885
|
+
|
|
1886
|
+
def _rebuild_arr(self, cur_idx: int, dft_idx: int) -> None:
|
|
1887
|
+
"""Rebuild the numeric array and select indices.
|
|
1888
|
+
|
|
1889
|
+
Args:
|
|
1890
|
+
cur_idx: Current index to select after rebuilding.
|
|
1891
|
+
dft_idx: Default index to store after rebuilding.
|
|
1892
|
+
"""
|
|
1893
|
+
n = self._arr_length
|
|
1894
|
+
self._default_index = dft_idx % n
|
|
1895
|
+
self._arr = np.linspace(self._min_value, self._max_value, n, dtype=float)
|
|
1896
|
+
self._current_value = float(self._arr[cur_idx % n])
|
|
1897
|
+
with self._quiet_updates():
|
|
1898
|
+
self._slider.setRange(0, n - 1)
|
|
1899
|
+
self._slider.setValue(cur_idx % n)
|
|
1900
|
+
|
|
1901
|
+
@staticmethod
|
|
1902
|
+
def _rescale_index(idx: int, old_len: int, new_len: int) -> int:
|
|
1903
|
+
"""Map an index to the same relative position in a resized array."""
|
|
1904
|
+
return round(idx * (new_len - 1) / (old_len - 1))
|
|
1905
|
+
|
|
1906
|
+
def _right_click_requested(self, item_id, checked: bool) -> None:
|
|
1907
|
+
"""Dispatch array-slider context-menu actions.
|
|
1908
|
+
|
|
1909
|
+
Args:
|
|
1910
|
+
item_id: Triggered menu item identifier.
|
|
1911
|
+
checked: Checked state associated with the action.
|
|
1912
|
+
"""
|
|
1913
|
+
super()._right_click_requested(item_id, checked)
|
|
1914
|
+
if item_id == "edit_config":
|
|
1915
|
+
self._open_edit_config_dlg()
|
|
1916
|
+
|
|
1917
|
+
def save_current_value_as_default_value(self) -> None:
|
|
1918
|
+
"""Store the current slider index as the default index."""
|
|
1919
|
+
self._default_index = self._slider.value()
|
|
1920
|
+
if self._default_red_marker is not None:
|
|
1921
|
+
self._default_red_marker.update()
|
|
1922
|
+
self.defaultChanged.emit({"arr_length": self._arr_length, "index": self._default_index})
|
|
1923
|
+
|
|
1924
|
+
def _reset_to_default_value(self) -> None:
|
|
1925
|
+
"""Reset the slider to the stored default index."""
|
|
1926
|
+
self._slider.setValue(self._default_index)
|
|
1927
|
+
|
|
1928
|
+
def _open_edit_config_dlg(self) -> None:
|
|
1929
|
+
"""Open the array configuration dialog and apply accepted changes."""
|
|
1930
|
+
dlg = ArrEditConfigDialog(
|
|
1931
|
+
slider_name=self._label_text,
|
|
1932
|
+
arr_length=self._arr_length,
|
|
1933
|
+
val_fmt=self._val_fmt,
|
|
1934
|
+
parent=self,
|
|
1935
|
+
)
|
|
1936
|
+
if dlg.exec() != QDialog.Accepted:
|
|
1937
|
+
return
|
|
1938
|
+
new_arr_length, new_fmt = dlg.get_values()
|
|
1939
|
+
|
|
1940
|
+
old_fmt = self._val_fmt
|
|
1941
|
+
|
|
1942
|
+
self.set_format(new_fmt)
|
|
1943
|
+
|
|
1944
|
+
if new_arr_length != self._arr_length:
|
|
1945
|
+
old_len = self._arr_length
|
|
1946
|
+
self._arr_length = new_arr_length
|
|
1947
|
+
self._rebuild_arr(
|
|
1948
|
+
self._rescale_index(self._slider.value(), old_len, new_arr_length),
|
|
1949
|
+
self._rescale_index(self._default_index, old_len, new_arr_length),
|
|
1950
|
+
)
|
|
1951
|
+
self.valueChanged.emit(self.get_value())
|
|
1952
|
+
|
|
1953
|
+
self._update_all_labels()
|
|
1954
|
+
self._update_value_width()
|
|
1955
|
+
self._update_layout()
|
|
1956
|
+
|
|
1957
|
+
if old_fmt != self._val_fmt:
|
|
1958
|
+
self.configChanged.emit(self.get_config())
|
|
1959
|
+
|
|
1960
|
+
def set_format(self, val_fmt: str) -> None:
|
|
1961
|
+
"""Set the display format without rounding the array range."""
|
|
1962
|
+
mn, mx = self._min_value, self._max_value
|
|
1963
|
+
super().set_format(val_fmt)
|
|
1964
|
+
self._min_value, self._max_value = mn, mx
|
|
1965
|
+
|
|
1966
|
+
def get_config(self) -> dict:
|
|
1967
|
+
"""Return a serializable configuration snapshot.
|
|
1968
|
+
|
|
1969
|
+
Returns:
|
|
1970
|
+
Mapping containing the current range and format.
|
|
1971
|
+
"""
|
|
1972
|
+
return {
|
|
1973
|
+
"min_val": self._min_value,
|
|
1974
|
+
"max_val": self._max_value,
|
|
1975
|
+
"val_fmt": self._val_fmt
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
def _apply_config(self, config: dict) -> None:
|
|
1979
|
+
"""Apply a validated configuration mapping.
|
|
1980
|
+
|
|
1981
|
+
Args:
|
|
1982
|
+
config: Configuration mapping to apply.
|
|
1983
|
+
"""
|
|
1984
|
+
self.set_format(config["val_fmt"])
|
|
1985
|
+
self.set_range(config["min_val"], config["max_val"])
|
|
1986
|
+
|
|
1987
|
+
def _validate_value(self, value: Dict[str, int]) -> Dict[str, int]:
|
|
1988
|
+
"""Validate and normalize an array-slider state mapping.
|
|
1989
|
+
|
|
1990
|
+
Args:
|
|
1991
|
+
value: Mapping with optional ``arr_length`` and ``index`` entries.
|
|
1992
|
+
|
|
1993
|
+
Returns:
|
|
1994
|
+
Mapping with both entries filled in and the index wrapped.
|
|
1995
|
+
|
|
1996
|
+
Raises:
|
|
1997
|
+
ValueError: If ``arr_length`` is less than 2.
|
|
1998
|
+
"""
|
|
1999
|
+
arr_length = value.get("arr_length", self._arr_length)
|
|
2000
|
+
if arr_length < 2:
|
|
2001
|
+
raise ValueError("ArraySlider: arr_length must be greater than 1.")
|
|
2002
|
+
index = value.get("index", self._default_index) % arr_length
|
|
2003
|
+
return {"arr_length": arr_length, "index": index}
|
|
2004
|
+
|
|
2005
|
+
def _apply_value(self, value: Dict[str, int]) -> None:
|
|
2006
|
+
"""Apply a validated array-slider state.
|
|
2007
|
+
|
|
2008
|
+
Args:
|
|
2009
|
+
value: Mapping returned by ``_validate_value``.
|
|
2010
|
+
"""
|
|
2011
|
+
self._arr_length = value["arr_length"]
|
|
2012
|
+
self._rebuild_arr(value["index"], value["index"])
|
|
2013
|
+
self._update_all_labels()
|
|
2014
|
+
self._update_value_width()
|
|
2015
|
+
|
|
2016
|
+
def get_value(self):
|
|
2017
|
+
"""Return the current array-slider state.
|
|
2018
|
+
|
|
2019
|
+
Returns:
|
|
2020
|
+
Mapping containing the array length and current selected index.
|
|
2021
|
+
"""
|
|
2022
|
+
return {
|
|
2023
|
+
"arr_length": len(self._arr),
|
|
2024
|
+
"index": self._slider.value(),
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
def set_range(self, min_val: TNum, max_val: TNum) -> None:
|
|
2028
|
+
"""Update the numeric range used to generate the backing array.
|
|
2029
|
+
|
|
2030
|
+
Args:
|
|
2031
|
+
min_val: New minimum array value.
|
|
2032
|
+
max_val: New maximum array value.
|
|
2033
|
+
"""
|
|
2034
|
+
if max_val < min_val:
|
|
2035
|
+
raise ValueError("ArraySlider: max_val must be >= min_val.")
|
|
2036
|
+
|
|
2037
|
+
self._min_value = min_val
|
|
2038
|
+
self._max_value = max_val
|
|
2039
|
+
self._rebuild_arr(self._slider.value(), self._default_index)
|
|
2040
|
+
self._update_all_labels()
|
|
2041
|
+
self._update_value_width()
|
|
2042
|
+
|