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.
@@ -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
+