refactor: Labeled slider updates (#197)

* refactor: some slider updates

* fix tests

* finish

* finish

* import future
This commit is contained in:
Talley Lambert
2023-09-12 08:08:23 -04:00
committed by GitHub
parent 717b7e3d96
commit 66da7113e9
2 changed files with 274 additions and 193 deletions

View File

@@ -14,6 +14,7 @@ ORIENTATION = Qt.Orientation.Horizontal
w = QWidget()
qls = QLabeledSlider(ORIENTATION)
qls.setEdgeLabelMode(qls.EdgeLabelMode.LabelIsRange | qls.EdgeLabelMode.LabelIsValue)
qls.valueChanged.connect(lambda e: print("qls valueChanged", e))
qls.setRange(0, 500)
qls.setValue(300)

View File

@@ -1,13 +1,16 @@
from __future__ import annotations
import contextlib
from enum import IntEnum
from enum import IntEnum, IntFlag, auto
from functools import partial
from typing import Any
from typing import Any, overload
from qtpy.QtCore import QPoint, QSize, Qt, Signal
from qtpy.QtGui import QFontMetrics, QValidator
from qtpy.QtWidgets import (
QAbstractSlider,
QApplication,
QBoxLayout,
QDoubleSpinBox,
QHBoxLayout,
QSlider,
@@ -25,79 +28,82 @@ from ._sliders import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
class LabelPosition(IntEnum):
NoLabel = 0
LabelsAbove = 1
LabelsBelow = 2
LabelsRight = 1
LabelsLeft = 2
LabelsAbove = auto()
LabelsBelow = auto()
LabelsRight = LabelsAbove
LabelsLeft = LabelsBelow
class EdgeLabelMode(IntEnum):
class EdgeLabelMode(IntFlag):
NoLabel = 0
LabelIsRange = 1
LabelIsValue = 2
LabelIsRange = auto()
LabelIsValue = auto()
class _SliderProxy:
_slider: QSlider
def value(self):
def value(self) -> int:
return self._slider.value()
def setValue(self, value) -> None:
def setValue(self, value: int) -> None:
self._slider.setValue(value)
def sliderPosition(self):
def sliderPosition(self) -> int:
return self._slider.sliderPosition()
def setSliderPosition(self, pos) -> None:
def setSliderPosition(self, pos: int) -> None:
self._slider.setSliderPosition(pos)
def minimum(self):
def minimum(self) -> int:
return self._slider.minimum()
def setMinimum(self, minimum):
def setMinimum(self, minimum: int) -> None:
self._slider.setMinimum(minimum)
def maximum(self):
def maximum(self) -> int:
return self._slider.maximum()
def setMaximum(self, maximum):
def setMaximum(self, maximum: int) -> None:
self._slider.setMaximum(maximum)
def singleStep(self):
return self._slider.singleStep()
def setSingleStep(self, step):
def setSingleStep(self, step: int) -> None:
self._slider.setSingleStep(step)
def pageStep(self):
def pageStep(self) -> int:
return self._slider.pageStep()
def setPageStep(self, step) -> None:
def setPageStep(self, step: int) -> None:
self._slider.setPageStep(step)
def setRange(self, min, max) -> None:
def setRange(self, min: int, max: int) -> None:
self._slider.setRange(min, max)
def tickInterval(self):
def tickInterval(self) -> int:
return self._slider.tickInterval()
def setTickInterval(self, interval) -> None:
def setTickInterval(self, interval: int) -> None:
self._slider.setTickInterval(interval)
def tickPosition(self):
def tickPosition(self) -> QSlider.TickPosition:
return self._slider.tickPosition()
def setTickPosition(self, pos) -> None:
def setTickPosition(self, pos: QSlider.TickPosition) -> None:
self._slider.setTickPosition(pos)
def __getattr__(self, name) -> Any:
def __getattr__(self, name: Any) -> Any:
return getattr(self._slider, name)
def _handle_overloaded_slider_sig(args, kwargs):
def _handle_overloaded_slider_sig(
args: tuple, kwargs: dict
) -> tuple[QWidget | None, Qt.Orientation]:
"""Maintaining signature of QSlider.__init__."""
parent = None
orientation = Qt.Orientation.Vertical
orientation = Qt.Orientation.Horizontal
errmsg = (
"TypeError: arguments did not match any overloaded call:\n"
" QSlider(parent: QWidget = None)\n"
@@ -123,11 +129,20 @@ def _handle_overloaded_slider_sig(args, kwargs):
class QLabeledSlider(_SliderProxy, QAbstractSlider):
editingFinished = Signal()
EdgeLabelMode = EdgeLabelMode
_slider_class = QSlider
_slider: QSlider
def __init__(self, *args, **kwargs) -> None:
@overload
def __init__(self, parent: QWidget | None = ...) -> None:
...
@overload
def __init__(
self, orientation: Qt.Orientation, parent: QWidget | None = ...
) -> None:
...
def __init__(self, *args: Any, **kwargs: Any) -> None:
parent, orientation = _handle_overloaded_slider_sig(args, kwargs)
super().__init__(parent)
@@ -141,7 +156,7 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider):
self._rename_signals()
self._slider.actionTriggered.connect(self.actionTriggered.emit)
self._slider.rangeChanged.connect(self.rangeChanged.emit)
self._slider.rangeChanged.connect(self._on_slider_range_changed)
self._slider.sliderMoved.connect(self.sliderMoved.emit)
self._slider.sliderPressed.connect(self.sliderPressed.emit)
self._slider.sliderReleased.connect(self.sliderReleased.emit)
@@ -150,19 +165,9 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider):
self.setOrientation(orientation)
def _on_slider_value_changed(self, v):
self._label.setValue(v)
self.valueChanged.emit(v)
# ------------------- public API -------------------
def _setValue(self, value: float):
"""Convert the value from float to int before setting the slider value."""
self._slider.setValue(int(value))
def _rename_signals(self):
# for subclasses
pass
def setOrientation(self, orientation):
def setOrientation(self, orientation: Qt.Orientation) -> None:
"""Set orientation, value will be 'horizontal' or 'vertical'."""
self._slider.setOrientation(orientation)
marg = (0, 0, 0, 0)
@@ -194,11 +199,21 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider):
return self._edge_label_mode
def setEdgeLabelMode(self, opt: EdgeLabelMode) -> None:
"""Set the `EdgeLabelMode`."""
"""Set the `EdgeLabelMode`.
Parameters
----------
opt : EdgeLabelMode
To show no label, use `EdgeLabelMode.NoLabel`. To show the value
of the slider, use `EdgeLabelMode.LabelIsValue`. To show
`value / maximum`, use
`EdgeLabelMode.LabelIsValue | EdgeLabelMode.LabelIsRange`.
"""
if opt is EdgeLabelMode.LabelIsRange:
raise ValueError(
"mode must be one of 'EdgeLabelMode.NoLabel' or "
"'EdgeLabelMode.LabelIsValue'."
"'EdgeLabelMode.LabelIsValue' or"
"'EdgeLabelMode.LabelIsValue | EdgeLabelMode.LabelIsRange'."
)
self._edge_label_mode = opt
@@ -206,15 +221,39 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider):
self._label.hide()
w = 5 if self.orientation() == Qt.Orientation.Horizontal else 0
self.layout().setContentsMargins(0, 0, w, 0)
else:
if opt & EdgeLabelMode.LabelIsValue:
if self.isVisible():
self._label.show()
self._label.setMode(opt)
self._label.setValue(self._slider.value())
self.layout().setContentsMargins(0, 0, 0, 0)
self._on_slider_range_changed(self.minimum(), self.maximum())
QApplication.processEvents()
# putting this after labelMode methods for the sake of mypy
EdgeLabelMode = EdgeLabelMode
# --------------------- private api --------------------
def _on_slider_range_changed(self, min_: int, max_: int) -> None:
slash = " / " if self._edge_label_mode & EdgeLabelMode.LabelIsValue else ""
if self._edge_label_mode & EdgeLabelMode.LabelIsRange:
self._label.setSuffix(f"{slash}{max_}")
self.rangeChanged.emit(min_, max_)
def _on_slider_value_changed(self, v: Any) -> None:
self._label.setValue(v)
self.valueChanged.emit(v)
def _setValue(self, value: float) -> None:
"""Convert the value from float to int before setting the slider value."""
self._slider.setValue(int(value))
def _rename_signals(self) -> None:
# for subclasses
pass
class QLabeledDoubleSlider(QLabeledSlider):
_slider_class = QDoubleSlider
@@ -223,15 +262,25 @@ class QLabeledDoubleSlider(QLabeledSlider):
_fsliderMoved = Signal(float)
_frangeChanged = Signal(float, float)
def __init__(self, *args, **kwargs) -> None:
@overload
def __init__(self, parent: QWidget | None = ...) -> None:
...
@overload
def __init__(
self, orientation: Qt.Orientation, parent: QWidget | None = ...
) -> None:
...
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.setDecimals(2)
def _setValue(self, value: float):
def _setValue(self, value: float) -> None:
"""Convert the value from float to int before setting the slider value."""
self._slider.setValue(value)
def _rename_signals(self):
def _rename_signals(self) -> None:
self.valueChanged = self._fvalueChanged
self.sliderMoved = self._fsliderMoved
self.rangeChanged = self._frangeChanged
@@ -239,7 +288,7 @@ class QLabeledDoubleSlider(QLabeledSlider):
def decimals(self) -> int:
return self._label.decimals()
def setDecimals(self, prec: int):
def setDecimals(self, prec: int) -> None:
self._label.setDecimals(prec)
@@ -247,12 +296,20 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
_valueChanged = Signal(tuple)
editingFinished = Signal()
LabelPosition = LabelPosition
EdgeLabelMode = EdgeLabelMode
_slider_class = QRangeSlider
_slider: QRangeSlider
def __init__(self, *args, **kwargs) -> None:
@overload
def __init__(self, parent: QWidget | None = ...) -> None:
...
@overload
def __init__(
self, orientation: Qt.Orientation, parent: QWidget | None = ...
) -> None:
...
def __init__(self, *args: Any, **kwargs: Any) -> None:
parent, orientation = _handle_overloaded_slider_sig(args, kwargs)
super().__init__(parent)
self._rename_signals()
@@ -290,14 +347,13 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
self._on_range_changed(self._slider.minimum(), self._slider.maximum())
self.setOrientation(orientation)
def _rename_signals(self):
self.valueChanged = self._valueChanged
# --------------------- public API -------------------
def handleLabelPosition(self) -> LabelPosition:
"""Return where/whether labels are shown adjacent to slider handles."""
return self._handle_label_position
def setHandleLabelPosition(self, opt: LabelPosition) -> LabelPosition:
def setHandleLabelPosition(self, opt: LabelPosition) -> None:
"""Set where/whether labels are shown adjacent to slider handles."""
self._handle_label_position = opt
for lbl in self._handle_labels:
@@ -311,7 +367,7 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
"""Return current `EdgeLabelMode`."""
return self._edge_label_mode
def setEdgeLabelMode(self, opt: EdgeLabelMode):
def setEdgeLabelMode(self, opt: EdgeLabelMode) -> None:
"""Set `EdgeLabelMode`, controls what is shown at the min/max labels."""
self._edge_label_mode = opt
if not self._edge_label_mode:
@@ -333,106 +389,14 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
QApplication.processEvents()
self._reposition_labels()
def _reposition_labels(self):
if (
not self._handle_labels
or self._handle_label_position == LabelPosition.NoLabel
):
return
horizontal = self.orientation() == Qt.Orientation.Horizontal
labels_above = self._handle_label_position == LabelPosition.LabelsAbove
last_edge = None
for i, label in enumerate(self._handle_labels):
rect = self._slider._handleRect(i)
dx = -label.width() / 2
dy = -label.height() / 2
if labels_above:
if horizontal:
dy *= 3
else:
dx *= -1
else:
if horizontal:
dy *= -1
else:
dx *= 3
pos = self._slider.mapToParent(rect.center())
pos += QPoint(int(dx + self.label_shift_x), int(dy + self.label_shift_y))
if last_edge is not None:
# prevent label overlap
if horizontal:
pos.setX(int(max(pos.x(), last_edge.x() + label.width() / 2 + 12)))
else:
pos.setY(int(min(pos.y(), last_edge.y() - label.height() / 2 - 4)))
label.move(pos)
last_edge = pos
label.clearFocus()
label.show()
self.update()
def _min_label_edited(self, val):
if self._edge_label_mode == EdgeLabelMode.LabelIsRange:
self.setMinimum(val)
else:
v = list(self._slider.value())
v[0] = val
self.setValue(v)
self._reposition_labels()
def _max_label_edited(self, val):
if self._edge_label_mode == EdgeLabelMode.LabelIsRange:
self.setMaximum(val)
else:
v = list(self._slider.value())
v[-1] = val
self.setValue(v)
self._reposition_labels()
def _on_value_changed(self, v):
if self._edge_label_mode == EdgeLabelMode.LabelIsValue:
self._min_label.setValue(v[0])
self._max_label.setValue(v[-1])
if len(v) != len(self._handle_labels):
for lbl in self._handle_labels:
lbl.setParent(None)
lbl.deleteLater()
self._handle_labels.clear()
for n, val in enumerate(self._slider.value()):
_cb = partial(self._slider.setSliderPosition, index=n)
s = SliderLabel(self._slider, parent=self, connect=_cb)
s.editingFinished.connect(self.editingFinished)
s.setValue(val)
self._handle_labels.append(s)
else:
for val, label in zip(v, self._handle_labels):
label.setValue(val)
self._reposition_labels()
def _on_range_changed(self, min, max):
if (min, max) != (self._slider.minimum(), self._slider.maximum()):
self._slider.setRange(min, max)
for lbl in self._handle_labels:
lbl.setRange(min, max)
if self._edge_label_mode == EdgeLabelMode.LabelIsRange:
self._min_label.setValue(min)
self._max_label.setValue(max)
self._reposition_labels()
# def setValue(self, value) -> None:
# super().setValue(value)
# self.sliderChange(QSlider.SliderValueChange)
def setRange(self, min, max) -> None:
def setRange(self, min: int, max: int) -> None:
self._on_range_changed(min, max)
def setOrientation(self, orientation):
def setOrientation(self, orientation: Qt.Orientation) -> None:
"""Set orientation, value will be 'horizontal' or 'vertical'."""
self._slider.setOrientation(orientation)
if orientation == Qt.Orientation.Vertical:
layout = QVBoxLayout()
layout: QBoxLayout = QVBoxLayout()
layout.setSpacing(1)
layout.addWidget(self._max_label)
layout.addWidget(self._slider)
@@ -473,24 +437,134 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
super().resizeEvent(a0)
self._reposition_labels()
# putting this after methods above for the sake of mypy
LabelPosition = LabelPosition
EdgeLabelMode = EdgeLabelMode
# ------------- private methods ----------------
def _rename_signals(self) -> None:
self.valueChanged = self._valueChanged
def _reposition_labels(self) -> None:
if (
not self._handle_labels
or self._handle_label_position == LabelPosition.NoLabel
):
return
horizontal = self.orientation() == Qt.Orientation.Horizontal
labels_above = self._handle_label_position == LabelPosition.LabelsAbove
last_edge = None
for i, label in enumerate(self._handle_labels):
rect = self._slider._handleRect(i)
dx = -label.width() / 2
dy = -label.height() / 2
if labels_above:
if horizontal:
dy *= 3
else:
dx *= -1
else:
if horizontal:
dy *= -1
else:
dx *= 3
pos = self._slider.mapToParent(rect.center())
pos += QPoint(int(dx + self.label_shift_x), int(dy + self.label_shift_y))
if last_edge is not None:
# prevent label overlap
if horizontal:
pos.setX(int(max(pos.x(), last_edge.x() + label.width() / 2 + 12)))
else:
pos.setY(int(min(pos.y(), last_edge.y() - label.height() / 2 - 4)))
label.move(pos)
last_edge = pos
label.clearFocus()
label.show()
self.update()
def _min_label_edited(self, val: float) -> None:
if self._edge_label_mode == EdgeLabelMode.LabelIsRange:
self.setMinimum(val)
else:
v = list(self._slider.value())
v[0] = val
self.setValue(v)
self._reposition_labels()
def _max_label_edited(self, val: float) -> None:
if self._edge_label_mode == EdgeLabelMode.LabelIsRange:
self.setMaximum(val)
else:
v = list(self._slider.value())
v[-1] = val
self.setValue(v)
self._reposition_labels()
def _on_value_changed(self, v: tuple[int, ...]) -> None:
if self._edge_label_mode == EdgeLabelMode.LabelIsValue:
self._min_label.setValue(v[0])
self._max_label.setValue(v[-1])
if len(v) != len(self._handle_labels):
for lbl in self._handle_labels:
lbl.setParent(None)
lbl.deleteLater()
self._handle_labels.clear()
for n, val in enumerate(self._slider.value()):
_cb = partial(self._slider.setSliderPosition, index=n)
s = SliderLabel(self._slider, parent=self, connect=_cb)
s.editingFinished.connect(self.editingFinished)
s.setValue(val)
self._handle_labels.append(s)
else:
for val, label in zip(v, self._handle_labels):
label.setValue(val)
self._reposition_labels()
def _on_range_changed(self, min: int, max: int) -> None:
if (min, max) != (self._slider.minimum(), self._slider.maximum()):
self._slider.setRange(min, max)
for lbl in self._handle_labels:
lbl.setRange(min, max)
if self._edge_label_mode == EdgeLabelMode.LabelIsRange:
self._min_label.setValue(min)
self._max_label.setValue(max)
self._reposition_labels()
# def setValue(self, value) -> None:
# super().setValue(value)
# self.sliderChange(QSlider.SliderValueChange)
class QLabeledDoubleRangeSlider(QLabeledRangeSlider):
_slider_class = QDoubleRangeSlider
_slider: QDoubleRangeSlider
_frangeChanged = Signal(float, float)
def __init__(self, *args, **kwargs) -> None:
@overload
def __init__(self, parent: QWidget | None = ...) -> None:
...
@overload
def __init__(
self, orientation: Qt.Orientation, parent: QWidget | None = ...
) -> None:
...
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.setDecimals(2)
def _rename_signals(self):
def _rename_signals(self) -> None:
super()._rename_signals()
self.rangeChanged = self._frangeChanged
def decimals(self) -> int:
return self._min_label.decimals()
def setDecimals(self, prec: int):
def setDecimals(self, prec: int) -> None:
self._min_label.setDecimals(prec)
self._max_label.setDecimals(prec)
for lbl in self._handle_labels:
@@ -521,56 +595,26 @@ class SliderLabel(QDoubleSpinBox):
self.editingFinished.connect(self._silent_clear_focus)
self._update_size()
def _silent_clear_focus(self):
with signals_blocked(self):
self.clearFocus()
def setDecimals(self, prec: int) -> None:
super().setDecimals(prec)
self._update_size()
def _update_size(self, *_):
# fontmetrics to measure the width of text
fm = QFontMetrics(self.font())
h = self.sizeHint().height()
fixed_content = self.prefix() + self.suffix() + " "
if self._mode == EdgeLabelMode.LabelIsValue:
# determine width based on min/max/specialValue
mintext = self.textFromValue(self.minimum())[:18] + fixed_content
maxtext = self.textFromValue(self.maximum())[:18] + fixed_content
w = max(0, _fm_width(fm, mintext))
w = max(w, _fm_width(fm, maxtext))
if self.specialValueText():
w = max(w, _fm_width(fm, self.specialValueText()))
else:
w = max(0, _fm_width(fm, self.textFromValue(self.value()))) + 3
w += 3 # cursor blinking space
# get the final size hint
opt = QStyleOptionSpinBox()
self.initStyleOption(opt)
size = self.style().sizeFromContents(
QStyle.ContentsType.CT_SpinBox, opt, QSize(w, h), self
)
self.setFixedSize(size)
def setValue(self, val: Any) -> None:
super().setValue(val)
if self._mode == EdgeLabelMode.LabelIsRange:
self._update_size()
def setMaximum(self, max: int) -> None:
def setMaximum(self, max: float) -> None:
super().setMaximum(max)
if self._mode == EdgeLabelMode.LabelIsValue:
self._update_size()
def setMinimum(self, min: int) -> None:
def setMinimum(self, min: float) -> None:
super().setMinimum(min)
if self._mode == EdgeLabelMode.LabelIsValue:
self._update_size()
def setMode(self, opt: EdgeLabelMode):
def setMode(self, opt: EdgeLabelMode) -> None:
# when the edge labels are controlling slider range,
# we want them to have a big range, but not have a huge label
self._mode = opt
@@ -585,14 +629,50 @@ class SliderLabel(QDoubleSpinBox):
self._slider.rangeChanged.connect(self.setRange)
self._update_size()
def validate(self, input: str, pos: int):
# --------------- private ----------------
def _silent_clear_focus(self) -> None:
with signals_blocked(self):
self.clearFocus()
def _update_size(self, *_: Any) -> None:
# fontmetrics to measure the width of text
fm = QFontMetrics(self.font())
h = self.sizeHint().height()
fixed_content = self.prefix() + self.suffix() + " "
if self._mode & EdgeLabelMode.LabelIsValue:
# determine width based on min/max/specialValue
mintext = self.textFromValue(self.minimum())[:18]
maxtext = self.textFromValue(self.maximum())[:18]
w = max(0, _fm_width(fm, mintext + fixed_content))
w = max(w, _fm_width(fm, maxtext + fixed_content))
if self.specialValueText():
w = max(w, _fm_width(fm, self.specialValueText()))
if self._mode & EdgeLabelMode.LabelIsRange:
w += 8 # it seems as thought suffix() is not enough
else:
w = max(0, _fm_width(fm, self.textFromValue(self.value()))) + 3
w += 3 # cursor blinking space
# get the final size hint
opt = QStyleOptionSpinBox()
self.initStyleOption(opt)
size = self.style().sizeFromContents(
QStyle.ContentsType.CT_SpinBox, opt, QSize(w, h), self
)
self.setFixedSize(size)
def validate(
self, input_: str | None, pos: int
) -> tuple[QValidator.State, str, int]:
# fake like an integer spinbox
if "." in input and self.decimals() < 1:
return QValidator.Invalid, input, len(input)
return super().validate(input, pos)
if input_ and "." in input_ and self.decimals() < 1:
return QValidator.State.Invalid, input_, len(input_)
return super().validate(input_, pos)
def _fm_width(fm, text):
def _fm_width(fm: QFontMetrics, text: str) -> int:
if hasattr(fm, "horizontalAdvance"):
return fm.horizontalAdvance(text)
return fm.width(text)