Compare commits

..

1 Commits

Author SHA1 Message Date
Talley Lambert
f76cf6d126 FloatSlider (#4)
* decent point

* QLabeledDoubleRangeSlider

* update float add tests

* ugly but working

* fix validator

* flexible orientation

* horiz

* warnings are errors

* try convert

* fix signals

* skip signals test on windows pyqt6
2021-05-02 14:30:55 -04:00
9 changed files with 489 additions and 37 deletions

28
examples/float.py Normal file
View File

@@ -0,0 +1,28 @@
from qtrangeslider import QRangeSlider
from qtrangeslider._float_slider import QDoubleRangeSlider, QDoubleSlider
from qtrangeslider.qtcompat.QtCore import Qt
from qtrangeslider.qtcompat.QtWidgets import QApplication, QVBoxLayout, QWidget
app = QApplication([])
w = QWidget()
sld1 = QDoubleSlider(Qt.Horizontal)
sld2 = QDoubleRangeSlider(Qt.Horizontal)
rs = QRangeSlider(Qt.Horizontal)
sld1.valueChanged.connect(lambda e: print("doubslider valuechanged", e))
sld2.setMaximum(1)
sld2.setValue((0.2, 0.8))
sld2.valueChanged.connect(lambda e: print("valueChanged", e))
sld2.sliderMoved.connect(lambda e: print("sliderMoved", e))
sld2.rangeChanged.connect(lambda e, f: print("rangeChanged", (e, f)))
w.setLayout(QVBoxLayout())
w.layout().addWidget(sld1)
w.layout().addWidget(sld2)
w.layout().addWidget(rs)
w.show()
w.resize(500, 150)
app.exec_()

View File

@@ -1,17 +1,49 @@
from qtrangeslider._labeled import QLabeledRangeSlider, QLabeledSlider
from qtrangeslider._labeled import (
QLabeledDoubleRangeSlider,
QLabeledDoubleSlider,
QLabeledRangeSlider,
QLabeledSlider,
)
from qtrangeslider.qtcompat.QtCore import Qt
from qtrangeslider.qtcompat.QtWidgets import QApplication, QVBoxLayout, QWidget
from qtrangeslider.qtcompat.QtWidgets import (
QApplication,
QHBoxLayout,
QVBoxLayout,
QWidget,
)
app = QApplication([])
w = QWidget()
sld = QLabeledRangeSlider()
ORIENTATION = Qt.Horizontal
sld.setRange(0, 500)
sld.setValue((100, 400))
w.setLayout(QVBoxLayout())
w.layout().addWidget(sld)
w.layout().addWidget(QLabeledSlider(Qt.Horizontal))
w = QWidget()
qls = QLabeledSlider(ORIENTATION)
qls.valueChanged.connect(lambda e: print("qls valueChanged", e))
qls.setRange(0, 500)
qls.setValue(300)
qlds = QLabeledDoubleSlider(ORIENTATION)
qlds.valueChanged.connect(lambda e: print("qlds valueChanged", e))
qlds.setRange(0, 1)
qlds.setValue(0.5)
qlds.setSingleStep(0.1)
qlrs = QLabeledRangeSlider(ORIENTATION)
qlrs.valueChanged.connect(lambda e: print("qlrs valueChanged", e))
qlrs.setValue((20, 60))
qldrs = QLabeledDoubleRangeSlider(ORIENTATION)
qldrs.valueChanged.connect(lambda e: print("qlrs valueChanged", e))
qldrs.setRange(0, 1)
qldrs.setValue((0.2, 0.7))
w.setLayout(QVBoxLayout() if ORIENTATION == Qt.Horizontal else QHBoxLayout())
w.layout().addWidget(qls)
w.layout().addWidget(qlds)
w.layout().addWidget(qlrs)
w.layout().addWidget(qldrs)
w.show()
w.resize(500, 150)
app.exec_()

View File

@@ -3,7 +3,21 @@ try:
except ImportError:
__version__ = "unknown"
from ._labeled import QLabeledRangeSlider, QLabeledSlider
from ._float_slider import QDoubleRangeSlider, QDoubleSlider
from ._labeled import (
QLabeledDoubleRangeSlider,
QLabeledDoubleSlider,
QLabeledRangeSlider,
QLabeledSlider,
)
from ._qrangeslider import QRangeSlider
__all__ = ["QRangeSlider", "QLabeledRangeSlider", "QLabeledSlider"]
__all__ = [
"QDoubleRangeSlider",
"QDoubleSlider",
"QLabeledDoubleRangeSlider",
"QLabeledDoubleSlider",
"QLabeledRangeSlider",
"QLabeledSlider",
"QRangeSlider",
]

View File

@@ -0,0 +1,96 @@
import math
from typing import Tuple
from ._hooked import _HookedSlider
from ._qrangeslider import QRangeSlider
from .qtcompat.QtCore import Signal
class QDoubleSlider(_HookedSlider):
valueChanged = Signal(float)
rangeChanged = Signal(float, float)
sliderMoved = Signal(float)
_multiplier = 1
def __init__(self, *args):
super().__init__(*args)
self._multiplier = 10 ** 2
self.setMinimum(0)
self.setMaximum(99)
self.setSingleStep(1)
self.setPageStep(10)
super().sliderMoved.connect(
lambda e: self.sliderMoved.emit(self._post_get_hook(e))
)
def decimals(self) -> int:
"""This property holds the precision of the slider, in decimals."""
return int(math.log10(self._multiplier))
def setDecimals(self, prec: int):
"""This property holds the precision of the slider, in decimals
Sets how many decimals the slider uses for displaying and interpreting doubles.
"""
previous = self._multiplier
self._multiplier = 10 ** int(prec)
ratio = self._multiplier / previous
if ratio != 1:
self.blockSignals(True)
try:
newmin = self.minimum() * ratio
newmax = self.maximum() * ratio
newval = self._scale_value(ratio)
newstep = self.singleStep() * ratio
newpage = self.pageStep() * ratio
self.setRange(newmin, newmax)
self.setValue(newval)
self.setSingleStep(newstep)
self.setPageStep(newpage)
except OverflowError as err:
self._multiplier = previous
raise OverflowError(
f"Cannot use {prec} decimals with a range of {newmin}-"
f"{newmax}. If you need this feature, please open a feature"
" request at github."
) from err
self.blockSignals(False)
def _scale_value(self, p):
# for subclasses
return self.value() * p
def _post_get_hook(self, value: int) -> float:
return value / self._multiplier
def _pre_set_hook(self, value: float) -> int:
return int(value * self._multiplier)
def sliderChange(self, change) -> None:
if change == self.SliderValueChange:
self.valueChanged.emit(self.value())
if change == self.SliderRangeChange:
self.rangeChanged.emit(self.minimum(), self.maximum())
return super().sliderChange(self.SliderChange(change))
class QDoubleRangeSlider(QRangeSlider, QDoubleSlider):
rangeChanged = Signal(float, float)
def value(self) -> Tuple[float, ...]:
"""Get current value of the widget as a tuple of integers."""
return tuple(float(i) for i in self._value)
def _min_max_bound(self, val: int) -> float:
return round(super()._min_max_bound(val), self.decimals())
def _scale_value(self, p):
# This function is called during setDecimals...
# but because QRangeSlider has a private nonQt `_value`
# we don't actually need to scale
return self._value
def setDecimals(self, prec: int):
return super().setDecimals(prec)

49
qtrangeslider/_hooked.py Normal file
View File

@@ -0,0 +1,49 @@
from .qtcompat.QtWidgets import QSlider
class _HookedSlider(QSlider):
def _post_get_hook(self, value):
return value
def _pre_set_hook(self, value):
return value
def value(self) -> float: # type: ignore[override]
return float(self._post_get_hook(super().value()))
def setValue(self, value: float) -> None:
super().setValue(self._pre_set_hook(value))
def minimum(self) -> float: # type: ignore[override]
return self._post_get_hook(super().minimum())
def setMinimum(self, minimum: float):
super().setMinimum(self._pre_set_hook(minimum))
def maximum(self) -> float: # type: ignore[override]
return self._post_get_hook(super().maximum())
def setMaximum(self, maximum: float):
super().setMaximum(self._pre_set_hook(maximum))
def singleStep(self) -> float: # type: ignore[override]
return self._post_get_hook(super().singleStep())
def setSingleStep(self, step: float):
super().setSingleStep(self._pre_set_hook(step))
def pageStep(self) -> float: # type: ignore[override]
return self._post_get_hook(super().pageStep())
def setPageStep(self, step: float) -> None:
super().setPageStep(self._pre_set_hook(step))
def setRange(self, min: float, max: float) -> None:
super().setRange(self._pre_set_hook(min), self._pre_set_hook(max))
# def sliderChange(self, change) -> None:
# if change == QSlider.SliderValueChange:
# self.valueChanged.emit(self.value())
# if change == QSlider.SliderRangeChange:
# self.rangeChanged.emit(self.minimum(), self.maximum())
# return super().sliderChange(change)

View File

@@ -1,12 +1,14 @@
from enum import IntEnum
from functools import partial
from ._float_slider import QDoubleRangeSlider, QDoubleSlider
from ._qrangeslider import QRangeSlider
from .qtcompat.QtCore import QPoint, QSize, Qt, Signal
from .qtcompat.QtGui import QFontMetrics
from .qtcompat.QtGui import QFontMetrics, QValidator
from .qtcompat.QtWidgets import (
QAbstractSlider,
QApplication,
QDoubleSpinBox,
QHBoxLayout,
QSlider,
QSpinBox,
@@ -31,7 +33,47 @@ class EdgeLabelMode(IntEnum):
LabelIsValue = 2
class QLabeledSlider(QAbstractSlider):
class SliderProxy:
_slider: QAbstractSlider
def value(self):
return self._slider.value()
def setValue(self, value) -> None:
self._slider.setValue(value)
def minimum(self):
return self._slider.minimum()
def setMinimum(self, minimum):
self._slider.setMinimum(minimum)
def maximum(self):
return self._slider.maximum()
def setMaximum(self, maximum):
self._slider.setMaximum(maximum)
def singleStep(self):
return self._slider.singleStep()
def setSingleStep(self, step):
self._slider.setSingleStep(step)
def pageStep(self):
return self._slider.pageStep()
def setPageStep(self, step) -> None:
self._slider.setPageStep(step)
def setRange(self, min, max) -> None:
self._slider.setRange(min, max)
class QLabeledSlider(SliderProxy, QAbstractSlider):
_slider_class = QSlider
_slider: QSlider
def __init__(self, *args) -> None:
parent = None
orientation = Qt.Horizontal
@@ -45,8 +87,9 @@ class QLabeledSlider(QAbstractSlider):
super().__init__(parent)
self._slider = QSlider()
self._slider = self._slider_class()
self._slider.valueChanged.connect(self.valueChanged.emit)
self._slider.rangeChanged.connect(self.rangeChanged.emit)
self._label = SliderLabel(self._slider, connect=self.setValue)
self.valueChanged.connect(self._label.setValue)
@@ -80,10 +123,30 @@ class QLabeledSlider(QAbstractSlider):
self.setLayout(layout)
class QLabeledRangeSlider(QAbstractSlider):
class QLabeledDoubleSlider(QLabeledSlider):
_slider_class = QDoubleSlider
_slider: QDoubleSlider
valueChanged = Signal(float)
rangeChanged = Signal(float, float)
def __init__(self, *args) -> None:
super().__init__(*args)
self.setDecimals(2)
def decimals(self) -> int:
return self._slider.decimals()
def setDecimals(self, prec: int):
self._slider.setDecimals(prec)
self._label.setDecimals(prec)
class QLabeledRangeSlider(SliderProxy, QAbstractSlider):
valueChanged = Signal(tuple)
LabelPosition = LabelPosition
EdgeLabelMode = EdgeLabelMode
_slider_class = QRangeSlider
_slider: QRangeSlider
def __init__(self, *args) -> None:
parent = None
@@ -105,8 +168,9 @@ class QLabeledRangeSlider(QAbstractSlider):
self.label_shift_x = 0
self.label_shift_y = 0
self._slider = QRangeSlider()
self._slider = self._slider_class()
self._slider.valueChanged.connect(self.valueChanged.emit)
self._slider.rangeChanged.connect(self.rangeChanged.emit)
self._min_label = SliderLabel(
self._slider, alignment=Qt.AlignLeft, connect=self._min_label_edited
@@ -117,7 +181,7 @@ class QLabeledRangeSlider(QAbstractSlider):
self.setEdgeLabelMode(EdgeLabelMode.LabelIsRange)
self._slider.valueChanged.connect(self._on_value_changed)
self.rangeChanged.connect(self._on_range_changed)
self._slider.rangeChanged.connect(self._on_range_changed)
self._on_value_changed(self._slider.value())
self._on_range_changed(self._slider.minimum(), self._slider.maximum())
@@ -180,7 +244,7 @@ class QLabeledRangeSlider(QAbstractSlider):
else:
dx *= 3
pos = self._slider.mapToParent(rect.center())
pos += QPoint(dx + self.label_shift_x, dy + self.label_shift_y)
pos += QPoint(int(dx + self.label_shift_x), int(dy + self.label_shift_y))
label.move(pos)
label.clearFocus()
@@ -231,12 +295,12 @@ class QLabeledRangeSlider(QAbstractSlider):
self._max_label.setValue(max)
self._reposition_labels()
def value(self):
return self._slider.value()
# def setValue(self, value) -> None:
# super().setValue(value)
# self.sliderChange(QSlider.SliderValueChange)
def setValue(self, v: int) -> None:
self._slider.setValue(v)
self.sliderChange(QSlider.SliderValueChange)
def setRange(self, min, max) -> None:
self._on_range_changed(min, max)
def setOrientation(self, orientation):
"""Set orientation, value will be 'horizontal' or 'vertical'."""
@@ -285,7 +349,27 @@ class QLabeledRangeSlider(QAbstractSlider):
self._reposition_labels()
class SliderLabel(QSpinBox):
class QLabeledDoubleRangeSlider(QLabeledRangeSlider):
_slider_class = QDoubleRangeSlider
_slider: QDoubleRangeSlider
rangeChanged = Signal(float, float)
def __init__(self, *args) -> None:
super().__init__(*args)
self.setDecimals(2)
def decimals(self) -> int:
return self._slider.decimals()
def setDecimals(self, prec: int):
self._slider.setDecimals(prec)
self._min_label.setDecimals(prec)
self._max_label.setDecimals(prec)
for lbl in self._handle_labels:
lbl.setDecimals(prec)
class SliderLabel(QDoubleSpinBox):
def __init__(
self, slider: QSlider, parent=None, alignment=Qt.AlignCenter, connect=None
) -> None:
@@ -293,6 +377,7 @@ class SliderLabel(QSpinBox):
self._slider = slider
self.setFocusPolicy(Qt.ClickFocus)
self.setMode(EdgeLabelMode.LabelIsValue)
self.setDecimals(0)
self.setRange(slider.minimum(), slider.maximum())
slider.rangeChanged.connect(self._update_size)
@@ -304,6 +389,10 @@ class SliderLabel(QSpinBox):
self.editingFinished.connect(self.clearFocus)
self._update_size()
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())
@@ -360,3 +449,9 @@ class SliderLabel(QSpinBox):
self.setMaximum(self._slider.maximum())
self._slider.rangeChanged.connect(self.setRange)
self._update_size()
def validate(self, input: str, pos: int):
# fake like an integer spinbox
if "." in input and self.decimals() < 1:
return QValidator.Invalid, input, len(input)
return super().validate(input, pos)

View File

@@ -2,6 +2,7 @@ import textwrap
from collections import abc
from typing import List, Sequence, Tuple
from ._hooked import _HookedSlider
from ._style import RangeSliderStyle, update_styles_from_stylesheet
from .qtcompat import QtGui
from .qtcompat.QtCore import (
@@ -25,7 +26,7 @@ from .qtcompat.QtWidgets import (
ControlType = Tuple[str, int]
class QRangeSlider(QSlider):
class QRangeSlider(_HookedSlider, QSlider):
"""MultiHandle Range Slider widget.
Same API as QSlider, but `value`, `setValue`, `sliderPosition`, and
@@ -93,12 +94,11 @@ class QRangeSlider(QSlider):
The number of handles will be equal to the length of the sequence
"""
if not isinstance(val, abc.Sequence) and len(val) >= 2:
if not (isinstance(val, abc.Sequence) and len(val) >= 2):
raise ValueError("value must be iterable of len >= 2")
val = [self._min_max_bound(v) for v in val]
if self._value == val and self._position == val:
return
self._value[:] = val[:]
if self._position != val:
self._position = val
@@ -106,7 +106,7 @@ class QRangeSlider(QSlider):
self.sliderMoved.emit(tuple(self._position))
self.sliderChange(QSlider.SliderValueChange)
self.valueChanged.emit(tuple(self._value))
self.valueChanged.emit(self.value())
def sliderPosition(self) -> Tuple[int, ...]:
"""Get current value of the widget as a tuple of integers.
@@ -173,6 +173,7 @@ class QRangeSlider(QSlider):
pos = self._neighbor_bound(pos, index, self._position)
if pos == self._position[index]:
return
self._position[index] = pos
if _update:
self._updateSliderMove()
@@ -256,7 +257,8 @@ class QRangeSlider(QSlider):
elif self._hoverControl[0] == "handle":
hidx = self._hoverControl[1]
for idx, pos in enumerate(self._position):
opt.sliderPosition = pos
opt.sliderPosition = self._pre_set_hook(pos)
if idx == pidx: # make pressed handles appear sunken
opt.state |= QStyle.State_Sunken
else:
@@ -361,14 +363,14 @@ class QRangeSlider(QSlider):
style = self.style().proxy()
if handle_index is not None: # get specific handle rect
opt.sliderPosition = self._position[handle_index]
opt.sliderPosition = self._pre_set_hook(self._position[handle_index])
return style.subControlRect(
QStyle.CC_Slider, opt, QStyle.SC_SliderHandle, self
)
else:
rects = []
for p in self._position:
opt.sliderPosition = p
opt.sliderPosition = self._pre_set_hook(p)
r = style.subControlRect(
QStyle.CC_Slider, opt, QStyle.SC_SliderHandle, self
)
@@ -454,7 +456,6 @@ class QRangeSlider(QSlider):
def _pixelPosToRangeValue(self, pos: int, opt: QStyleOptionSlider = None) -> int:
if not opt:
opt = self._getStyleOption()
groove_rect = self._grooveRect(opt)
handle_rect = self._handleRects(opt, 0)
if self.orientation() == Qt.Horizontal:
@@ -465,13 +466,14 @@ class QRangeSlider(QSlider):
sliderLength = handle_rect.height()
sliderMin = groove_rect.y()
sliderMax = groove_rect.bottom() - sliderLength + 1
return QStyle.sliderValueFromPosition(
self.minimum(),
self.maximum(),
v = QStyle.sliderValueFromPosition(
opt.minimum,
opt.maximum,
pos - sliderMin,
sliderMax - sliderMin,
opt.upsideDown,
)
return self._post_get_hook(v)
def _pick(self, pt: QPoint) -> int:
return pt.x() if self.orientation() == Qt.Horizontal else pt.y()
@@ -550,10 +552,9 @@ class QRangeSlider(QSlider):
_prev_value = self.value()
if modifiers & Qt.AltModifier:
self._spreadAllPositions(shrink=steps_to_scroll < 0)
else:
self._offsetAllPositions(steps_to_scroll)
self._offsetAllPositions(self._post_get_hook(steps_to_scroll))
self.triggerAction(QSlider.SliderMove)
if _prev_value == self.value():

View File

@@ -0,0 +1,134 @@
import os
import pytest
from qtrangeslider import (
QDoubleRangeSlider,
QDoubleSlider,
QLabeledDoubleRangeSlider,
QLabeledDoubleSlider,
)
from qtrangeslider.qtcompat import API_NAME
range_types = {QDoubleRangeSlider, QLabeledDoubleRangeSlider}
@pytest.fixture(
params=[
QDoubleSlider,
QLabeledDoubleSlider,
QDoubleRangeSlider,
QLabeledDoubleRangeSlider,
]
)
def ds(qtbot, request):
# convenience fixture that converts value() and setValue()
# to let us use setValue((a, b)) for both range and non-range sliders
cls = request.param
wdg = cls()
qtbot.addWidget(wdg)
def assert_val_type():
type_ = float
if cls in range_types:
assert all([isinstance(i, type_) for i in wdg.value()]) # sourcery skip
else:
assert isinstance(wdg.value(), type_)
def assert_val_eq(val):
assert wdg.value() == val if cls is QDoubleRangeSlider else val[0]
wdg.assert_val_type = assert_val_type
wdg.assert_val_eq = assert_val_eq
if cls not in range_types:
superset = wdg.setValue
def _safe_set(val):
superset(val[0] if isinstance(val, tuple) else val)
wdg.setValue = _safe_set
return wdg
def test_double_sliders(ds):
ds.setMinimum(10)
ds.setMaximum(99)
ds.setValue((20, 40))
ds.setSingleStep(1)
assert ds.minimum() == 10
assert ds.maximum() == 99
ds.assert_val_eq((20, 40))
assert ds.singleStep() == 1
ds.setDecimals(2)
ds.assert_val_eq((20, 40))
ds.assert_val_type()
ds.setValue((20.23435, 40.2342))
ds.assert_val_eq((20.23, 40.23)) # because of decimals
ds.assert_val_type()
ds.setDecimals(4)
assert ds.minimum() == 10
assert ds.maximum() == 99
assert ds.singleStep() == 1
ds.assert_val_eq((20.23, 40.23))
ds.setValue((20.2343, 40.2342))
ds.assert_val_eq((20.2343, 40.2342))
ds.setDecimals(6)
ds.assert_val_eq((20.2343, 40.2342))
assert ds.minimum() == 10
assert ds.maximum() == 99
assert ds.singleStep() == 1
with pytest.raises(OverflowError) as err:
ds.setDecimals(8)
assert "open a feature request" in str(err)
ds.assert_val_eq((20.2343, 40.2342))
assert ds.minimum() == 10
assert ds.maximum() == 99
assert ds.singleStep() == 1
def test_double_sliders_small(ds):
ds.setMaximum(1)
ds.setDecimals(8)
ds.setValue((0.5, 0.9))
assert ds.minimum() == 0
assert ds.maximum() == 1
ds.assert_val_eq((0.5, 0.9))
ds.setValue((0.122233, 0.72644353))
ds.assert_val_eq((0.122233, 0.72644353))
def test_double_sliders_big(ds):
ds.setValue((20, 80))
ds.setDecimals(-6)
assert ds.decimals() == -6
ds.setMaximum(5e14)
assert ds.minimum() == 0
assert ds.maximum() == 5e14
ds.setValue((1.74e9, 1.432e10))
ds.assert_val_eq((1.74e9, 1.432e10))
@pytest.mark.skipif(
os.name == "nt" and API_NAME == "PyQt6", reason="Not ready for pyqt6"
)
def test_signals(ds, qtbot):
with qtbot.waitSignal(ds.valueChanged):
ds.setValue((10, 20))
with qtbot.waitSignal(ds.rangeChanged):
ds.setMinimum(0.5)
with qtbot.waitSignal(ds.rangeChanged):
ds.setMaximum(3.7)
with qtbot.waitSignal(ds.rangeChanged):
ds.setRange(1.2, 3.3)

View File

@@ -63,3 +63,6 @@ ignore = E203,W503,E501,C901,F403,F405
[isort]
profile=black
[tool:pytest]
addopts = -W error