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,1841 @@
1
+ """Numeric range sliders with custom painting, dialogs, and overlays."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numbers
6
+ import random
7
+ from typing import Any, Callable, Dict, Generic, List, Optional, Tuple, TypeVar
8
+
9
+ from PySide6.QtCore import QEvent, QObject, QRect, Qt, Signal
10
+ from PySide6.QtGui import QColor, QFontMetrics, QPainter, QPalette, QPen, QShowEvent, QTextDocument
11
+ from PySide6.QtWidgets import (
12
+ QDialog,
13
+ QDialogButtonBox,
14
+ QFormLayout,
15
+ QHBoxLayout,
16
+ QLabel,
17
+ QSpinBox,
18
+ QStyle,
19
+ QStyleOptionSlider,
20
+ QStylePainter,
21
+ QWidget,
22
+ )
23
+
24
+ from ...dialogs import AskResult, show_warning
25
+ from ...formatting import format_is_valid, get_numeric_format_field
26
+ from ...labels import MathLabel
27
+ from ._defaults import UNIT_LABEL_SIZE, SliderBorderState
28
+ from ._dialogs import range_slider_ask_clamp_value, range_slider_ask_extend_range
29
+ from .base import SliderFeature, SliderWidgetBase
30
+ from .single import EditConfigDialog, make_line_edit
31
+
32
+ TNum = TypeVar("TNum", int, float)
33
+
34
+
35
+ class SetValuesDialog(QDialog, Generic[TNum]):
36
+ """Dialog for editing a lower and upper bound pair."""
37
+
38
+ def __init__(
39
+ self,
40
+ slider_name: str,
41
+ value_is_int: bool,
42
+ current_lower: TNum,
43
+ current_upper: TNum,
44
+ fmt: str,
45
+ min_limit: TNum | None,
46
+ max_limit: TNum | None,
47
+ min_limit_inclusive: bool,
48
+ max_limit_inclusive: bool,
49
+ validate: Callable[[TNum, TNum], str | None],
50
+ parent: Optional[QWidget] = None,
51
+ ):
52
+ """Initialize the value-pair dialog.
53
+
54
+ Args:
55
+ slider_name: Display name of the slider.
56
+ value_is_int: Whether the slider uses integer values.
57
+ current_lower: Current lower bound.
58
+ current_upper: Current upper bound.
59
+ fmt: Current display format string.
60
+ min_limit: Optional lower hard limit.
61
+ max_limit: Optional upper hard limit.
62
+ min_limit_inclusive: Whether the lower hard limit is inclusive.
63
+ max_limit_inclusive: Whether the upper hard limit is inclusive.
64
+ validate: Callable checking (lower, upper); returns an error
65
+ message or None.
66
+ parent: Optional parent widget.
67
+ """
68
+ super().__init__(parent)
69
+ self.setWindowTitle("Set Values")
70
+ self._validate = validate
71
+
72
+ self._edit_lower = make_line_edit(
73
+ value_is_int, current_lower, fmt, min_limit, max_limit, min_limit_inclusive, max_limit_inclusive
74
+ )
75
+ self._edit_upper = make_line_edit(
76
+ value_is_int, current_upper, fmt, min_limit, max_limit, min_limit_inclusive, max_limit_inclusive
77
+ )
78
+
79
+ buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=self)
80
+ buttons.accepted.connect(self.accept)
81
+ buttons.rejected.connect(self.reject)
82
+
83
+ form = QFormLayout(self)
84
+ form.addRow("Range Slider:", MathLabel(slider_name, alignment=Qt.AlignLeft | Qt.AlignVCenter))
85
+ form.addRow("Lower:", self._edit_lower)
86
+ form.addRow("Upper:", self._edit_upper)
87
+ form.addRow(buttons)
88
+
89
+ self.setSizeGripEnabled(False)
90
+ self.adjustSize()
91
+ self.setFixedSize(self.size())
92
+
93
+ def accept(self) -> None:
94
+ """Validate the lower and upper values before accepting."""
95
+ msg = self._validate(self._edit_lower.get_value(), self._edit_upper.get_value())
96
+ if msg:
97
+ show_warning("Invalid values", msg, parent=self)
98
+ return
99
+ super().accept()
100
+
101
+ def get_values(self) -> Tuple[TNum, TNum]:
102
+ """Return the lower and upper values.
103
+
104
+ Returns:
105
+ Tuple containing the lower and upper values.
106
+ """
107
+ return self._edit_lower.get_value(), self._edit_upper.get_value()
108
+
109
+ def showEvent(self, event: QShowEvent) -> None:
110
+ """Focus the lower editor when the dialog is shown.
111
+
112
+ Args:
113
+ event: Qt show event.
114
+ """
115
+ super().showEvent(event)
116
+ self._edit_lower.setFocus()
117
+ self._edit_lower.selectAll()
118
+
119
+
120
+ class SetMarginsDialog(QDialog):
121
+ """Dialog for editing lower and upper margin values."""
122
+
123
+ def __init__(
124
+ self,
125
+ slider_name: str,
126
+ margin_lower: int,
127
+ margin_upper: int,
128
+ parent: Optional[QWidget] = None,
129
+ ):
130
+ """Initialize the margins dialog.
131
+
132
+ Args:
133
+ slider_name: Display name of the slider.
134
+ margin_lower: Current lower margin.
135
+ margin_upper: Current upper margin.
136
+ parent: Optional parent widget.
137
+ """
138
+ super().__init__(parent)
139
+ self.setWindowTitle("Set Margins")
140
+
141
+ self._margin_lo_sb = self._build_spin_box(margin_lower)
142
+ self._margin_up_sb = self._build_spin_box(margin_upper)
143
+
144
+ buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=self)
145
+ buttons.accepted.connect(self.accept)
146
+ buttons.rejected.connect(self.reject)
147
+
148
+ form = QFormLayout(self)
149
+ form.addRow("Range Slider:", MathLabel(slider_name, alignment=Qt.AlignLeft | Qt.AlignVCenter))
150
+ form.addRow("Upper:", self._margin_up_sb)
151
+ form.addRow("Lower:", self._margin_lo_sb)
152
+ form.addRow(buttons)
153
+
154
+ self.setSizeGripEnabled(False)
155
+ self.adjustSize()
156
+ self.setFixedSize(self.size())
157
+
158
+ def _build_spin_box(self, value: int) -> QSpinBox:
159
+ """Create a spin box configured for margin editing.
160
+
161
+ Args:
162
+ value: Initial spin-box value.
163
+
164
+ Returns:
165
+ Configured spin box.
166
+ """
167
+ sb = QSpinBox(self)
168
+ sb.setSuffix(" %")
169
+ sb.setMinimum(0)
170
+ sb.setMaximum(100)
171
+ sb.setValue(value)
172
+ sb.setFixedWidth(sb.sizeHint().width())
173
+ return sb
174
+
175
+ def get_values(self) -> Tuple[int, int]:
176
+ """Return the margin values.
177
+
178
+ Returns:
179
+ Tuple containing lower and upper margins.
180
+ """
181
+ return self._margin_lo_sb.value(), self._margin_up_sb.value()
182
+
183
+ def showEvent(self, event: QShowEvent) -> None:
184
+ """Focus the lower-margin editor when the dialog is shown.
185
+
186
+ Args:
187
+ event: Qt show event.
188
+ """
189
+ super().showEvent(event)
190
+ self._margin_lo_sb.setFocus()
191
+ self._margin_lo_sb.selectAll()
192
+
193
+
194
+ class DualHandleSlider(QWidget):
195
+ """Custom slider widget with independently draggable lower and upper handles."""
196
+
197
+ valueChanged = Signal(tuple)
198
+
199
+ def __init__(
200
+ self,
201
+ minimum=0.0,
202
+ maximum=100.0,
203
+ values=(20.0, 80.0),
204
+ orientation=Qt.Horizontal,
205
+ parent=None,
206
+ ):
207
+ """Initialize the dual-handle slider.
208
+
209
+ Args:
210
+ minimum: Minimum allowed value.
211
+ maximum: Maximum allowed value.
212
+ values: Initial lower and upper values.
213
+ orientation: Slider orientation.
214
+ parent: Optional parent widget.
215
+
216
+ Raises:
217
+ ValueError: If ``maximum <= minimum``.
218
+ """
219
+ super().__init__(parent)
220
+
221
+ self._min = float(minimum)
222
+ self._max = float(maximum)
223
+ self._lower = float(values[0])
224
+ self._upper = float(values[1])
225
+ self._orientation = orientation
226
+
227
+ self._active_handle = None
228
+ self._margin = 7
229
+ self._show_ticks = False
230
+ self._tick_count = 2
231
+
232
+ self.setMouseTracking(True)
233
+
234
+ if self._orientation == Qt.Horizontal:
235
+ self.setMinimumSize(80, 30)
236
+ else:
237
+ self.setMinimumSize(30, 80)
238
+
239
+ self._validate()
240
+
241
+ def _is_horizontal(self):
242
+ """Return whether the slider orientation is horizontal."""
243
+ return self._orientation == Qt.Horizontal
244
+
245
+ def _pixel_min(self):
246
+ """Return the minimum usable pixel coordinate along the slider."""
247
+ return self._margin
248
+
249
+ def _pixel_max(self):
250
+ """Return the maximum usable pixel coordinate along the slider."""
251
+ if self._is_horizontal():
252
+ return self.width() - self._margin
253
+ return self.height() - self._margin
254
+
255
+ def _pixel_span(self):
256
+ """Return the usable pixel span of the slider."""
257
+ return max(1, self._pixel_max() - self._pixel_min())
258
+
259
+ def _value_to_pixel(self, value):
260
+ """Convert a logical value to a coordinate along the slider axis.
261
+
262
+ Args:
263
+ value: Logical slider value.
264
+
265
+ Returns:
266
+ Axis coordinate (measured from the minimum end).
267
+ """
268
+ value = max(self._min, min(value, self._max))
269
+ frac = (value - self._min) / (self._max - self._min)
270
+ return int(round(self._pixel_min() + frac * self._pixel_span()))
271
+
272
+ def _pixel_to_value(self, pixel):
273
+ """Convert an axis coordinate to a logical slider value.
274
+
275
+ Args:
276
+ pixel: Coordinate along the slider axis.
277
+
278
+ Returns:
279
+ Logical slider value.
280
+ """
281
+ pixel = max(self._pixel_min(), min(pixel, self._pixel_max()))
282
+ frac = (pixel - self._pixel_min()) / self._pixel_span()
283
+ return self._min + frac * (self._max - self._min)
284
+
285
+ def _style_option(self, value: float) -> QStyleOptionSlider:
286
+ """Build a style option describing a handle at a given value.
287
+
288
+ Args:
289
+ value: Logical slider value.
290
+
291
+ Returns:
292
+ Configured style option.
293
+ """
294
+ opt = QStyleOptionSlider()
295
+ opt.initFrom(self)
296
+ opt.orientation = self._orientation
297
+ opt.minimum = 0
298
+ opt.maximum = 10000
299
+
300
+ pos = self._value_to_style_pos(value)
301
+ opt.sliderPosition = pos
302
+ opt.sliderValue = pos
303
+ opt.upsideDown = self._orientation == Qt.Vertical
304
+ opt.subControls = QStyle.SC_SliderGroove | QStyle.SC_SliderHandle
305
+ return opt
306
+
307
+ def _handle_rect(self, value: float) -> QRect:
308
+ """Return the handle rectangle for a given value.
309
+
310
+ Args:
311
+ value: Logical slider value.
312
+
313
+ Returns:
314
+ Handle rectangle in widget coordinates.
315
+ """
316
+ opt = self._style_option(value)
317
+ return self.style().subControlRect(QStyle.CC_Slider, opt, QStyle.SC_SliderHandle, self)
318
+
319
+ def _value_to_style_pos(self, value: float) -> int:
320
+ """Convert a logical value to the style system's slider position.
321
+
322
+ Args:
323
+ value: Logical slider value.
324
+
325
+ Returns:
326
+ Style slider position in the range [0, 10000].
327
+ """
328
+ frac = (value - self._min) / (self._max - self._min)
329
+ return int(round(frac * 10000))
330
+
331
+ def _hit_test(self, pos):
332
+ """Return which handle contains the given point.
333
+
334
+ Args:
335
+ pos: Mouse position in widget coordinates.
336
+
337
+ Returns:
338
+ ``"lower"``, ``"upper"``, or ``None``.
339
+ """
340
+ lower_rect = self._handle_rect(self._lower)
341
+ upper_rect = self._handle_rect(self._upper)
342
+
343
+ in_lower = lower_rect.contains(pos)
344
+ in_upper = upper_rect.contains(pos)
345
+
346
+ if in_lower and in_upper:
347
+ if self._is_horizontal():
348
+ dl = abs(pos.x() - lower_rect.center().x())
349
+ du = abs(pos.x() - upper_rect.center().x())
350
+ else:
351
+ dl = abs(pos.y() - lower_rect.center().y())
352
+ du = abs(pos.y() - upper_rect.center().y())
353
+ return "lower" if dl <= du else "upper"
354
+
355
+ if in_lower:
356
+ return "lower"
357
+ if in_upper:
358
+ return "upper"
359
+ return None
360
+
361
+ def _pos_along_slider(self, pos):
362
+ """Return the position projected onto the slider axis.
363
+
364
+ Args:
365
+ pos: Mouse position in widget coordinates.
366
+
367
+ Returns:
368
+ Coordinate along the logical slider axis.
369
+ """
370
+ if self._is_horizontal():
371
+ return pos.x()
372
+ return self._pixel_min() + self._pixel_max() - pos.y()
373
+
374
+ def _closest_handle(self, pos):
375
+ """Return the handle closest to a point.
376
+
377
+ Args:
378
+ pos: Mouse position in widget coordinates.
379
+
380
+ Returns:
381
+ ``"lower"`` or ``"upper"``.
382
+ """
383
+ lower_c = self._handle_rect(self._lower).center()
384
+ upper_c = self._handle_rect(self._upper).center()
385
+ if self._is_horizontal():
386
+ dl = abs(pos.x() - lower_c.x())
387
+ du = abs(pos.x() - upper_c.x())
388
+ else:
389
+ dl = abs(pos.y() - lower_c.y())
390
+ du = abs(pos.y() - upper_c.y())
391
+ return "lower" if dl <= du else "upper"
392
+
393
+ def _clamp_pixel(self, pixel: int):
394
+ """Move the active handle to a valid axis coordinate.
395
+
396
+ Args:
397
+ pixel: Proposed coordinate along the slider axis.
398
+ """
399
+ if self._active_handle == "lower":
400
+ p_up = self._value_to_pixel(self._upper)
401
+ new_p = max(self._pixel_min(), min(pixel, p_up - 1))
402
+ self.setValues((self._pixel_to_value(new_p), self._upper))
403
+ else:
404
+ p_lo = self._value_to_pixel(self._lower)
405
+ new_p = min(self._pixel_max(), max(pixel, p_lo + 1))
406
+ self.setValues((self._lower, self._pixel_to_value(new_p)))
407
+
408
+ def _draw_ticks(self, painter, groove_rect):
409
+ """Draw tick marks along the slider groove.
410
+
411
+ Args:
412
+ painter: Painter used for drawing.
413
+ groove_rect: Groove rectangle in widget coordinates.
414
+ """
415
+ if not self._show_ticks:
416
+ return
417
+ painter.save()
418
+ pen = painter.pen()
419
+ pen.setColor(self.palette().color(QPalette.Disabled, QPalette.WindowText))
420
+ painter.setPen(pen)
421
+
422
+ n = max(2, self._tick_count)
423
+
424
+ if self._is_horizontal():
425
+ y1 = groove_rect.bottom() + 6
426
+ y2 = y1 + 3
427
+ for i in range(n):
428
+ frac = i / (n - 1)
429
+ x = int(round(self._pixel_min() + frac * self._pixel_span()))
430
+ painter.drawLine(x, y1, x, y2)
431
+ else:
432
+ x1 = groove_rect.right() + 6
433
+ x2 = x1 + 3
434
+ for i in range(n):
435
+ frac = i / (n - 1)
436
+ y = int(round(self._pixel_max() - frac * self._pixel_span()))
437
+ painter.drawLine(x1, y, x2, y)
438
+ painter.restore()
439
+
440
+ def _validate(self):
441
+ """Validate the configured slider range.
442
+
443
+ Raises:
444
+ ValueError: If ``maximum <= minimum``.
445
+ """
446
+ if self._max <= self._min:
447
+ raise ValueError("maximum must be greater than minimum")
448
+
449
+ def values(self):
450
+ """Return the current lower and upper values."""
451
+ return self._lower, self._upper
452
+
453
+ def setValues(self, values):
454
+ """Set the current lower and upper values.
455
+
456
+ Args:
457
+ values: Tuple containing lower and upper values.
458
+ """
459
+ new_vals = (float(values[0]), float(values[1]))
460
+ old_vals = (self._lower, self._upper)
461
+ self._lower, self._upper = new_vals
462
+ self.update()
463
+ if new_vals != old_vals:
464
+ self.valueChanged.emit(new_vals)
465
+
466
+ def setRange(self, minimum, maximum):
467
+ """Set the allowed slider range.
468
+
469
+ Args:
470
+ minimum: New minimum value.
471
+ maximum: New maximum value.
472
+
473
+ Raises:
474
+ ValueError: If ``maximum <= minimum``.
475
+ """
476
+ minimum, maximum = float(minimum), float(maximum)
477
+ if maximum <= minimum:
478
+ raise ValueError("maximum must be greater than minimum")
479
+ self._min, self._max = minimum, maximum
480
+ self.setValues((self._lower, self._upper))
481
+
482
+ def orientation(self):
483
+ """Return the slider orientation."""
484
+ return self._orientation
485
+
486
+ def setShowTicks(self, state: bool):
487
+ """Enable or disable tick rendering.
488
+
489
+ Args:
490
+ state: Whether ticks should be drawn.
491
+ """
492
+ self._show_ticks = bool(state)
493
+ self.update()
494
+
495
+ def setTickCount(self, count: int):
496
+ """Set the number of ticks drawn on the slider.
497
+
498
+ Args:
499
+ count: Desired tick count.
500
+ """
501
+ self._tick_count = max(2, int(count))
502
+ self.update()
503
+
504
+ def mousePressEvent(self, event):
505
+ """Pick a handle and move it to the click position.
506
+
507
+ Args:
508
+ event: Qt mouse event.
509
+ """
510
+ if event.button() != Qt.LeftButton:
511
+ return super().mousePressEvent(event)
512
+
513
+ pos = event.position().toPoint()
514
+ handle = self._hit_test(pos)
515
+ if handle is None:
516
+ handle = self._closest_handle(pos)
517
+
518
+ self._active_handle = handle
519
+ self._clamp_pixel(self._pos_along_slider(pos))
520
+ event.accept()
521
+
522
+ def mouseMoveEvent(self, event):
523
+ """Drag the active handle.
524
+
525
+ Args:
526
+ event: Qt mouse event.
527
+ """
528
+ if self._active_handle is None:
529
+ return super().mouseMoveEvent(event)
530
+
531
+ self._clamp_pixel(self._pos_along_slider(event.position().toPoint()))
532
+ event.accept()
533
+
534
+ def mouseReleaseEvent(self, event):
535
+ """Release the active handle.
536
+
537
+ Args:
538
+ event: Qt mouse event.
539
+ """
540
+ self._active_handle = None
541
+ event.accept()
542
+
543
+ def paintEvent(self, event):
544
+ """Paint the groove, active span, ticks, and both handles.
545
+
546
+ Args:
547
+ event: Qt paint event.
548
+ """
549
+ painter = QStylePainter(self)
550
+ style = self.style()
551
+
552
+ lower_opt = self._style_option(self._lower)
553
+ upper_opt = self._style_option(self._upper)
554
+
555
+ groove_rect = style.subControlRect(QStyle.CC_Slider, lower_opt, QStyle.SC_SliderGroove, self)
556
+ lower_handle_rect = style.subControlRect(QStyle.CC_Slider, lower_opt, QStyle.SC_SliderHandle, self)
557
+ upper_handle_rect = style.subControlRect(QStyle.CC_Slider, upper_opt, QStyle.SC_SliderHandle, self)
558
+
559
+ base_opt = self._style_option(self._min)
560
+ base_opt.subControls = QStyle.SC_SliderGroove
561
+
562
+ painter.save()
563
+ painter.setClipRect(groove_rect.adjusted(-1, -1, 1, 1))
564
+ painter.drawComplexControl(QStyle.CC_Slider, base_opt)
565
+ painter.restore()
566
+
567
+ if self.isEnabled():
568
+ active_opt = self._style_option(self._upper)
569
+ active_opt.subControls = QStyle.SC_SliderGroove
570
+ if self._is_horizontal():
571
+ left = lower_handle_rect.center().x()
572
+ right = upper_handle_rect.center().x()
573
+ if right > left:
574
+ painter.save()
575
+ painter.setClipRect(QRect(left, groove_rect.top() - 2, right - left + 1, groove_rect.height() + 4))
576
+ painter.drawComplexControl(QStyle.CC_Slider, active_opt)
577
+ painter.restore()
578
+ else:
579
+ top = upper_handle_rect.center().y()
580
+ bottom = lower_handle_rect.center().y()
581
+ if bottom > top:
582
+ painter.save()
583
+ painter.setClipRect(QRect(groove_rect.left() - 2, top, groove_rect.width() + 4, bottom - top + 1))
584
+ painter.drawComplexControl(QStyle.CC_Slider, active_opt)
585
+ painter.restore()
586
+
587
+ self._draw_ticks(painter, groove_rect)
588
+
589
+ for handle, value in (("lower", self._lower), ("upper", self._upper)):
590
+ opt = self._style_option(value)
591
+ opt.subControls = QStyle.SC_SliderHandle
592
+ if self._active_handle == handle and self.isEnabled():
593
+ opt.activeSubControls = QStyle.SC_SliderHandle
594
+ opt.state |= QStyle.State_Sunken
595
+ painter.drawComplexControl(QStyle.CC_Slider, opt)
596
+
597
+
598
+ class RangeMarkerOverlay(QWidget):
599
+ """Transparent overlay that draws two default markers for a range slider."""
600
+
601
+ def __init__(
602
+ self,
603
+ slider: "DualHandleSlider",
604
+ lower_value_fn,
605
+ upper_value_fn,
606
+ *,
607
+ thickness: int = 2,
608
+ color: QColor | str = Qt.red,
609
+ ):
610
+ """Initialize the range-marker overlay.
611
+
612
+ Args:
613
+ slider: Target dual-handle slider.
614
+ lower_value_fn: Callable returning the lower marker value.
615
+ upper_value_fn: Callable returning the upper marker value.
616
+ thickness: Marker line thickness in pixels.
617
+ color: Marker color.
618
+ """
619
+ super().__init__(slider)
620
+ self._slider = slider
621
+ self._lower_value_fn = lower_value_fn
622
+ self._upper_value_fn = upper_value_fn
623
+ self._thickness = thickness
624
+ self._color = QColor(color)
625
+
626
+ self.setAttribute(Qt.WA_TransparentForMouseEvents, True)
627
+ self.setAttribute(Qt.WA_NoSystemBackground, True)
628
+ self.setAttribute(Qt.WA_TranslucentBackground, True)
629
+
630
+ slider.installEventFilter(self)
631
+ self._sync_geometry()
632
+ self.show()
633
+
634
+ def _current_color(self):
635
+ """Return the effective marker color for the current enabled state."""
636
+ c = QColor(self._color)
637
+ if not self._slider.isEnabled():
638
+ ref = self.palette().color(QPalette.Disabled, QPalette.WindowText)
639
+ c.setAlphaF(ref.alphaF())
640
+ return c
641
+
642
+ def eventFilter(self, obj: QObject, ev: QEvent) -> bool:
643
+ """Synchronize overlay state when the target slider changes.
644
+
645
+ Args:
646
+ obj: Watched object.
647
+ ev: Event delivered to the watched object.
648
+
649
+ Returns:
650
+ Result of the base-class event filter.
651
+ """
652
+ if obj is self._slider and ev.type() in (
653
+ QEvent.Resize,
654
+ QEvent.Show,
655
+ QEvent.Hide,
656
+ QEvent.EnabledChange,
657
+ ):
658
+ self._sync_geometry()
659
+ self.update()
660
+ return super().eventFilter(obj, ev)
661
+
662
+ def _sync_geometry(self) -> None:
663
+ """Synchronize geometry and visibility with the target slider."""
664
+ self.setGeometry(self._slider.rect())
665
+ self.setVisible(self._slider.isVisible())
666
+
667
+ def _draw_one_marker(self, painter: QPainter, value: float) -> None:
668
+ """Draw a single marker line.
669
+
670
+ Args:
671
+ painter: Painter used for drawing.
672
+ value: Logical slider value at which to draw the marker.
673
+ """
674
+ groove_rect = self._slider.style().subControlRect(
675
+ QStyle.CC_Slider,
676
+ self._slider._style_option(value),
677
+ QStyle.SC_SliderGroove,
678
+ self._slider,
679
+ )
680
+ handle_rect = self._slider._handle_rect(value)
681
+ if groove_rect.isNull() or handle_rect.isNull():
682
+ return
683
+
684
+ c = handle_rect.center()
685
+ if self._slider.orientation() == Qt.Horizontal:
686
+ painter.drawLine(c.x(), groove_rect.top(), c.x(), groove_rect.bottom())
687
+ else:
688
+ painter.drawLine(groove_rect.left(), c.y(), groove_rect.right(), c.y())
689
+
690
+ def paintEvent(self, _ev) -> None:
691
+ """Paint both default markers.
692
+
693
+ Args:
694
+ _ev: Qt paint event.
695
+ """
696
+ p = QPainter(self)
697
+ p.setRenderHint(QPainter.Antialiasing, False)
698
+ pen = QPen(self._current_color())
699
+ pen.setWidth(self._thickness)
700
+ p.setPen(pen)
701
+ self._draw_one_marker(p, float(self._lower_value_fn()))
702
+ self._draw_one_marker(p, float(self._upper_value_fn()))
703
+ p.end()
704
+
705
+
706
+ class LabeledRangeSliderBase(SliderWidgetBase[Any], Generic[TNum]):
707
+ """Base widget for labeled range sliders built on ``DualHandleSlider``.
708
+
709
+ Frame, menu, layout, widths and events come from ``SliderWidgetBase``; this
710
+ class adds the ``[lower, upper]`` display and the range-specific hooks.
711
+
712
+ Signals ``valueChanged``, ``defaultChanged`` and ``configChanged`` are
713
+ emitted only for user actions (dragging, context menu, dialogs).
714
+ Programmatic setters never emit them.
715
+ """
716
+
717
+ _RANGE_MARGIN_H = 3
718
+ _RANGE_MARGIN_V = 3
719
+ value_is_int: bool = False
720
+
721
+ def __init__(
722
+ self,
723
+ label: str,
724
+ unit: str,
725
+ min_val: TNum,
726
+ max_val: TNum,
727
+ init_vals: Optional[Tuple[TNum, TNum]],
728
+ orientation: Qt.Orientation,
729
+ val_fmt: str,
730
+ show_range: bool,
731
+ features: SliderFeature,
732
+ parent: Optional[QWidget],
733
+ ):
734
+ """Initialize the labeled range-slider base widget.
735
+
736
+ Args:
737
+ label: Slider label text.
738
+ unit: Optional unit label.
739
+ min_val: Initial minimum value.
740
+ max_val: Initial maximum value.
741
+ init_vals: Initial lower and upper values, or ``None`` for 25 % /
742
+ 75 % of the range.
743
+ orientation: Slider orientation.
744
+ val_fmt: Value display format string.
745
+ show_range: Initial visibility of range labels and ticks; change
746
+ later with ``set_show_range``.
747
+ features: Context-menu features offered by the slider.
748
+ parent: Optional parent widget.
749
+ """
750
+ super().__init__(
751
+ label=label,
752
+ orientation=orientation,
753
+ val_fmt=val_fmt,
754
+ show_range=show_range,
755
+ features=features,
756
+ parent=parent,
757
+ )
758
+
759
+ self._unit = unit
760
+ self._min_value = min_val
761
+ self._max_value = max_val
762
+
763
+ if init_vals is None:
764
+ span = max_val - min_val
765
+ lo = self.evaluate_value(min_val + 0.25 * span)
766
+ hi = self.evaluate_value(min_val + 0.75 * span)
767
+ else:
768
+ lo = self.evaluate_value(init_vals[0])
769
+ hi = self.evaluate_value(init_vals[1])
770
+
771
+ self._current_lower = lo
772
+ self._current_upper = hi
773
+ self._default_lower = lo
774
+ self._default_upper = hi
775
+
776
+ self._value_wrap = QWidget(self)
777
+ self._value_l_label = self._make_value_label("[")
778
+ self._value_lo_label = self._make_value_label("")
779
+ self._value_c_label = self._make_value_label(", ")
780
+ self._value_hi_label = self._make_value_label("")
781
+ self._value_r_label = self._make_value_label("]")
782
+ hl = QHBoxLayout(self._value_wrap)
783
+ hl.setContentsMargins(0, 0, 0, 0)
784
+ hl.setSpacing(0)
785
+ for w in (self._value_l_label, self._value_lo_label, self._value_c_label,
786
+ self._value_hi_label, self._value_r_label):
787
+ hl.addWidget(w)
788
+ if unit:
789
+ self._unit_label = MathLabel(f"({unit})", parent=self, fix_size=True, font_size=UNIT_LABEL_SIZE)
790
+ hl.addWidget(self._unit_label)
791
+ hl.addStretch(1)
792
+
793
+ self._attach_slider(
794
+ DualHandleSlider(
795
+ minimum=float(min_val),
796
+ maximum=float(max_val),
797
+ values=(float(lo), float(hi)),
798
+ orientation=orientation,
799
+ parent=self,
800
+ )
801
+ )
802
+ self.set_format(val_fmt)
803
+ self._make_layout()
804
+
805
+ @staticmethod
806
+ def _make_value_label(text: str) -> QLabel:
807
+ """Create a QLabel used in the formatted range display.
808
+
809
+ Args:
810
+ text: Initial label text.
811
+
812
+ Returns:
813
+ Configured value label.
814
+ """
815
+ label = QLabel(text)
816
+ label.setObjectName("slider_value")
817
+ return label
818
+
819
+ # ----- SliderWidgetBase hooks -----
820
+ def _make_default_marker(self) -> QWidget:
821
+ """Create the two default-value markers."""
822
+ return RangeMarkerOverlay(
823
+ self._slider,
824
+ lower_value_fn=lambda: float(self._default_lower),
825
+ upper_value_fn=lambda: float(self._default_upper),
826
+ thickness=2,
827
+ )
828
+
829
+ def _value_widget(self) -> QWidget:
830
+ """Return the ``[lower, upper]`` display with its unit."""
831
+ return self._value_wrap
832
+
833
+ def _align_value_labels(self, horizontal: bool) -> None:
834
+ """Align the five value labels for the orientation.
835
+
836
+ Args:
837
+ horizontal: Whether the layout is horizontal.
838
+ """
839
+ if horizontal:
840
+ fixed = Qt.AlignVCenter
841
+ value = Qt.AlignVCenter | Qt.AlignLeft
842
+ else:
843
+ fixed = Qt.AlignHCenter | Qt.AlignVCenter
844
+ value = Qt.AlignLeft | Qt.AlignVCenter
845
+ for label in (self._value_l_label, self._value_c_label, self._value_r_label):
846
+ label.setAlignment(fixed)
847
+ for label in (self._value_lo_label, self._value_hi_label):
848
+ label.setAlignment(value)
849
+
850
+ def _value_text_labels(self) -> List[QLabel]:
851
+ """Return the labels whose text format follows the value format."""
852
+ return [self._value_lo_label, self._value_hi_label, self._min_label, self._max_label]
853
+
854
+ def _round_values_to_format(self) -> None:
855
+ """Round current, default, min and max to the current format."""
856
+ self._current_lower = self.evaluate_value(self._current_lower)
857
+ self._current_upper = self.evaluate_value(self._current_upper)
858
+ self._default_lower = self.evaluate_value(self._default_lower)
859
+ self._default_upper = self.evaluate_value(self._default_upper)
860
+ self._min_value = self.evaluate_value(self._min_value)
861
+ self._max_value = self.evaluate_value(self._max_value)
862
+
863
+ def _apply_range_visibility(self, show: bool) -> None:
864
+ """Show or hide the slider ticks.
865
+
866
+ Args:
867
+ show: Whether range ticks should be shown.
868
+ """
869
+ self._slider.setShowTicks(show)
870
+
871
+ def _update_value_width(self) -> None:
872
+ """Measure the width required by the ``[lower, upper]`` display."""
873
+ l = self._value_lo_label
874
+ l.ensurePolished()
875
+ values = self._iter_width_values(max_samples=200)
876
+
877
+ value_w = 0
878
+ if l.textFormat() == Qt.PlainText:
879
+ fm = QFontMetrics(l.font())
880
+ for v in values:
881
+ value_w = max(value_w, fm.horizontalAdvance(self._value_to_text(v)))
882
+ else:
883
+ doc = QTextDocument()
884
+ doc.setDefaultFont(l.font())
885
+ doc.setTextWidth(-1)
886
+ if self._orient == Qt.Horizontal:
887
+ doc.setDocumentMargin(0)
888
+ for v in values:
889
+ doc.setHtml(self._value_to_text(v))
890
+ value_w = max(value_w, int(doc.idealWidth()))
891
+
892
+ self._value_lo_label.setFixedWidth(value_w)
893
+ self._value_hi_label.setFixedWidth(value_w)
894
+ self._set_measured_value_width(self._value_wrap.sizeHint().width())
895
+
896
+ # ----- value handling -----
897
+ def evaluate_value(self, value: TNum, eval_fmt: str | None = None) -> TNum:
898
+ """Normalize a value according to the evaluation format.
899
+
900
+ Args:
901
+ value: Value to normalize.
902
+ eval_fmt: Format to use instead of the current one.
903
+
904
+ Returns:
905
+ Normalized typed value.
906
+ """
907
+ raise NotImplementedError
908
+
909
+ def _range_to_text(self, values: Tuple[TNum, TNum]) -> str:
910
+ """Format a lower/upper pair for display.
911
+
912
+ Args:
913
+ values: Pair of lower and upper values.
914
+
915
+ Returns:
916
+ Formatted range string.
917
+ """
918
+ lo, hi = values
919
+ return f"[{self._value_to_text(lo)} , {self._value_to_text(hi)}]"
920
+
921
+ def _update_value_label(self) -> None:
922
+ """Refresh the lower and upper value labels."""
923
+ self._value_lo_label.setText(self._value_to_text(self._current_lower))
924
+ self._value_hi_label.setText(self._value_to_text(self._current_upper))
925
+
926
+ def _update_all_labels(self) -> None:
927
+ """Refresh value and range labels."""
928
+ self._update_value_label()
929
+ self._min_label.setText(self._value_to_text(self._min_value))
930
+ self._max_label.setText(self._value_to_text(self._max_value))
931
+
932
+ def _update_marker(self) -> None:
933
+ """Repaint the default markers, if any."""
934
+ if self._default_red_marker is not None:
935
+ self._default_red_marker.update()
936
+
937
+ def _iter_width_values(self, max_samples: int = 200) -> list[TNum]:
938
+ """Generate representative values for width measurement.
939
+
940
+ Args:
941
+ max_samples: Maximum number of samples to generate.
942
+
943
+ Returns:
944
+ List of representative values.
945
+ """
946
+ a = self.evaluate_value(self._min_value)
947
+ b = self.evaluate_value(self._max_value)
948
+
949
+ if self.value_is_int:
950
+ mn, mx = int(a), int(b)
951
+ span = mx - mn
952
+ if span <= max_samples:
953
+ return [self.evaluate_value(v) for v in range(mn, mx + 1)]
954
+
955
+ vals = {
956
+ mn,
957
+ mx,
958
+ int(self._current_lower),
959
+ int(self._current_upper),
960
+ int(self._default_lower),
961
+ int(self._default_upper),
962
+ }
963
+ n_lin = min(max_samples - len(vals), 100)
964
+ for k in range(1, n_lin + 1):
965
+ vals.add(mn + (span * k) // (n_lin + 1))
966
+
967
+ rng = random.Random(0)
968
+ tries = 0
969
+ while len(vals) < max_samples and tries < 10 * max_samples:
970
+ vals.add(rng.randint(mn, mx))
971
+ tries += 1
972
+ return [self.evaluate_value(v) for v in sorted(vals)]
973
+
974
+ vals = []
975
+ seen = set()
976
+
977
+ def add(v):
978
+ """Add a value if its formatted representation is new."""
979
+ v = self.evaluate_value(v)
980
+ key = self._value_to_text(v)
981
+ if key not in seen:
982
+ seen.add(key)
983
+ vals.append(v)
984
+
985
+ for v in (a, b, self._current_lower, self._current_upper, self._default_lower, self._default_upper):
986
+ add(v)
987
+
988
+ n_lin = min(max_samples, 100)
989
+ for k in range(n_lin + 1):
990
+ add(a + k / n_lin * (b - a))
991
+
992
+ rng = random.Random(0)
993
+ tries = 0
994
+ while len(vals) < max_samples and tries < 20 * max_samples:
995
+ add(rng.uniform(a, b))
996
+ tries += 1
997
+ return vals
998
+
999
+ def _on_slider_values_changed(self, vals: Tuple[float, float]) -> None:
1000
+ """Handle lower/upper changes from the dual-handle slider.
1001
+
1002
+ Args:
1003
+ vals: New lower and upper values from the internal slider.
1004
+ """
1005
+ lo = self.evaluate_value(vals[0])
1006
+ hi = self.evaluate_value(vals[1])
1007
+
1008
+ if hi <= lo:
1009
+ self._slider.setValues((self._current_lower, self._current_upper))
1010
+ elif lo != self._current_lower or hi != self._current_upper:
1011
+ self._current_lower = lo
1012
+ self._current_upper = hi
1013
+ self._update_value_label()
1014
+ if not self._quiet:
1015
+ self.valueChanged.emit(self.get_value())
1016
+
1017
+ def save_current_value_as_default_value(self) -> None:
1018
+ """Store the current lower and upper values as defaults (user action)."""
1019
+ self._default_lower = self._current_lower
1020
+ self._default_upper = self._current_upper
1021
+ with self._quiet_updates():
1022
+ self._reset_to_default_value()
1023
+ self._update_marker()
1024
+ self.defaultChanged.emit((self._default_lower, self._default_upper))
1025
+
1026
+ def _reset_to_default_value(self) -> None:
1027
+ """Move the handles to the default lower and upper values."""
1028
+ self._slider.setValues((float(self._default_lower), float(self._default_upper)))
1029
+
1030
+ def get_value(self) -> Any:
1031
+ """Return the current lower and upper values.
1032
+
1033
+ Returns:
1034
+ Tuple containing lower and upper values.
1035
+ """
1036
+ return self._current_lower, self._current_upper
1037
+
1038
+
1039
+ class NumericRangeSliderBase(LabeledRangeSliderBase[TNum], Generic[TNum]):
1040
+ """Base class for integer and float range sliders.
1041
+
1042
+ The slider works in one of two modes: *values* (the handles define the
1043
+ range) or *margins* (lower/upper percentages are used instead and the
1044
+ handles are disabled). The mode and margins are part of the value returned
1045
+ by ``get_value``.
1046
+ """
1047
+
1048
+ def __init__(
1049
+ self,
1050
+ label: str,
1051
+ unit: str,
1052
+ value_is_int: bool,
1053
+ min_val: TNum,
1054
+ max_val: TNum,
1055
+ init_vals: Optional[Tuple[TNum, TNum]],
1056
+ val_fmt: str,
1057
+ min_limit: TNum | None,
1058
+ max_limit: TNum | None,
1059
+ min_limit_inclusive: bool,
1060
+ max_limit_inclusive: bool,
1061
+ use_margins: bool,
1062
+ margins: Tuple[int, int],
1063
+ show_range: bool,
1064
+ orientation: Qt.Orientation,
1065
+ features: SliderFeature,
1066
+ parent: Optional[QWidget] = None,
1067
+ ):
1068
+ """Initialize the numeric range-slider base class.
1069
+
1070
+ Args:
1071
+ label: Slider label text.
1072
+ unit: Optional unit label.
1073
+ value_is_int: Whether the slider uses integer values.
1074
+ min_val: Initial minimum value.
1075
+ max_val: Initial maximum value.
1076
+ init_vals: Initial lower and upper values.
1077
+ val_fmt: Value display format string.
1078
+ min_limit: Optional lower hard limit.
1079
+ max_limit: Optional upper hard limit.
1080
+ min_limit_inclusive: Whether the lower hard limit is inclusive.
1081
+ max_limit_inclusive: Whether the upper hard limit is inclusive.
1082
+ use_margins: Whether the slider starts in margins mode.
1083
+ margins: Lower and upper margins in percent (0-100).
1084
+ show_range: Initial visibility of range labels and ticks; change
1085
+ later with ``set_show_range``.
1086
+ orientation: Slider orientation.
1087
+ features: Context-menu features offered by the slider.
1088
+ parent: Optional parent widget.
1089
+
1090
+ Raises:
1091
+ ValueError: If the range violates the limits, ``init_vals`` is
1092
+ outside the range or limits or its upper value is not greater
1093
+ than its lower value, a margin is outside 0-100, or
1094
+ ``use_margins`` is set without ``SliderFeature.MARGINS``.
1095
+ TypeError: If ``init_vals`` contains non-numeric values.
1096
+ """
1097
+ if use_margins and SliderFeature.MARGINS not in features:
1098
+ raise ValueError("use_margins=True requires SliderFeature.MARGINS in features")
1099
+
1100
+ super().__init__(
1101
+ label=label,
1102
+ unit=unit,
1103
+ min_val=min_val,
1104
+ max_val=max_val,
1105
+ init_vals=init_vals,
1106
+ orientation=orientation,
1107
+ val_fmt=val_fmt,
1108
+ show_range=show_range,
1109
+ features=features,
1110
+ parent=parent,
1111
+ )
1112
+
1113
+ self.value_is_int = value_is_int
1114
+ self._min_limit = min_limit
1115
+ self._max_limit = max_limit
1116
+ self._min_limit_inclusive = min_limit_inclusive
1117
+ self._max_limit_inclusive = max_limit_inclusive
1118
+ self._use_margins = bool(use_margins)
1119
+ self._margin_lower, self._margin_upper = self._validate_margins(margins)
1120
+
1121
+ self._validate_limit_one("min_val", self._min_value)
1122
+ self._validate_limit_one("max_val", self._max_value)
1123
+ lo, hi = self._validate_pair(self.get_current_value())
1124
+ if not (self._min_value <= lo and hi <= self._max_value):
1125
+ raise ValueError(
1126
+ f"init_vals ({lo}, {hi}) must lie within [{self._min_value}, {self._max_value}]"
1127
+ )
1128
+
1129
+ self._add_right_click_items()
1130
+ self._slider.valueChanged.connect(self._on_slider_values_changed)
1131
+
1132
+ self._apply_mode()
1133
+ self._update_all_labels()
1134
+ self._update_value_width()
1135
+
1136
+ # ----- validation -----
1137
+ def _validate_limit_one(self, name: str, value: TNum) -> None:
1138
+ """Validate that one value lies within the hard limits.
1139
+
1140
+ Args:
1141
+ name: Name of the value being validated.
1142
+ value: Value to validate.
1143
+
1144
+ Raises:
1145
+ ValueError: If the value violates a limit.
1146
+ """
1147
+ if self._min_limit is not None:
1148
+ ok = value >= self._min_limit if self._min_limit_inclusive else value > self._min_limit
1149
+ if not ok:
1150
+ raise ValueError(f"{name} must be within the allowed limits.")
1151
+
1152
+ if self._max_limit is not None:
1153
+ ok = value <= self._max_limit if self._max_limit_inclusive else value < self._max_limit
1154
+ if not ok:
1155
+ raise ValueError(f"{name} must be within the allowed limits.")
1156
+
1157
+ def _check_number(self, name: str, value: Any) -> None:
1158
+ """Check that a value has an acceptable numeric type.
1159
+
1160
+ Args:
1161
+ name: Name of the value being checked.
1162
+ value: Value to check.
1163
+
1164
+ Raises:
1165
+ TypeError: If the value is not a real number (or not an integer on
1166
+ integer sliders). ``bool`` is rejected.
1167
+ """
1168
+ if isinstance(value, bool) or not isinstance(value, numbers.Real):
1169
+ raise TypeError(f"{name} must be a number, not {type(value).__name__}")
1170
+ if self.value_is_int and not isinstance(value, numbers.Integral):
1171
+ raise TypeError(f"{name} must be an integer, not {type(value).__name__}")
1172
+
1173
+ def _validate_pair(self, values: Tuple[Any, Any]) -> Tuple[TNum, TNum]:
1174
+ """Validate and normalize a lower/upper pair.
1175
+
1176
+ Args:
1177
+ values: Pair of proposed lower and upper values.
1178
+
1179
+ Returns:
1180
+ Normalized lower and upper values.
1181
+
1182
+ Raises:
1183
+ TypeError: If a value is not numeric.
1184
+ ValueError: If a value violates the limits or upper <= lower.
1185
+ """
1186
+ self._check_number("lower", values[0])
1187
+ self._check_number("upper", values[1])
1188
+ lo = self.evaluate_value(values[0])
1189
+ hi = self.evaluate_value(values[1])
1190
+
1191
+ self._validate_limit_one("lower", lo)
1192
+ self._validate_limit_one("upper", hi)
1193
+
1194
+ if hi <= lo:
1195
+ raise ValueError("upper must be greater than lower")
1196
+ return lo, hi
1197
+
1198
+ @staticmethod
1199
+ def _validate_margins(margins: Tuple[Any, Any]) -> Tuple[int, int]:
1200
+ """Validate a lower/upper margin pair.
1201
+
1202
+ Args:
1203
+ margins: Lower and upper margins in percent.
1204
+
1205
+ Returns:
1206
+ Margins as integers.
1207
+
1208
+ Raises:
1209
+ ValueError: If a margin is outside 0-100.
1210
+ """
1211
+ m_lo, m_up = int(margins[0]), int(margins[1])
1212
+ if not (0 <= m_lo <= 100 and 0 <= m_up <= 100):
1213
+ raise ValueError("margins must be within 0-100")
1214
+ return m_lo, m_up
1215
+
1216
+ def _check_new_pair(self, lower: TNum, upper: TNum) -> str | None:
1217
+ """Check a proposed lower/upper pair (for the Set Values dialog).
1218
+
1219
+ Args:
1220
+ lower: Proposed lower value.
1221
+ upper: Proposed upper value.
1222
+
1223
+ Returns:
1224
+ Error message, or ``None`` if the pair is valid.
1225
+ """
1226
+ try:
1227
+ self._validate_pair((lower, upper))
1228
+ except (TypeError, ValueError) as e:
1229
+ return str(e)
1230
+ return None
1231
+
1232
+ def _check_new_config(self, new_min: TNum, new_max: TNum, new_fmt: str) -> str | None:
1233
+ """Check a proposed range and format before applying them.
1234
+
1235
+ Args:
1236
+ new_min: Proposed minimum value.
1237
+ new_max: Proposed maximum value.
1238
+ new_fmt: Proposed display format string.
1239
+
1240
+ Returns:
1241
+ Error message, or ``None`` if the configuration is valid.
1242
+ """
1243
+ eval_fmt = get_numeric_format_field(new_fmt)
1244
+ mn = self.evaluate_value(new_min, eval_fmt)
1245
+ mx = self.evaluate_value(new_max, eval_fmt)
1246
+ if not format_is_valid(new_fmt, mn):
1247
+ return f"Invalid format: {new_fmt!r}"
1248
+ if mn >= mx:
1249
+ return f"Min must be less than max (with this format: {mn} and {mx})."
1250
+ try:
1251
+ self._validate_limit_one("Min", mn)
1252
+ self._validate_limit_one("Max", mx)
1253
+ except ValueError as e:
1254
+ return str(e)
1255
+ return None
1256
+
1257
+ def _validate_config(self, config: dict[str, Any]) -> dict[str, Any]:
1258
+ """Merge and check a configuration mapping before applying it.
1259
+
1260
+ Args:
1261
+ config: Partial or complete configuration mapping.
1262
+
1263
+ Returns:
1264
+ Complete, validated configuration mapping.
1265
+
1266
+ Raises:
1267
+ ValueError: If the range or format is invalid.
1268
+ """
1269
+ config = super()._validate_config(config)
1270
+ msg = self._check_new_config(config["min_val"], config["max_val"], config["val_fmt"])
1271
+ if msg:
1272
+ raise ValueError(msg)
1273
+ return config
1274
+
1275
+ def _validate_value(self, value: Dict[str, Any]) -> Dict[str, Any]:
1276
+ """Validate and complete a value mapping.
1277
+
1278
+ Args:
1279
+ value: Mapping with optional ``use_margins``, ``range`` and
1280
+ ``margins`` entries; missing entries keep their current values.
1281
+
1282
+ Returns:
1283
+ Complete, normalized mapping.
1284
+
1285
+ Raises:
1286
+ TypeError: If ``value`` is not a mapping or the range is not numeric.
1287
+ ValueError: If the range, margins or mode are invalid.
1288
+ """
1289
+ if not isinstance(value, dict):
1290
+ raise TypeError(f"value must be a dict, not {type(value).__name__}")
1291
+ use_margins = bool(value.get("use_margins", self._use_margins))
1292
+ if use_margins and SliderFeature.MARGINS not in self._features:
1293
+ raise ValueError("use_margins=True requires SliderFeature.MARGINS")
1294
+ margins = self._validate_margins(value.get("margins", (self._margin_lower, self._margin_upper)))
1295
+ pair = self._validate_pair(value.get("range", self.get_current_value()))
1296
+ return {"use_margins": use_margins, "range": pair, "margins": margins}
1297
+
1298
+ @staticmethod
1299
+ def _clamp_pair_to_range(values: Tuple[TNum, TNum], new_min: TNum, new_max: TNum) -> Tuple[TNum, TNum]:
1300
+ """Clamp a lower/upper pair to a new numeric range.
1301
+
1302
+ Each value is clamped into ``[new_min, new_max]``. If that leaves no
1303
+ valid pair (upper not greater than lower), the full range is used.
1304
+
1305
+ Args:
1306
+ values: Pair of lower and upper values.
1307
+ new_min: New minimum range value.
1308
+ new_max: New maximum range value.
1309
+
1310
+ Returns:
1311
+ Clamped lower and upper values, with lower < upper.
1312
+ """
1313
+ lo, hi = values
1314
+ lo = min(max(lo, new_min), new_max)
1315
+ hi = min(max(hi, new_min), new_max)
1316
+ if hi <= lo:
1317
+ return new_min, new_max
1318
+ return lo, hi
1319
+
1320
+ # ----- context menu -----
1321
+ def _add_right_click_items(self) -> None:
1322
+ """Append range-slider context-menu items allowed by ``features``."""
1323
+ f = self._features
1324
+ items: List[Dict[str, Any]] = [{"id": "sep"}]
1325
+ if SliderFeature.MARGINS in f:
1326
+ items.append({"id": "use_values", "text": "Use Values", "checkable": True, "checked": False})
1327
+ if SliderFeature.SET_VALUE in f:
1328
+ items.append({"id": "set_values", "text": "Set Values..."})
1329
+ items.append({"id": "sep"})
1330
+ if SliderFeature.MARGINS in f:
1331
+ items += [
1332
+ {"id": "use_margins", "text": "Use Margins", "checkable": True, "checked": False},
1333
+ {"id": "set_margins", "text": "Set Margins..."},
1334
+ ]
1335
+ if SliderFeature.CONFIG in f:
1336
+ items += [{"id": "sep"}, {"id": "edit_config", "text": "Edit Configuration..."}]
1337
+ self._right_click_items.extend(items)
1338
+
1339
+ def _right_click_requested(self, item_id, checked: bool) -> None:
1340
+ """Dispatch range-slider context-menu actions (user actions).
1341
+
1342
+ Args:
1343
+ item_id: Triggered menu item identifier.
1344
+ checked: Checked state associated with the action.
1345
+ """
1346
+ super()._right_click_requested(item_id, checked)
1347
+ if item_id == "edit_config":
1348
+ self._open_edit_config_dlg()
1349
+ elif item_id == "set_values":
1350
+ self._open_set_values_dlg()
1351
+ elif item_id == "set_margins":
1352
+ self._open_set_margins_dlg()
1353
+ elif item_id in ("use_values", "use_margins"):
1354
+ # Clicking the active mode unchecks it (checked=False): just restore the marks.
1355
+ if checked:
1356
+ self._use_margins = item_id == "use_margins"
1357
+ self._apply_mode()
1358
+ self.valueChanged.emit(self.get_value())
1359
+ else:
1360
+ self._apply_mode()
1361
+
1362
+ def _apply_mode(self) -> None:
1363
+ """Apply the current values/margins mode to the menu and widgets (no signals)."""
1364
+ use_margins = self._use_margins
1365
+ self._set_right_click_item_checked("use_margins", use_margins)
1366
+ self._set_right_click_item_checked("use_values", not use_margins)
1367
+
1368
+ self._set_right_click_item_enabled("set_margins", use_margins)
1369
+ self._set_right_click_item_enabled("set_values", not use_margins)
1370
+ self._set_right_click_item_enabled("save_as_default", not use_margins)
1371
+ self._set_right_click_item_enabled("reset_to_default", not use_margins)
1372
+
1373
+ for w in (self._slider, self._min_label, self._max_label, self._value_wrap):
1374
+ w.setEnabled(not use_margins)
1375
+
1376
+ self._sync_overlay()
1377
+
1378
+ # ----- dialogs (user actions) -----
1379
+ def _open_edit_config_dlg(self) -> None:
1380
+ """Open the configuration dialog and apply accepted changes.
1381
+
1382
+ If the new range would exclude the current or default pair, the user
1383
+ is asked whether to clamp them. Cancelling restores the previous format
1384
+ and values.
1385
+ """
1386
+ dlg = EditConfigDialog[TNum](
1387
+ slider_name=self._label_text,
1388
+ value_is_int=self.value_is_int,
1389
+ min_val=self._min_value,
1390
+ max_val=self._max_value,
1391
+ val_fmt=self._val_fmt,
1392
+ min_limit=self._min_limit,
1393
+ max_limit=self._max_limit,
1394
+ min_limit_inclusive=self._min_limit_inclusive,
1395
+ max_limit_inclusive=self._max_limit_inclusive,
1396
+ validate=self._check_new_config,
1397
+ parent=self,
1398
+ )
1399
+ if dlg.exec() != QDialog.Accepted:
1400
+ return
1401
+ new_min, new_max, new_fmt = dlg.get_values()
1402
+
1403
+ # snapshot before set_format rounds anything
1404
+ old_fmt = self._val_fmt
1405
+ old_min, old_max = self._min_value, self._max_value
1406
+ saved_cur = self.get_current_value()
1407
+ saved_dft = self.get_default_value()
1408
+ old_value = self.get_value()
1409
+
1410
+ self.set_format(new_fmt)
1411
+ new_min = self.evaluate_value(new_min)
1412
+ new_max = self.evaluate_value(new_max)
1413
+
1414
+ cur = self.get_current_value()
1415
+ dft = self.get_default_value()
1416
+ new_cur = self._clamp_pair_to_range(cur, new_min, new_max)
1417
+ new_dft = self._clamp_pair_to_range(dft, new_min, new_max)
1418
+
1419
+ if new_cur != cur or new_dft != dft:
1420
+ self.set_border(SliderBorderState.ERROR)
1421
+ try:
1422
+ result = range_slider_ask_clamp_value(
1423
+ proposed_range=self._range_to_text((new_min, new_max)),
1424
+ old_cur=self._range_to_text(cur),
1425
+ old_dft=self._range_to_text(dft),
1426
+ new_cur=self._range_to_text(new_cur),
1427
+ new_dft=self._range_to_text(new_dft),
1428
+ parent=self,
1429
+ )
1430
+ finally:
1431
+ self.set_border(SliderBorderState.OFF)
1432
+ if result != AskResult.YES:
1433
+ self.set_format(old_fmt)
1434
+ self._current_lower, self._current_upper = saved_cur
1435
+ self._default_lower, self._default_upper = saved_dft
1436
+ self._min_value, self._max_value = old_min, old_max
1437
+ return
1438
+
1439
+ self.set_range(new_min, new_max)
1440
+
1441
+ if self.get_value() != old_value:
1442
+ self.valueChanged.emit(self.get_value())
1443
+ if self.get_default_value() != saved_dft:
1444
+ self.defaultChanged.emit(self.get_default_value())
1445
+ if (old_min, old_max, old_fmt) != (self._min_value, self._max_value, self._val_fmt):
1446
+ self.configChanged.emit(self.get_config())
1447
+
1448
+ def _open_set_values_dlg(self) -> None:
1449
+ """Open the value-pair dialog and apply the accepted pair.
1450
+
1451
+ Only the current pair changes; the default stays as it is.
1452
+ """
1453
+ dlg = SetValuesDialog[TNum](
1454
+ slider_name=self._label_text,
1455
+ value_is_int=self.value_is_int,
1456
+ current_lower=self._current_lower,
1457
+ current_upper=self._current_upper,
1458
+ fmt=self._val_fmt,
1459
+ min_limit=self._min_limit,
1460
+ max_limit=self._max_limit,
1461
+ min_limit_inclusive=self._min_limit_inclusive,
1462
+ max_limit_inclusive=self._max_limit_inclusive,
1463
+ validate=self._check_new_pair,
1464
+ parent=self,
1465
+ )
1466
+ if dlg.exec() != QDialog.Accepted:
1467
+ return
1468
+
1469
+ new_pair = self._validate_pair(dlg.get_values())
1470
+ old_value = self.get_value()
1471
+ old_range = (self._min_value, self._max_value)
1472
+
1473
+ if not (self._min_value <= new_pair[0] and new_pair[1] <= self._max_value):
1474
+ new_min = min(self._min_value, new_pair[0])
1475
+ new_max = max(self._max_value, new_pair[1])
1476
+ self.set_border(SliderBorderState.ERROR)
1477
+ try:
1478
+ result = range_slider_ask_extend_range(
1479
+ proposed_vals=self._range_to_text(new_pair),
1480
+ old_range=self._range_to_text(old_range),
1481
+ new_range=self._range_to_text((new_min, new_max)),
1482
+ parent=self,
1483
+ )
1484
+ finally:
1485
+ self.set_border(SliderBorderState.OFF)
1486
+ if result != AskResult.YES:
1487
+ return
1488
+
1489
+ self.set_value({"range": new_pair}, keep_default_value=True)
1490
+
1491
+ if self.get_value() != old_value:
1492
+ self.valueChanged.emit(self.get_value())
1493
+ if (self._min_value, self._max_value) != old_range:
1494
+ self.configChanged.emit(self.get_config())
1495
+
1496
+ def _open_set_margins_dlg(self) -> None:
1497
+ """Open the margins dialog and apply accepted changes."""
1498
+ dlg = SetMarginsDialog(
1499
+ slider_name=self._label_text,
1500
+ margin_lower=self._margin_lower,
1501
+ margin_upper=self._margin_upper,
1502
+ parent=self,
1503
+ )
1504
+ if dlg.exec() != QDialog.Accepted:
1505
+ return
1506
+
1507
+ new_margins = dlg.get_values()
1508
+ if new_margins != (self._margin_lower, self._margin_upper):
1509
+ self._margin_lower, self._margin_upper = new_margins
1510
+ self.valueChanged.emit(self.get_value())
1511
+
1512
+ # ----- config -----
1513
+ def get_config(self) -> dict:
1514
+ """Return the current configuration mapping.
1515
+
1516
+ Returns:
1517
+ Mapping containing the current range and format.
1518
+ """
1519
+ return {
1520
+ "min_val": self._min_value,
1521
+ "max_val": self._max_value,
1522
+ "val_fmt": self._val_fmt,
1523
+ }
1524
+
1525
+ def _apply_config(self, config: dict) -> None:
1526
+ """Apply a validated configuration mapping (no signals).
1527
+
1528
+ Args:
1529
+ config: Configuration mapping to apply.
1530
+ """
1531
+ self.set_format(config["val_fmt"])
1532
+ self.set_range(config["min_val"], config["max_val"])
1533
+
1534
+ # ----- value API (programmatic, no signals) -----
1535
+ def get_value(self) -> Dict[str, Any]:
1536
+ """Return the current mode, range and margins.
1537
+
1538
+ Returns:
1539
+ Mapping with ``use_margins``, ``range`` and ``margins``.
1540
+ """
1541
+ return {
1542
+ "use_margins": self._use_margins,
1543
+ "range": (self._current_lower, self._current_upper),
1544
+ "margins": (self._margin_lower, self._margin_upper),
1545
+ }
1546
+
1547
+ def get_current_value(self) -> Tuple[TNum, TNum]:
1548
+ """Return the current lower and upper values."""
1549
+ return self._current_lower, self._current_upper
1550
+
1551
+ def get_default_value(self) -> Tuple[TNum, TNum]:
1552
+ """Return the stored default lower and upper values."""
1553
+ return self._default_lower, self._default_upper
1554
+
1555
+ def get_min_value(self) -> TNum:
1556
+ """Return the current minimum range value."""
1557
+ return self._min_value
1558
+
1559
+ def get_max_value(self) -> TNum:
1560
+ """Return the current maximum range value."""
1561
+ return self._max_value
1562
+
1563
+ def set_value(self, value: Dict[str, Any], keep_default_value: bool = False) -> None:
1564
+ """Set the mode, range and/or margins (no signals).
1565
+
1566
+ Missing entries keep their current values. Everything is validated
1567
+ before anything changes. The visible range is extended if needed.
1568
+
1569
+ Args:
1570
+ value: Mapping with optional ``use_margins``, ``range`` and
1571
+ ``margins`` entries.
1572
+ keep_default_value: Whether to keep the stored default range.
1573
+
1574
+ Raises:
1575
+ TypeError: If ``value`` is not a mapping or the range is not numeric.
1576
+ ValueError: If the range, margins or mode are invalid.
1577
+ """
1578
+ self._apply_value(self._validate_value(value), keep_default_value)
1579
+
1580
+ def _apply_value(self, value: Dict[str, Any], keep_default_value: bool = False) -> None:
1581
+ """Apply a validated value mapping.
1582
+
1583
+ Args:
1584
+ value: Mapping returned by ``_validate_value``.
1585
+ keep_default_value: Whether to keep the stored default range.
1586
+ """
1587
+ lo, hi = value["range"]
1588
+ self._use_margins = value["use_margins"]
1589
+ self._margin_lower, self._margin_upper = value["margins"]
1590
+ self._current_lower, self._current_upper = lo, hi
1591
+ if not keep_default_value:
1592
+ self._default_lower, self._default_upper = lo, hi
1593
+
1594
+ self._min_value = min(self._min_value, lo)
1595
+ self._max_value = max(self._max_value, hi)
1596
+
1597
+ with self._quiet_updates():
1598
+ self._slider.setRange(float(self._min_value), float(self._max_value))
1599
+ self._slider.setValues((float(lo), float(hi)))
1600
+
1601
+ self._update_marker()
1602
+ self._update_all_labels()
1603
+ self._update_value_width()
1604
+ self._apply_mode()
1605
+
1606
+ def set_default_value(self, value: Tuple[TNum, TNum]) -> None:
1607
+ """Set the stored default lower and upper values (no signals).
1608
+
1609
+ The visible range is extended if needed.
1610
+
1611
+ Args:
1612
+ value: New default lower and upper values.
1613
+
1614
+ Raises:
1615
+ TypeError: If a value is not numeric.
1616
+ ValueError: If the pair violates the limits or upper <= lower.
1617
+ """
1618
+ lo, hi = self._validate_pair(value)
1619
+ self._default_lower, self._default_upper = lo, hi
1620
+ self._min_value = min(self._min_value, lo)
1621
+ self._max_value = max(self._max_value, hi)
1622
+
1623
+ with self._quiet_updates():
1624
+ self._slider.setRange(float(self._min_value), float(self._max_value))
1625
+ self._slider.setValues((float(self._current_lower), float(self._current_upper)))
1626
+
1627
+ self._update_marker()
1628
+ self._update_all_labels()
1629
+ self._update_value_width()
1630
+
1631
+ def set_range(self, min_val: TNum, max_val: TNum) -> None:
1632
+ """Set the numeric range and clamp current/default pairs (no signals).
1633
+
1634
+ Args:
1635
+ min_val: New minimum value.
1636
+ max_val: New maximum value.
1637
+
1638
+ Raises:
1639
+ ValueError: If a bound violates the limits or ``max_val <= min_val``.
1640
+ """
1641
+ min_val = self.evaluate_value(min_val)
1642
+ max_val = self.evaluate_value(max_val)
1643
+ self._validate_limit_one("min", min_val)
1644
+ self._validate_limit_one("max", max_val)
1645
+ if max_val <= min_val:
1646
+ raise ValueError("max_val must be greater than min_val")
1647
+
1648
+ self._min_value, self._max_value = min_val, max_val
1649
+ self._current_lower, self._current_upper = self._clamp_pair_to_range(
1650
+ self.get_current_value(), min_val, max_val
1651
+ )
1652
+ self._default_lower, self._default_upper = self._clamp_pair_to_range(
1653
+ self.get_default_value(), min_val, max_val
1654
+ )
1655
+
1656
+ with self._quiet_updates():
1657
+ self._slider.setRange(float(min_val), float(max_val))
1658
+ self._slider.setValues((float(self._current_lower), float(self._current_upper)))
1659
+
1660
+ self._update_marker()
1661
+ self._update_all_labels()
1662
+ self._update_value_width()
1663
+
1664
+ def set_min_value(self, min_val: TNum) -> None:
1665
+ """Set only the minimum bound (no signals).
1666
+
1667
+ Args:
1668
+ min_val: New minimum value.
1669
+ """
1670
+ self.set_range(min_val, self._max_value)
1671
+
1672
+ def set_max_value(self, max_val: TNum) -> None:
1673
+ """Set only the maximum bound (no signals).
1674
+
1675
+ Args:
1676
+ max_val: New maximum value.
1677
+ """
1678
+ self.set_range(self._min_value, max_val)
1679
+
1680
+
1681
+ class IntRangeSlider(NumericRangeSliderBase[int]):
1682
+ """Integer-valued range slider widget."""
1683
+
1684
+ def __init__(
1685
+ self,
1686
+ label: str,
1687
+ min_val: int,
1688
+ max_val: int,
1689
+ unit: str = "",
1690
+ init_vals: Optional[Tuple[int, int]] = None,
1691
+ val_fmt: str = "{:d}",
1692
+ min_limit: int | None = None,
1693
+ max_limit: int | None = None,
1694
+ min_limit_inclusive: bool = True,
1695
+ max_limit_inclusive: bool = True,
1696
+ use_margins: bool = False,
1697
+ margins: Tuple[int, int] = (10, 10),
1698
+ show_range: bool = True,
1699
+ orientation: Qt.Orientation = Qt.Horizontal,
1700
+ features: SliderFeature = SliderFeature.ALL,
1701
+ parent: Optional[QWidget] = None,
1702
+ ):
1703
+ """Initialize the integer range slider.
1704
+
1705
+ Args:
1706
+ label: Slider label text.
1707
+ min_val: Minimum finite value.
1708
+ max_val: Maximum finite value.
1709
+ unit: Optional unit label.
1710
+ init_vals: Initial lower and upper values.
1711
+ val_fmt: Integer display format string.
1712
+ min_limit: Optional lower hard limit.
1713
+ max_limit: Optional upper hard limit.
1714
+ min_limit_inclusive: Whether the lower hard limit is inclusive.
1715
+ max_limit_inclusive: Whether the upper hard limit is inclusive.
1716
+ use_margins: Whether the slider starts in margins mode.
1717
+ margins: Initial lower and upper margins in percent.
1718
+ show_range: Initial visibility of range labels and ticks; change
1719
+ later with ``set_show_range``.
1720
+ orientation: Slider orientation.
1721
+ features: Context-menu features offered by the slider. Defaults
1722
+ to ``SliderFeature.ALL``; ``SPECIAL`` and ``VALUES`` are ignored.
1723
+ parent: Optional parent widget.
1724
+ """
1725
+ super().__init__(
1726
+ label=label,
1727
+ unit=unit,
1728
+ value_is_int=True,
1729
+ min_val=min_val,
1730
+ max_val=max_val,
1731
+ init_vals=init_vals,
1732
+ val_fmt=val_fmt,
1733
+ min_limit=min_limit,
1734
+ max_limit=max_limit,
1735
+ min_limit_inclusive=min_limit_inclusive,
1736
+ max_limit_inclusive=max_limit_inclusive,
1737
+ use_margins=use_margins,
1738
+ margins=margins,
1739
+ show_range=show_range,
1740
+ orientation=orientation,
1741
+ features=features,
1742
+ parent=parent,
1743
+ )
1744
+
1745
+ def evaluate_value(self, value: int, eval_fmt: str | None = None) -> int:
1746
+ """Normalize an integer value using the evaluation format.
1747
+
1748
+ Args:
1749
+ value: Integer value to normalize.
1750
+ eval_fmt: Format to use instead of the current one.
1751
+
1752
+ Returns:
1753
+ Normalized integer value.
1754
+ """
1755
+ fmt = self._eval_fmt if eval_fmt is None else eval_fmt
1756
+ try:
1757
+ return int(fmt.format(value))
1758
+ except Exception:
1759
+ return int(value)
1760
+
1761
+
1762
+ class FloatRangeSlider(NumericRangeSliderBase[float]):
1763
+ """Floating-point range slider widget."""
1764
+
1765
+ def __init__(
1766
+ self,
1767
+ label: str,
1768
+ min_val: float,
1769
+ max_val: float,
1770
+ unit: str = "",
1771
+ init_vals: Optional[Tuple[float, float]] = None,
1772
+ val_fmt: str = "{:.3f}",
1773
+ min_limit: float | None = None,
1774
+ max_limit: float | None = None,
1775
+ min_limit_inclusive: bool = True,
1776
+ max_limit_inclusive: bool = True,
1777
+ use_margins: bool = False,
1778
+ margins: Tuple[int, int] = (10, 10),
1779
+ show_range: bool = True,
1780
+ orientation: Qt.Orientation = Qt.Horizontal,
1781
+ features: SliderFeature = SliderFeature.ALL,
1782
+ parent: Optional[QWidget] = None,
1783
+ ):
1784
+ """Initialize the floating-point range slider.
1785
+
1786
+ Args:
1787
+ label: Slider label text.
1788
+ min_val: Minimum finite value.
1789
+ max_val: Maximum finite value.
1790
+ unit: Optional unit label.
1791
+ init_vals: Initial lower and upper values.
1792
+ val_fmt: Float display format string.
1793
+ min_limit: Optional lower hard limit.
1794
+ max_limit: Optional upper hard limit.
1795
+ min_limit_inclusive: Whether the lower hard limit is inclusive.
1796
+ max_limit_inclusive: Whether the upper hard limit is inclusive.
1797
+ use_margins: Whether the slider starts in margins mode.
1798
+ margins: Initial lower and upper margins in percent.
1799
+ show_range: Initial visibility of range labels and ticks; change
1800
+ later with ``set_show_range``.
1801
+ orientation: Slider orientation.
1802
+ features: Context-menu features offered by the slider. Defaults
1803
+ to ``SliderFeature.ALL``; ``SPECIAL`` and ``VALUES`` are ignored.
1804
+ parent: Optional parent widget.
1805
+ """
1806
+ super().__init__(
1807
+ label=label,
1808
+ unit=unit,
1809
+ value_is_int=False,
1810
+ min_val=min_val,
1811
+ max_val=max_val,
1812
+ init_vals=init_vals,
1813
+ val_fmt=val_fmt,
1814
+ min_limit=min_limit,
1815
+ max_limit=max_limit,
1816
+ min_limit_inclusive=min_limit_inclusive,
1817
+ max_limit_inclusive=max_limit_inclusive,
1818
+ use_margins=use_margins,
1819
+ margins=margins,
1820
+ show_range=show_range,
1821
+ orientation=orientation,
1822
+ features=features,
1823
+ parent=parent,
1824
+ )
1825
+
1826
+ def evaluate_value(self, value: float, eval_fmt: str | None = None) -> float:
1827
+ """Normalize a float value using the evaluation format.
1828
+
1829
+ Args:
1830
+ value: Float value to normalize.
1831
+ eval_fmt: Format to use instead of the current one.
1832
+
1833
+ Returns:
1834
+ Normalized float value.
1835
+ """
1836
+ fmt = self._eval_fmt if eval_fmt is None else eval_fmt
1837
+ try:
1838
+ return float(fmt.format(float(value)))
1839
+ except Exception:
1840
+ return float(value)
1841
+