mirror of
https://github.com/pyapp-kit/superqt.git
synced 2025-07-21 12:11:07 +02:00
Generic slider (#14)
* good coverage * merged classes * working cross platform * range slider tests working too * many more fixes and unification * type * reorg * working labels, better typing * tests * legacy compat * update envlist * skip mouse press not on mac * fix getStyleOption * fix again * skip hover * remove print * add module docstring
This commit is contained in:
13
.github/workflows/test_and_deploy.yml
vendored
13
.github/workflows/test_and_deploy.yml
vendored
@@ -66,6 +66,15 @@ jobs:
|
||||
- python-version: 3.6
|
||||
platform: windows-2016
|
||||
backend: pyqt5
|
||||
|
||||
# legacy Qt
|
||||
- python-version: 3.7
|
||||
platform: ubuntu-latest
|
||||
backend: pyqt511
|
||||
- python-version: 3.7
|
||||
platform: ubuntu-latest
|
||||
backend: pyside511
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
@@ -106,11 +115,11 @@ jobs:
|
||||
if: runner.os == 'Linux' && matrix.screenshot
|
||||
uses: GabrielBB/xvfb-action@v1
|
||||
with:
|
||||
run: python examples/demo_widget.py
|
||||
run: python examples/demo_widget.py -snap
|
||||
|
||||
- name: Screenshots
|
||||
if: runner.os != 'Linux' && matrix.screenshot
|
||||
run: python examples/demo_widget.py
|
||||
run: python examples/demo_widget.py -snap
|
||||
|
||||
- uses: actions/upload-artifact@v2
|
||||
if: matrix.screenshot
|
||||
|
@@ -4,6 +4,7 @@ from qtrangeslider.qtcompat.QtWidgets import QApplication
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
slider = QRangeSlider(Qt.Horizontal)
|
||||
slider = QRangeSlider(Qt.Horizontal)
|
||||
|
||||
slider.setValue((20, 80))
|
||||
|
12
examples/basic_float.py
Normal file
12
examples/basic_float.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from qtrangeslider import QDoubleSlider
|
||||
from qtrangeslider.qtcompat.QtCore import Qt
|
||||
from qtrangeslider.qtcompat.QtWidgets import QApplication
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
slider = QDoubleSlider(Qt.Horizontal)
|
||||
slider.setRange(0, 1)
|
||||
slider.setValue(0.5)
|
||||
slider.show()
|
||||
|
||||
app.exec_()
|
@@ -112,10 +112,10 @@ if __name__ == "__main__":
|
||||
app = QtW.QApplication([])
|
||||
demo = DemoWidget()
|
||||
|
||||
if "-x" in sys.argv:
|
||||
app.exec_()
|
||||
else:
|
||||
if "-snap" in sys.argv:
|
||||
import platform
|
||||
|
||||
QtW.QApplication.processEvents()
|
||||
demo.grab().save(str(dest / f"demo_{platform.system().lower()}.png"))
|
||||
else:
|
||||
app.exec_()
|
||||
|
@@ -1,5 +1,4 @@
|
||||
from qtrangeslider import QRangeSlider
|
||||
from qtrangeslider._float_slider import QDoubleRangeSlider, QDoubleSlider
|
||||
from qtrangeslider import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
|
||||
from qtrangeslider.qtcompat.QtCore import Qt
|
||||
from qtrangeslider.qtcompat.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||
|
||||
|
12
examples/generic.py
Normal file
12
examples/generic.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from qtrangeslider import QDoubleSlider
|
||||
from qtrangeslider.qtcompat.QtCore import Qt
|
||||
from qtrangeslider.qtcompat.QtWidgets import QApplication
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
sld = QDoubleSlider(Qt.Horizontal)
|
||||
sld.setRange(0, 1)
|
||||
sld.setValue(0.5)
|
||||
sld.show()
|
||||
|
||||
app.exec_()
|
@@ -30,12 +30,13 @@ qlds.setValue(0.5)
|
||||
qlds.setSingleStep(0.1)
|
||||
|
||||
qlrs = QLabeledRangeSlider(ORIENTATION)
|
||||
qlrs.valueChanged.connect(lambda e: print("qlrs valueChanged", e))
|
||||
qlrs.valueChanged.connect(lambda e: print("QLabeledRangeSlider valueChanged", e))
|
||||
qlrs.setValue((20, 60))
|
||||
|
||||
qldrs = QLabeledDoubleRangeSlider(ORIENTATION)
|
||||
qldrs.valueChanged.connect(lambda e: print("qlrs valueChanged", e))
|
||||
qldrs.setRange(0, 1)
|
||||
qldrs.setSingleStep(0.01)
|
||||
qldrs.setValue((0.2, 0.7))
|
||||
|
||||
|
||||
|
@@ -3,14 +3,13 @@ try:
|
||||
except ImportError:
|
||||
__version__ = "unknown"
|
||||
|
||||
from ._float_slider import QDoubleRangeSlider, QDoubleSlider
|
||||
from ._labeled import (
|
||||
QLabeledDoubleRangeSlider,
|
||||
QLabeledDoubleSlider,
|
||||
QLabeledRangeSlider,
|
||||
QLabeledSlider,
|
||||
)
|
||||
from ._qrangeslider import QRangeSlider
|
||||
from ._sliders import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
|
||||
|
||||
__all__ = [
|
||||
"QDoubleRangeSlider",
|
||||
|
@@ -1,96 +0,0 @@
|
||||
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, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
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 float(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)
|
336
qtrangeslider/_generic_range_slider.py
Normal file
336
qtrangeslider/_generic_range_slider.py
Normal file
@@ -0,0 +1,336 @@
|
||||
from typing import Generic, List, Sequence, Tuple, TypeVar, Union
|
||||
|
||||
from ._generic_slider import CC_SLIDER, SC_GROOVE, SC_HANDLE, SC_NONE, _GenericSlider
|
||||
from ._range_style import RangeSliderStyle, update_styles_from_stylesheet
|
||||
from .qtcompat import QtGui
|
||||
from .qtcompat.QtCore import (
|
||||
Property,
|
||||
QEvent,
|
||||
QPoint,
|
||||
QPointF,
|
||||
QRect,
|
||||
QRectF,
|
||||
Qt,
|
||||
Signal,
|
||||
)
|
||||
from .qtcompat.QtWidgets import QSlider, QStyle, QStyleOptionSlider, QStylePainter
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
SC_BAR = QStyle.SubControl.SC_ScrollBarSubPage
|
||||
|
||||
|
||||
class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
|
||||
"""MultiHandle Range Slider widget.
|
||||
|
||||
Same API as QSlider, but `value`, `setValue`, `sliderPosition`, and
|
||||
`setSliderPosition` are all sequences of integers.
|
||||
|
||||
The `valueChanged` and `sliderMoved` signals also both emit a tuple of
|
||||
integers.
|
||||
"""
|
||||
|
||||
# Emitted when the slider value has changed, with the new slider values
|
||||
valueChanged = Signal(tuple)
|
||||
|
||||
# Emitted when sliderDown is true and the slider moves
|
||||
# This usually happens when the user is dragging the slider
|
||||
# The value is the positions of *all* handles.
|
||||
sliderMoved = Signal(tuple)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# list of values
|
||||
self._value: List[_T] = [20, 80]
|
||||
|
||||
# list of current positions of each handle. same length as _value
|
||||
# If tracking is enabled (the default) this will be identical to _value
|
||||
self._position: List[_T] = [20, 80]
|
||||
|
||||
# which handle is being pressed/hovered
|
||||
self._pressedIndex = 0
|
||||
self._hoverIndex = 0
|
||||
|
||||
# whether bar length is constant when dragging the bar
|
||||
# if False, the bar can shorten when dragged beyond min/max
|
||||
self._bar_is_rigid = True
|
||||
# whether clicking on the bar moves all handles, or just the nearest handle
|
||||
self._bar_moves_all = True
|
||||
self._should_draw_bar = True
|
||||
|
||||
# color
|
||||
|
||||
self._style = RangeSliderStyle()
|
||||
self.setStyleSheet("")
|
||||
update_styles_from_stylesheet(self)
|
||||
|
||||
# ############### New Public API #######################
|
||||
|
||||
def barIsRigid(self) -> bool:
|
||||
"""Whether bar length is constant when dragging the bar.
|
||||
|
||||
If False, the bar can shorten when dragged beyond min/max. Default is True.
|
||||
"""
|
||||
return self._bar_is_rigid
|
||||
|
||||
def setBarIsRigid(self, val: bool = True) -> None:
|
||||
"""Whether bar length is constant when dragging the bar.
|
||||
|
||||
If False, the bar can shorten when dragged beyond min/max. Default is True.
|
||||
"""
|
||||
self._bar_is_rigid = bool(val)
|
||||
|
||||
def barMovesAllHandles(self) -> bool:
|
||||
"""Whether clicking on the bar moves all handles (default), or just the nearest."""
|
||||
return self._bar_moves_all
|
||||
|
||||
def setBarMovesAllHandles(self, val: bool = True) -> None:
|
||||
"""Whether clicking on the bar moves all handles (default), or just the nearest."""
|
||||
self._bar_moves_all = bool(val)
|
||||
|
||||
def barIsVisible(self) -> bool:
|
||||
"""Whether to show the bar between the first and last handle."""
|
||||
return self._should_draw_bar
|
||||
|
||||
def setBarVisible(self, val: bool = True) -> None:
|
||||
"""Whether to show the bar between the first and last handle."""
|
||||
self._should_draw_bar = bool(val)
|
||||
|
||||
def hideBar(self) -> None:
|
||||
self.setBarVisible(False)
|
||||
|
||||
def showBar(self) -> None:
|
||||
self.setBarVisible(True)
|
||||
|
||||
# ############### QtOverrides #######################
|
||||
|
||||
def value(self) -> Tuple[_T, ...]:
|
||||
"""Get current value of the widget as a tuple of integers."""
|
||||
return tuple(self._value)
|
||||
|
||||
def sliderPosition(self):
|
||||
"""Get current value of the widget as a tuple of integers.
|
||||
|
||||
If tracking is enabled (the default) this will be identical to value().
|
||||
"""
|
||||
return tuple(float(i) for i in self._position)
|
||||
|
||||
def setSliderPosition(self, pos: Union[float, Sequence[float]], index=None) -> None:
|
||||
"""Set current position of the handles with a sequence of integers.
|
||||
|
||||
If `pos` is a sequence, it must have the same length as `value()`.
|
||||
If it is a scalar, index will be
|
||||
"""
|
||||
if isinstance(pos, (list, tuple)):
|
||||
val_len = len(self.value())
|
||||
if len(pos) != val_len:
|
||||
msg = f"'sliderPosition' must have same length as 'value()' ({val_len})"
|
||||
raise ValueError(msg)
|
||||
pairs = list(enumerate(pos))
|
||||
else:
|
||||
pairs = [(self._pressedIndex if index is None else index, pos)]
|
||||
|
||||
for idx, position in pairs:
|
||||
self._position[idx] = self._bound(position, idx)
|
||||
|
||||
self._doSliderMove()
|
||||
|
||||
def setStyleSheet(self, styleSheet: str) -> None:
|
||||
# sub-page styles render on top of the lower sliders and don't work here.
|
||||
override = f"""
|
||||
\n{type(self).__name__}::sub-page:horizontal {{background: none}}
|
||||
\n{type(self).__name__}::sub-page:vertical {{background: none}}
|
||||
"""
|
||||
return super().setStyleSheet(styleSheet + override)
|
||||
|
||||
def event(self, ev: QEvent) -> bool:
|
||||
if ev.type() == QEvent.StyleChange:
|
||||
update_styles_from_stylesheet(self)
|
||||
return super().event(ev)
|
||||
|
||||
def mouseMoveEvent(self, ev: QtGui.QMouseEvent) -> None:
|
||||
if self._pressedControl == SC_BAR:
|
||||
ev.accept()
|
||||
delta = self._clickOffset - self._pixelPosToRangeValue(self._pick(ev.pos()))
|
||||
self._offsetAllPositions(-delta, self._sldPosAtPress)
|
||||
else:
|
||||
super().mouseMoveEvent(ev)
|
||||
|
||||
# ############### Implementation Details #######################
|
||||
|
||||
def _setPosition(self, val):
|
||||
self._position = list(val)
|
||||
|
||||
def _bound(self, value, index=None):
|
||||
if isinstance(value, (list, tuple)):
|
||||
return type(value)(self._bound(v) for v in value)
|
||||
pos = super()._bound(value)
|
||||
if index is not None:
|
||||
pos = self._neighbor_bound(pos, index)
|
||||
return self._type_cast(pos)
|
||||
|
||||
def _neighbor_bound(self, val, index):
|
||||
# make sure we don't go lower than any preceding index:
|
||||
min_dist = self.singleStep()
|
||||
_lst = self._position
|
||||
if index > 0:
|
||||
val = max(_lst[index - 1] + min_dist, val)
|
||||
# make sure we don't go higher than any following index:
|
||||
if index < (len(_lst) - 1):
|
||||
val = min(_lst[index + 1] - min_dist, val)
|
||||
return val
|
||||
|
||||
def _getBarColor(self):
|
||||
return self._style.brush(self._styleOption)
|
||||
|
||||
def _setBarColor(self, color):
|
||||
self._style.brush_active = color
|
||||
|
||||
barColor = Property(QtGui.QBrush, _getBarColor, _setBarColor)
|
||||
|
||||
def _offsetAllPositions(self, offset: float, ref=None) -> None:
|
||||
if ref is None:
|
||||
ref = self._position
|
||||
if self._bar_is_rigid:
|
||||
# NOTE: This assumes monotonically increasing slider positions
|
||||
if offset > 0 and ref[-1] + offset > self.maximum():
|
||||
offset = self.maximum() - ref[-1]
|
||||
elif ref[0] + offset < self.minimum():
|
||||
offset = self.minimum() - ref[0]
|
||||
self.setSliderPosition([i + offset for i in ref])
|
||||
|
||||
def _fixStyleOption(self, option):
|
||||
pass
|
||||
|
||||
@property
|
||||
def _optSliderPositions(self):
|
||||
return [self._to_qinteger_space(p - self._minimum) for p in self._position]
|
||||
|
||||
# SubControl Positions
|
||||
|
||||
def _handleRect(self, handle_index: int, opt: QStyleOptionSlider = None) -> QRect:
|
||||
"""Return the QRect for all handles."""
|
||||
opt = opt or self._styleOption
|
||||
opt.sliderPosition = self._optSliderPositions[handle_index]
|
||||
return self.style().subControlRect(CC_SLIDER, opt, SC_HANDLE, self)
|
||||
|
||||
def _barRect(self, opt: QStyleOptionSlider) -> QRect:
|
||||
"""Return the QRect for the bar between the outer handles."""
|
||||
r_groove = self.style().subControlRect(CC_SLIDER, opt, SC_GROOVE, self)
|
||||
r_bar = QRectF(r_groove)
|
||||
hdl_low, hdl_high = self._handleRect(0, opt), self._handleRect(-1, opt)
|
||||
|
||||
thickness = self._style.thickness(opt)
|
||||
offset = self._style.offset(opt)
|
||||
|
||||
if opt.orientation == Qt.Horizontal:
|
||||
r_bar.setTop(r_bar.center().y() - thickness / 2 + offset)
|
||||
r_bar.setHeight(thickness)
|
||||
r_bar.setLeft(hdl_low.center().x())
|
||||
r_bar.setRight(hdl_high.center().x())
|
||||
else:
|
||||
r_bar.setLeft(r_bar.center().x() - thickness / 2 + offset)
|
||||
r_bar.setWidth(thickness)
|
||||
r_bar.setBottom(hdl_low.center().y())
|
||||
r_bar.setTop(hdl_high.center().y())
|
||||
|
||||
return r_bar
|
||||
|
||||
# Painting
|
||||
|
||||
def _drawBar(self, painter: QStylePainter, opt: QStyleOptionSlider):
|
||||
brush = self._style.brush(opt)
|
||||
r_bar = self._barRect(opt)
|
||||
if isinstance(brush, QtGui.QGradient):
|
||||
brush.setStart(r_bar.topLeft())
|
||||
brush.setFinalStop(r_bar.bottomRight())
|
||||
painter.setPen(self._style.pen(opt))
|
||||
painter.setBrush(brush)
|
||||
painter.drawRect(r_bar)
|
||||
|
||||
def _draw_handle(self, painter: QStylePainter, opt: QStyleOptionSlider):
|
||||
if self._should_draw_bar:
|
||||
self._drawBar(painter, opt)
|
||||
|
||||
opt.subControls = SC_HANDLE
|
||||
pidx = self._pressedIndex if self._pressedControl == SC_HANDLE else -1
|
||||
hidx = self._hoverIndex if self._hoverControl == SC_HANDLE else -1
|
||||
for idx, pos in enumerate(self._optSliderPositions):
|
||||
opt.sliderPosition = pos
|
||||
# make pressed handles appear sunken
|
||||
if idx == pidx:
|
||||
opt.state |= QStyle.State_Sunken
|
||||
else:
|
||||
opt.state = opt.state & ~QStyle.State_Sunken
|
||||
opt.activeSubControls = SC_HANDLE if idx == hidx else SC_NONE
|
||||
painter.drawComplexControl(CC_SLIDER, opt)
|
||||
|
||||
def _updateHoverControl(self, pos):
|
||||
old_hover = self._hoverControl, self._hoverIndex
|
||||
self._hoverControl, self._hoverIndex = self._getControlAtPos(pos)
|
||||
if (self._hoverControl, self._hoverIndex) != old_hover:
|
||||
self.update()
|
||||
|
||||
def _updatePressedControl(self, pos):
|
||||
opt = self._styleOption
|
||||
self._pressedControl, self._pressedIndex = self._getControlAtPos(pos, opt)
|
||||
|
||||
def _setClickOffset(self, pos):
|
||||
if self._pressedControl == SC_BAR:
|
||||
self._clickOffset = self._pixelPosToRangeValue(self._pick(pos))
|
||||
self._sldPosAtPress = tuple(self._position)
|
||||
elif self._pressedControl == SC_HANDLE:
|
||||
hr = self._handleRect(self._pressedIndex)
|
||||
self._clickOffset = self._pick(pos - hr.topLeft())
|
||||
|
||||
# NOTE: this is very much tied to mousepress... not a generic "get control"
|
||||
def _getControlAtPos(
|
||||
self, pos: QPoint, opt: QStyleOptionSlider = None
|
||||
) -> Tuple[QStyle.SubControl, int]:
|
||||
"""Update self._pressedControl based on ev.pos()."""
|
||||
opt = opt or self._styleOption
|
||||
|
||||
if isinstance(pos, QPointF):
|
||||
pos = pos.toPoint()
|
||||
|
||||
for i in range(len(self._position)):
|
||||
if self._handleRect(i, opt).contains(pos):
|
||||
return (SC_HANDLE, i)
|
||||
|
||||
click_pos = self._pixelPosToRangeValue(self._pick(pos))
|
||||
for i, p in enumerate(self._position):
|
||||
if p > click_pos:
|
||||
if i > 0:
|
||||
# the click was in an internal segment
|
||||
if self._bar_moves_all:
|
||||
return (SC_BAR, i)
|
||||
avg = (self._position[i - 1] + self._position[i]) / 2
|
||||
return (SC_HANDLE, i - 1 if click_pos < avg else i)
|
||||
# the click was below the minimum slider
|
||||
return (SC_HANDLE, 0)
|
||||
# the click was above the maximum slider
|
||||
return (SC_HANDLE, len(self._position) - 1)
|
||||
|
||||
def _execute_scroll(self, steps_to_scroll, modifiers):
|
||||
if modifiers & Qt.AltModifier:
|
||||
self._spreadAllPositions(shrink=steps_to_scroll < 0)
|
||||
else:
|
||||
self._offsetAllPositions(steps_to_scroll)
|
||||
self.triggerAction(QSlider.SliderMove)
|
||||
|
||||
def _has_scroll_space_left(self, offset):
|
||||
return (offset > 0 and max(self._value) < self._maximum) or (
|
||||
offset < 0 and min(self._value) < self._minimum
|
||||
)
|
||||
|
||||
def _spreadAllPositions(self, shrink=False, gain=1.1, ref=None) -> None:
|
||||
if ref is None:
|
||||
ref = self._position
|
||||
# if self._bar_is_rigid: # TODO
|
||||
|
||||
if shrink:
|
||||
gain = 1 / gain
|
||||
center = abs(ref[-1] + ref[0]) / 2
|
||||
self.setSliderPosition([((i - center) * gain) + center for i in ref])
|
488
qtrangeslider/_generic_slider.py
Normal file
488
qtrangeslider/_generic_slider.py
Normal file
@@ -0,0 +1,488 @@
|
||||
"""Generic Sliders with internal python-based models
|
||||
|
||||
This module reimplements most of the logic from qslider.cpp in python:
|
||||
https://code.woboq.org/qt5/qtbase/src/widgets/widgets/qslider.cpp.html
|
||||
|
||||
This probably looks like tremendous overkill at first (and it may be!),
|
||||
since a it's possible to acheive a very reasonable "float slider" by
|
||||
scaling input float values to some internal integer range for the QSlider,
|
||||
and converting back to float when getting `value()`. However, one still
|
||||
runs into overflow limitations due to the internal integer model.
|
||||
|
||||
In order to circumvent them, one needs to reimplement more and more of
|
||||
the attributes from QSliderPrivate in order to have the slider behave
|
||||
like a native slider (with all of the proper signals and options).
|
||||
So that's what `_GenericSlider` is below.
|
||||
|
||||
`_GenericRangeSlider` is a variant that expects `value()` and
|
||||
`sliderPosition()` to be a sequence of scalars rather than a single
|
||||
scalar (with one handle per item), and it forms the basis of
|
||||
QRangeSlider.
|
||||
"""
|
||||
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from .qtcompat import QtGui
|
||||
from .qtcompat.QtCore import QEvent, QPoint, QPointF, QRect, Qt, Signal
|
||||
from .qtcompat.QtWidgets import (
|
||||
QApplication,
|
||||
QSlider,
|
||||
QStyle,
|
||||
QStyleOptionSlider,
|
||||
QStylePainter,
|
||||
)
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
SC_NONE = QStyle.SubControl.SC_None
|
||||
SC_HANDLE = QStyle.SubControl.SC_SliderHandle
|
||||
SC_GROOVE = QStyle.SubControl.SC_SliderGroove
|
||||
SC_TICKMARKS = QStyle.SubControl.SC_SliderTickmarks
|
||||
|
||||
CC_SLIDER = QStyle.ComplexControl.CC_Slider
|
||||
QOVERFLOW = 2 ** 31 - 1
|
||||
|
||||
|
||||
class _GenericSlider(QSlider, Generic[_T]):
|
||||
valueChanged = Signal(float)
|
||||
sliderMoved = Signal(float)
|
||||
rangeChanged = Signal(float, float)
|
||||
|
||||
MAX_DISPLAY = 5000
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
|
||||
self._minimum = 0.0
|
||||
self._maximum = 99.0
|
||||
self._pageStep = 10.0
|
||||
self._value: _T = 0.0 # type: ignore
|
||||
self._position: _T = 0.0
|
||||
self._singleStep = 1.0
|
||||
self._offsetAccumulated = 0.0
|
||||
self._blocktracking = False
|
||||
self._tickInterval = 0.0
|
||||
self._pressedControl = SC_NONE
|
||||
self._hoverControl = SC_NONE
|
||||
self._hoverRect = QRect()
|
||||
self._clickOffset = 0.0
|
||||
|
||||
# for keyboard nav
|
||||
self._repeatMultiplier = 1 # TODO
|
||||
# for wheel nav
|
||||
self._offset_accum = 0.0
|
||||
# fraction of total range to scroll when holding Ctrl while scrolling
|
||||
self._control_fraction = 0.04
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
self.setAttribute(Qt.WA_Hover)
|
||||
|
||||
# ############### QtOverrides #######################
|
||||
|
||||
def value(self) -> _T: # type: ignore
|
||||
return self._value
|
||||
|
||||
def setValue(self, value: _T) -> None:
|
||||
value = self._bound(value)
|
||||
if self._value == value and self._position == value:
|
||||
return
|
||||
self._value = value
|
||||
if self._position != value:
|
||||
self._setPosition(value)
|
||||
if self.isSliderDown():
|
||||
self.sliderMoved.emit(self.sliderPosition())
|
||||
self.sliderChange(self.SliderChange.SliderValueChange)
|
||||
self.valueChanged.emit(self.value())
|
||||
|
||||
def sliderPosition(self) -> _T: # type: ignore
|
||||
return self._position
|
||||
|
||||
def setSliderPosition(self, pos: _T) -> None:
|
||||
position = self._bound(pos)
|
||||
if position == self._position:
|
||||
return
|
||||
self._setPosition(position)
|
||||
self._doSliderMove()
|
||||
|
||||
def singleStep(self) -> float: # type: ignore
|
||||
return self._singleStep
|
||||
|
||||
def setSingleStep(self, step: float) -> None:
|
||||
if step != self._singleStep:
|
||||
self._setSteps(step, self._pageStep)
|
||||
|
||||
def pageStep(self) -> float: # type: ignore
|
||||
return self._pageStep
|
||||
|
||||
def setPageStep(self, step: float) -> None:
|
||||
if step != self._pageStep:
|
||||
self._setSteps(self._singleStep, step)
|
||||
|
||||
def minimum(self) -> float: # type: ignore
|
||||
return self._minimum
|
||||
|
||||
def setMinimum(self, min: float) -> None:
|
||||
self.setRange(min, max(self._maximum, min))
|
||||
|
||||
def maximum(self) -> float: # type: ignore
|
||||
return self._maximum
|
||||
|
||||
def setMaximum(self, max: float) -> None:
|
||||
self.setRange(min(self._minimum, max), max)
|
||||
|
||||
def setRange(self, min: float, max_: float) -> None:
|
||||
oldMin, self._minimum = self._minimum, float(min)
|
||||
oldMax, self._maximum = self._maximum, float(max(min, max_))
|
||||
|
||||
if oldMin != self._minimum or oldMax != self._maximum:
|
||||
self.sliderChange(self.SliderRangeChange)
|
||||
self.rangeChanged.emit(self._minimum, self._maximum)
|
||||
self.setValue(self._value) # re-bound
|
||||
|
||||
def tickInterval(self) -> float: # type: ignore
|
||||
return self._tickInterval
|
||||
|
||||
def setTickInterval(self, ts: float) -> None:
|
||||
self._tickInterval = max(0.0, ts)
|
||||
self.update()
|
||||
|
||||
def triggerAction(self, action: QSlider.SliderAction) -> None:
|
||||
self._blocktracking = True
|
||||
# other actions here
|
||||
# self.actionTriggered.emit(action) # FIXME: type not working for all Qt
|
||||
self._blocktracking = False
|
||||
self.setValue(self._position)
|
||||
|
||||
def initStyleOption(self, option: QStyleOptionSlider) -> None:
|
||||
option.initFrom(self)
|
||||
option.subControls = SC_NONE
|
||||
option.activeSubControls = SC_NONE
|
||||
option.orientation = self.orientation()
|
||||
option.tickPosition = self.tickPosition()
|
||||
option.upsideDown = (
|
||||
self.invertedAppearance() != (option.direction == Qt.RightToLeft)
|
||||
if self.orientation() == Qt.Horizontal
|
||||
else not self.invertedAppearance()
|
||||
)
|
||||
option.direction = Qt.LeftToRight # we use the upsideDown option instead
|
||||
# option.sliderValue = self._value # type: ignore
|
||||
# option.singleStep = self._singleStep # type: ignore
|
||||
if self.orientation() == Qt.Horizontal:
|
||||
option.state |= QStyle.State_Horizontal
|
||||
|
||||
# scale style option to integer space
|
||||
option.minimum = 0
|
||||
option.maximum = self.MAX_DISPLAY
|
||||
option.tickInterval = self._to_qinteger_space(self._tickInterval)
|
||||
option.pageStep = self._to_qinteger_space(self._pageStep)
|
||||
option.singleStep = self._to_qinteger_space(self._singleStep)
|
||||
self._fixStyleOption(option)
|
||||
|
||||
def event(self, ev: QEvent) -> bool:
|
||||
if ev.type() == QEvent.WindowActivate:
|
||||
self.update()
|
||||
elif ev.type() in (QEvent.HoverEnter, QEvent.HoverMove):
|
||||
self._updateHoverControl(_event_position(ev))
|
||||
elif ev.type() == QEvent.HoverLeave:
|
||||
self._hoverControl = SC_NONE
|
||||
lastHoverRect, self._hoverRect = self._hoverRect, QRect()
|
||||
self.update(lastHoverRect)
|
||||
return super().event(ev)
|
||||
|
||||
def mousePressEvent(self, ev: QtGui.QMouseEvent) -> None:
|
||||
if self._minimum == self._maximum or ev.buttons() ^ ev.button():
|
||||
ev.ignore()
|
||||
return
|
||||
|
||||
ev.accept()
|
||||
|
||||
pos = _event_position(ev)
|
||||
|
||||
# If the mouse button used is allowed to set the value
|
||||
if ev.button() in (Qt.LeftButton, Qt.MiddleButton):
|
||||
self._updatePressedControl(pos)
|
||||
if self._pressedControl == SC_HANDLE:
|
||||
opt = self._styleOption
|
||||
sr = self.style().subControlRect(CC_SLIDER, opt, SC_HANDLE, self)
|
||||
offset = sr.center() - sr.topLeft()
|
||||
new_pos = self._pixelPosToRangeValue(self._pick(pos - offset))
|
||||
self.setSliderPosition(new_pos)
|
||||
self.triggerAction(QSlider.SliderMove)
|
||||
self.setRepeatAction(QSlider.SliderNoAction)
|
||||
|
||||
self.update()
|
||||
# elif: deal with PageSetButtons
|
||||
else:
|
||||
ev.ignore()
|
||||
|
||||
if self._pressedControl != SC_NONE:
|
||||
self.setRepeatAction(QSlider.SliderNoAction)
|
||||
self._setClickOffset(pos)
|
||||
self.update()
|
||||
self.setSliderDown(True)
|
||||
|
||||
def mouseMoveEvent(self, ev: QtGui.QMouseEvent) -> None:
|
||||
# TODO: add pixelMetric(QStyle::PM_MaximumDragDistance, &opt, this);
|
||||
if self._pressedControl == SC_NONE:
|
||||
ev.ignore()
|
||||
return
|
||||
ev.accept()
|
||||
pos = self._pick(_event_position(ev))
|
||||
newPosition = self._pixelPosToRangeValue(pos - self._clickOffset)
|
||||
self.setSliderPosition(newPosition)
|
||||
|
||||
def mouseReleaseEvent(self, ev: QtGui.QMouseEvent) -> None:
|
||||
if self._pressedControl == SC_NONE or ev.buttons():
|
||||
ev.ignore()
|
||||
return
|
||||
|
||||
ev.accept()
|
||||
oldPressed = self._pressedControl
|
||||
self._pressedControl = SC_NONE
|
||||
self.setRepeatAction(QSlider.SliderNoAction)
|
||||
if oldPressed != SC_NONE:
|
||||
self.setSliderDown(False)
|
||||
self.update()
|
||||
|
||||
def wheelEvent(self, e: QtGui.QWheelEvent) -> None:
|
||||
|
||||
e.ignore()
|
||||
vertical = bool(e.angleDelta().y())
|
||||
delta = e.angleDelta().y() if vertical else e.angleDelta().x()
|
||||
if e.inverted():
|
||||
delta *= -1
|
||||
|
||||
orientation = Qt.Vertical if vertical else Qt.Horizontal
|
||||
if self._scrollByDelta(orientation, e.modifiers(), delta):
|
||||
e.accept()
|
||||
|
||||
def paintEvent(self, ev: QtGui.QPaintEvent) -> None:
|
||||
painter = QStylePainter(self)
|
||||
opt = self._styleOption
|
||||
|
||||
# draw groove and ticks
|
||||
opt.subControls = SC_GROOVE
|
||||
if opt.tickPosition != QSlider.NoTicks:
|
||||
opt.subControls |= SC_TICKMARKS
|
||||
painter.drawComplexControl(CC_SLIDER, opt)
|
||||
|
||||
self._draw_handle(painter, opt)
|
||||
|
||||
# ############### Implementation Details #######################
|
||||
|
||||
def _type_cast(self, val):
|
||||
return val
|
||||
|
||||
def _setPosition(self, val):
|
||||
self._position = val
|
||||
|
||||
def _bound(self, value: _T) -> _T:
|
||||
return self._type_cast(max(self._minimum, min(self._maximum, value)))
|
||||
|
||||
def _fixStyleOption(self, option):
|
||||
option.sliderPosition = self._to_qinteger_space(self._position - self._minimum)
|
||||
option.sliderValue = self._to_qinteger_space(self._value - self._minimum)
|
||||
|
||||
def _to_qinteger_space(self, val, _max=None):
|
||||
_max = _max or self.MAX_DISPLAY
|
||||
return int(min(QOVERFLOW, val / (self._maximum - self._minimum) * _max))
|
||||
|
||||
def _pick(self, pt: QPoint) -> int:
|
||||
return pt.x() if self.orientation() == Qt.Horizontal else pt.y()
|
||||
|
||||
def _setSteps(self, single: float, page: float):
|
||||
self._singleStep = single
|
||||
self._pageStep = page
|
||||
self.sliderChange(QSlider.SliderStepsChange)
|
||||
|
||||
def _doSliderMove(self):
|
||||
if not self.hasTracking():
|
||||
self.update()
|
||||
if self.isSliderDown():
|
||||
self.sliderMoved.emit(self.sliderPosition())
|
||||
if self.hasTracking() and not self._blocktracking:
|
||||
self.triggerAction(QSlider.SliderMove)
|
||||
|
||||
@property
|
||||
def _styleOption(self):
|
||||
opt = QStyleOptionSlider()
|
||||
self.initStyleOption(opt)
|
||||
return opt
|
||||
|
||||
def _updateHoverControl(self, pos: QPoint) -> bool:
|
||||
lastHoverRect = self._hoverRect
|
||||
lastHoverControl = self._hoverControl
|
||||
doesHover = self.testAttribute(Qt.WA_Hover)
|
||||
if lastHoverControl != self._newHoverControl(pos) and doesHover:
|
||||
self.update(lastHoverRect)
|
||||
self.update(self._hoverRect)
|
||||
return True
|
||||
return not doesHover
|
||||
|
||||
def _newHoverControl(self, pos: QPoint) -> QStyle.SubControl:
|
||||
opt = self._styleOption
|
||||
opt.subControls = QStyle.SubControl.SC_All
|
||||
|
||||
handleRect = self.style().subControlRect(CC_SLIDER, opt, SC_HANDLE, self)
|
||||
grooveRect = self.style().subControlRect(CC_SLIDER, opt, SC_GROOVE, self)
|
||||
tickmarksRect = self.style().subControlRect(CC_SLIDER, opt, SC_TICKMARKS, self)
|
||||
|
||||
if handleRect.contains(pos):
|
||||
self._hoverRect = handleRect
|
||||
self._hoverControl = SC_HANDLE
|
||||
elif grooveRect.contains(pos):
|
||||
self._hoverRect = grooveRect
|
||||
self._hoverControl = SC_GROOVE
|
||||
elif tickmarksRect.contains(pos):
|
||||
self._hoverRect = tickmarksRect
|
||||
self._hoverControl = SC_TICKMARKS
|
||||
else:
|
||||
self._hoverRect = QRect()
|
||||
self._hoverControl = SC_NONE
|
||||
return self._hoverControl
|
||||
|
||||
def _setClickOffset(self, pos: QPoint):
|
||||
hr = self.style().subControlRect(CC_SLIDER, self._styleOption, SC_HANDLE, self)
|
||||
self._clickOffset = self._pick(pos - hr.topLeft())
|
||||
|
||||
def _updatePressedControl(self, pos: QPoint):
|
||||
self._pressedControl = SC_HANDLE
|
||||
|
||||
def _draw_handle(self, painter, opt):
|
||||
opt.subControls = SC_HANDLE
|
||||
if self._pressedControl:
|
||||
opt.activeSubControls = self._pressedControl
|
||||
opt.state |= QStyle.State_Sunken
|
||||
else:
|
||||
opt.activeSubControls = self._hoverControl
|
||||
|
||||
painter.drawComplexControl(CC_SLIDER, opt)
|
||||
|
||||
# from QSliderPrivate.pixelPosToRangeValue
|
||||
def _pixelPosToRangeValue(self, pos: int) -> float:
|
||||
opt = self._styleOption
|
||||
|
||||
gr = self.style().subControlRect(CC_SLIDER, opt, SC_GROOVE, self)
|
||||
sr = self.style().subControlRect(CC_SLIDER, opt, SC_HANDLE, self)
|
||||
|
||||
if self.orientation() == Qt.Horizontal:
|
||||
sliderLength = sr.width()
|
||||
sliderMin = gr.x()
|
||||
sliderMax = gr.right() - sliderLength + 1
|
||||
else:
|
||||
sliderLength = sr.height()
|
||||
sliderMin = gr.y()
|
||||
sliderMax = gr.bottom() - sliderLength + 1
|
||||
return _sliderValueFromPosition(
|
||||
self._minimum,
|
||||
self._maximum,
|
||||
pos - sliderMin,
|
||||
sliderMax - sliderMin,
|
||||
opt.upsideDown,
|
||||
)
|
||||
|
||||
def _scrollByDelta(self, orientation, modifiers, delta: int) -> bool:
|
||||
steps_to_scroll = 0.0
|
||||
pg_step = self._pageStep
|
||||
|
||||
# in Qt scrolling to the right gives negative values.
|
||||
if orientation == Qt.Horizontal:
|
||||
delta *= -1
|
||||
offset = delta / 120
|
||||
if modifiers & Qt.ShiftModifier:
|
||||
# Scroll one page regardless of delta:
|
||||
steps_to_scroll = max(-pg_step, min(pg_step, offset * pg_step))
|
||||
self._offset_accum = 0
|
||||
elif modifiers & Qt.ControlModifier:
|
||||
_range = self._maximum - self._minimum
|
||||
steps_to_scroll = offset * _range * self._control_fraction
|
||||
self._offset_accum = 0
|
||||
else:
|
||||
# Calculate how many lines to scroll. Depending on what delta is (and
|
||||
# offset), we might end up with a fraction (e.g. scroll 1.3 lines). We can
|
||||
# only scroll whole lines, so we keep the reminder until next event.
|
||||
wheel_scroll_lines = QApplication.wheelScrollLines()
|
||||
steps_to_scrollF = wheel_scroll_lines * offset * self._effectiveSingleStep()
|
||||
# Check if wheel changed direction since last event:
|
||||
if self._offset_accum != 0 and (offset / self._offset_accum) < 0:
|
||||
self._offset_accum = 0
|
||||
|
||||
self._offset_accum += steps_to_scrollF
|
||||
|
||||
# Don't scroll more than one page in any case:
|
||||
steps_to_scroll = max(-pg_step, min(pg_step, self._offset_accum))
|
||||
self._offset_accum -= self._offset_accum
|
||||
|
||||
if steps_to_scroll == 0:
|
||||
# We moved less than a line, but might still have accumulated partial
|
||||
# scroll, unless we already are at one of the ends.
|
||||
effective_offset = self._offset_accum
|
||||
if self.invertedControls():
|
||||
effective_offset *= -1
|
||||
if self._has_scroll_space_left(effective_offset):
|
||||
return True
|
||||
self._offset_accum = 0
|
||||
return False
|
||||
|
||||
if self.invertedControls():
|
||||
steps_to_scroll *= -1
|
||||
|
||||
prevValue = self._value
|
||||
self._execute_scroll(steps_to_scroll, modifiers)
|
||||
if prevValue == self._value:
|
||||
self._offset_accum = 0
|
||||
return False
|
||||
return True
|
||||
|
||||
def _has_scroll_space_left(self, offset):
|
||||
return (offset > 0 and self._value < self._maximum) or (
|
||||
offset < 0 and self._value < self._minimum
|
||||
)
|
||||
|
||||
def _execute_scroll(self, steps_to_scroll, modifiers):
|
||||
self._setPosition(self._bound(self._overflowSafeAdd(steps_to_scroll)))
|
||||
self.triggerAction(QSlider.SliderMove)
|
||||
|
||||
def _effectiveSingleStep(self) -> float:
|
||||
return self._singleStep * self._repeatMultiplier
|
||||
|
||||
def _overflowSafeAdd(self, add: float) -> float:
|
||||
newValue = self._value + add
|
||||
if add > 0 and newValue < self._value:
|
||||
newValue = self._maximum
|
||||
elif add < 0 and newValue > self._value:
|
||||
newValue = self._minimum
|
||||
return newValue
|
||||
|
||||
# def keyPressEvent(self, ev: QtGui.QKeyEvent) -> None:
|
||||
# return # TODO
|
||||
|
||||
|
||||
def _event_position(ev: QEvent) -> QPoint:
|
||||
# safe for Qt6, Qt5, and hoverEvent
|
||||
evp = getattr(ev, "position", getattr(ev, "pos", None))
|
||||
pos = evp() if evp else QPoint()
|
||||
if isinstance(pos, QPointF):
|
||||
pos = pos.toPoint()
|
||||
return pos
|
||||
|
||||
|
||||
def _sliderValueFromPosition(
|
||||
min: float, max: float, position: int, span: int, upsideDown: bool = False
|
||||
) -> float:
|
||||
"""Converts the given pixel `position` to a value.
|
||||
|
||||
0 maps to the `min` parameter, `span` maps to `max` and other values are
|
||||
distributed evenly in-between.
|
||||
|
||||
By default, this function assumes that the maximum value is on the right
|
||||
for horizontal items and on the bottom for vertical items. Set the
|
||||
`upsideDown` parameter to True to reverse this behavior.
|
||||
"""
|
||||
|
||||
if span <= 0 or position <= 0:
|
||||
return max if upsideDown else min
|
||||
if position >= span:
|
||||
return min if upsideDown else max
|
||||
range = max - min
|
||||
tmp = min + position * range / span
|
||||
return max - tmp if upsideDown else tmp + min
|
@@ -1,42 +0,0 @@
|
||||
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):
|
||||
return self._post_get_hook(super().value())
|
||||
|
||||
def setValue(self, value) -> None:
|
||||
super().setValue(self._pre_set_hook(value))
|
||||
|
||||
def minimum(self):
|
||||
return self._post_get_hook(super().minimum())
|
||||
|
||||
def setMinimum(self, minimum):
|
||||
super().setMinimum(self._pre_set_hook(minimum))
|
||||
|
||||
def maximum(self):
|
||||
return self._post_get_hook(super().maximum())
|
||||
|
||||
def setMaximum(self, maximum):
|
||||
super().setMaximum(self._pre_set_hook(maximum))
|
||||
|
||||
def singleStep(self):
|
||||
return self._post_get_hook(super().singleStep())
|
||||
|
||||
def setSingleStep(self, step):
|
||||
super().setSingleStep(self._pre_set_hook(step))
|
||||
|
||||
def pageStep(self):
|
||||
return self._post_get_hook(super().pageStep())
|
||||
|
||||
def setPageStep(self, step) -> 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))
|
@@ -1,8 +1,7 @@
|
||||
from enum import IntEnum
|
||||
from functools import partial
|
||||
|
||||
from ._float_slider import QDoubleRangeSlider, QDoubleSlider
|
||||
from ._qrangeslider import QRangeSlider
|
||||
from ._sliders import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
|
||||
from .qtcompat.QtCore import QPoint, QSize, Qt, Signal
|
||||
from .qtcompat.QtGui import QFontMetrics, QValidator
|
||||
from .qtcompat.QtWidgets import (
|
||||
@@ -34,7 +33,7 @@ class EdgeLabelMode(IntEnum):
|
||||
|
||||
|
||||
class SliderProxy:
|
||||
_slider: QAbstractSlider
|
||||
_slider: QSlider
|
||||
|
||||
def value(self):
|
||||
return self._slider.value()
|
||||
@@ -42,6 +41,12 @@ class SliderProxy:
|
||||
def setValue(self, value) -> None:
|
||||
self._slider.setValue(value)
|
||||
|
||||
def sliderPosition(self):
|
||||
return self._slider.sliderPosition()
|
||||
|
||||
def setSliderPosition(self, pos) -> None:
|
||||
self._slider.setSliderPosition(pos)
|
||||
|
||||
def minimum(self):
|
||||
return self._slider.minimum()
|
||||
|
||||
@@ -69,6 +74,18 @@ class SliderProxy:
|
||||
def setRange(self, min, max) -> None:
|
||||
self._slider.setRange(min, max)
|
||||
|
||||
def tickInterval(self):
|
||||
return self._slider.tickInterval()
|
||||
|
||||
def setTickInterval(self, interval) -> None:
|
||||
self._slider.setTickInterval(interval)
|
||||
|
||||
def tickPosition(self):
|
||||
return self._slider.tickPosition()
|
||||
|
||||
def setTickPosition(self, pos) -> None:
|
||||
self._slider.setTickPosition(pos)
|
||||
|
||||
|
||||
def _handle_overloaded_slider_sig(args, kwargs):
|
||||
parent = None
|
||||
@@ -148,10 +165,9 @@ class QLabeledDoubleSlider(QLabeledSlider):
|
||||
self.setDecimals(2)
|
||||
|
||||
def decimals(self) -> int:
|
||||
return self._slider.decimals()
|
||||
return self._label.decimals()
|
||||
|
||||
def setDecimals(self, prec: int):
|
||||
self._slider.setDecimals(prec)
|
||||
self._label.setDecimals(prec)
|
||||
|
||||
|
||||
@@ -236,7 +252,8 @@ class QLabeledRangeSlider(SliderProxy, QAbstractSlider):
|
||||
labels_above = self._handle_label_position == LabelPosition.LabelsAbove
|
||||
|
||||
last_edge = None
|
||||
for label, rect in zip(self._handle_labels, self._slider._handleRects()):
|
||||
for i, label in enumerate(self._handle_labels):
|
||||
rect = self._slider._handleRect(i)
|
||||
dx = -label.width() / 2
|
||||
dy = -label.height() / 2
|
||||
if labels_above:
|
||||
@@ -260,6 +277,7 @@ class QLabeledRangeSlider(SliderProxy, QAbstractSlider):
|
||||
label.move(pos)
|
||||
last_edge = pos
|
||||
label.clearFocus()
|
||||
self.update()
|
||||
|
||||
def _min_label_edited(self, val):
|
||||
if self._edge_label_mode == EdgeLabelMode.LabelIsRange:
|
||||
@@ -290,7 +308,7 @@ class QLabeledRangeSlider(SliderProxy, QAbstractSlider):
|
||||
lbl.deleteLater()
|
||||
self._handle_labels.clear()
|
||||
for n, val in enumerate(self._slider.value()):
|
||||
_cb = partial(self._slider._setSliderPositionAt, n)
|
||||
_cb = partial(self._slider.setSliderPosition, index=n)
|
||||
s = SliderLabel(self._slider, parent=self, connect=_cb)
|
||||
s.setValue(val)
|
||||
self._handle_labels.append(s)
|
||||
@@ -300,7 +318,8 @@ class QLabeledRangeSlider(SliderProxy, QAbstractSlider):
|
||||
self._reposition_labels()
|
||||
|
||||
def _on_range_changed(self, min, max):
|
||||
self._slider.setRange(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:
|
||||
@@ -372,10 +391,9 @@ class QLabeledDoubleRangeSlider(QLabeledRangeSlider):
|
||||
self.setDecimals(2)
|
||||
|
||||
def decimals(self) -> int:
|
||||
return self._slider.decimals()
|
||||
return self._min_label.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:
|
||||
@@ -406,7 +424,7 @@ class SliderLabel(QDoubleSpinBox):
|
||||
super().setDecimals(prec)
|
||||
self._update_size()
|
||||
|
||||
def _update_size(self):
|
||||
def _update_size(self, *_):
|
||||
# fontmetrics to measure the width of text
|
||||
fm = QFontMetrics(self.font())
|
||||
h = self.sizeHint().height()
|
||||
|
@@ -1,585 +0,0 @@
|
||||
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 (
|
||||
Property,
|
||||
QEvent,
|
||||
QPoint,
|
||||
QPointF,
|
||||
QRect,
|
||||
QRectF,
|
||||
Qt,
|
||||
Signal,
|
||||
)
|
||||
from .qtcompat.QtWidgets import (
|
||||
QApplication,
|
||||
QSlider,
|
||||
QStyle,
|
||||
QStyleOptionSlider,
|
||||
QStylePainter,
|
||||
)
|
||||
|
||||
ControlType = Tuple[str, int]
|
||||
|
||||
|
||||
class QRangeSlider(_HookedSlider, QSlider):
|
||||
"""MultiHandle Range Slider widget.
|
||||
|
||||
Same API as QSlider, but `value`, `setValue`, `sliderPosition`, and
|
||||
`setSliderPosition` are all sequences of integers.
|
||||
|
||||
The `valueChanged` and `sliderMoved` signals also both emit a tuple of
|
||||
integers.
|
||||
"""
|
||||
|
||||
# Emitted when the slider value has changed, with the new slider values
|
||||
valueChanged = Signal(tuple)
|
||||
|
||||
# Emitted when sliderDown is true and the slider moves
|
||||
# This usually happens when the user is dragging the slider
|
||||
# The value is the positions of *all* handles.
|
||||
sliderMoved = Signal(tuple)
|
||||
|
||||
_NULL_CTRL = ("None", -1)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# list of values
|
||||
self._value: List[int] = [20, 80]
|
||||
|
||||
# list of current positions of each handle. same length as _value
|
||||
# If tracking is enabled (the default) this will be identical to _value
|
||||
self._position: List[int] = [20, 80]
|
||||
self._pressedControl: ControlType = self._NULL_CTRL
|
||||
self._hoverControl: ControlType = self._NULL_CTRL
|
||||
|
||||
# whether bar length is constant when dragging the bar
|
||||
# if False, the bar can shorten when dragged beyond min/max
|
||||
self._bar_is_rigid = True
|
||||
# whether clicking on the bar moves all handles, or just the nearest handle
|
||||
self._bar_moves_all = True
|
||||
self._should_draw_bar = True
|
||||
|
||||
# for keyboard nav
|
||||
self._repeatMultiplier = 1 # TODO
|
||||
# for wheel nav
|
||||
self._offset_accum = 0
|
||||
# fraction of total range to scroll when holding Ctrl while scrolling
|
||||
self._control_fraction = 0.04
|
||||
|
||||
# color
|
||||
self._style = RangeSliderStyle()
|
||||
self.setStyleSheet("")
|
||||
update_styles_from_stylesheet(self)
|
||||
|
||||
# ############### Public API #######################
|
||||
|
||||
def setStyleSheet(self, styleSheet: str) -> None:
|
||||
# sub-page styles render on top of the lower sliders and don't work here.
|
||||
override = f"""
|
||||
\n{type(self).__name__}::sub-page:horizontal {{background: none}}
|
||||
\n{type(self).__name__}::sub-page:vertical {{background: none}}
|
||||
"""
|
||||
return super().setStyleSheet(styleSheet + override)
|
||||
|
||||
def value(self) -> Tuple[int, ...]:
|
||||
"""Get current value of the widget as a tuple of integers."""
|
||||
return tuple(self._value)
|
||||
|
||||
def setValue(self, val: Sequence[int]) -> None:
|
||||
"""Set current value of the widget with a sequence of integers.
|
||||
|
||||
The number of handles will be equal to the length of the sequence
|
||||
"""
|
||||
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
|
||||
if self.isSliderDown():
|
||||
self.sliderMoved.emit(tuple(self._position))
|
||||
|
||||
self.sliderChange(QSlider.SliderValueChange)
|
||||
self.valueChanged.emit(self.value())
|
||||
|
||||
def sliderPosition(self) -> Tuple[int, ...]:
|
||||
"""Get current value of the widget as a tuple of integers.
|
||||
|
||||
If tracking is enabled (the default) this will be identical to value().
|
||||
"""
|
||||
return tuple(self._position)
|
||||
|
||||
def setSliderPosition(self, val: Sequence[int]) -> None:
|
||||
"""Set current position of the handles with a sequence of integers.
|
||||
|
||||
The sequence must have the same length as `value()`.
|
||||
"""
|
||||
if len(val) != len(self.value()):
|
||||
raise ValueError(
|
||||
f"'sliderPosition' must have length of 'value()' ({len(self.value())})"
|
||||
)
|
||||
|
||||
for i, v in enumerate(val):
|
||||
self._setSliderPositionAt(i, v, _update=False)
|
||||
self._updateSliderMove()
|
||||
|
||||
def barIsRigid(self) -> bool:
|
||||
"""Whether bar length is constant when dragging the bar.
|
||||
|
||||
If False, the bar can shorten when dragged beyond min/max. Default is True.
|
||||
"""
|
||||
return self._bar_is_rigid
|
||||
|
||||
def setBarIsRigid(self, val: bool = True) -> None:
|
||||
"""Whether bar length is constant when dragging the bar.
|
||||
|
||||
If False, the bar can shorten when dragged beyond min/max. Default is True.
|
||||
"""
|
||||
self._bar_is_rigid = bool(val)
|
||||
|
||||
def barMovesAllHandles(self) -> bool:
|
||||
"""Whether clicking on the bar moves all handles (default), or just the nearest."""
|
||||
return self._bar_moves_all
|
||||
|
||||
def setBarMovesAllHandles(self, val: bool = True) -> None:
|
||||
"""Whether clicking on the bar moves all handles (default), or just the nearest."""
|
||||
self._bar_moves_all = bool(val)
|
||||
|
||||
def barIsVisible(self) -> bool:
|
||||
"""Whether to show the bar between the first and last handle."""
|
||||
return self._should_draw_bar
|
||||
|
||||
def setBarVisible(self, val: bool = True) -> None:
|
||||
"""Whether to show the bar between the first and last handle."""
|
||||
self._should_draw_bar = bool(val)
|
||||
|
||||
def hideBar(self) -> None:
|
||||
self.setBarVisible(False)
|
||||
|
||||
def showBar(self) -> None:
|
||||
self.setBarVisible(True)
|
||||
|
||||
# ############### Implementation Details #######################
|
||||
|
||||
def _setSliderPositionAt(self, index: int, pos: int, _update=True) -> None:
|
||||
pos = self._min_max_bound(pos)
|
||||
# prevent sliders from moving beyond their neighbors
|
||||
pos = self._neighbor_bound(pos, index, self._position)
|
||||
if pos == self._position[index]:
|
||||
return
|
||||
|
||||
self._position[index] = pos
|
||||
if _update:
|
||||
self._updateSliderMove()
|
||||
|
||||
def _updateSliderMove(self):
|
||||
if not self.hasTracking():
|
||||
self.update()
|
||||
if self.isSliderDown():
|
||||
self.sliderMoved.emit(tuple(self._position))
|
||||
if self.hasTracking():
|
||||
self.triggerAction(QSlider.SliderMove)
|
||||
|
||||
def _offsetAllPositions(self, offset: int, ref=None) -> None:
|
||||
if ref is None:
|
||||
ref = self._position
|
||||
if self._bar_is_rigid:
|
||||
# NOTE: This assumes monotonically increasing slider positions
|
||||
if offset > 0 and ref[-1] + offset > self.maximum():
|
||||
offset = self.maximum() - ref[-1]
|
||||
elif ref[0] + offset < self.minimum():
|
||||
offset = self.minimum() - ref[0]
|
||||
self.setSliderPosition([i + offset for i in ref])
|
||||
|
||||
def _spreadAllPositions(self, shrink=False, gain=1.1, ref=None) -> None:
|
||||
if ref is None:
|
||||
ref = self._position
|
||||
# if self._bar_is_rigid: # TODO
|
||||
|
||||
if shrink:
|
||||
gain = 1 / gain
|
||||
center = abs(ref[-1] + ref[0]) / 2
|
||||
self.setSliderPosition([((i - center) * gain) + center for i in ref])
|
||||
|
||||
def _getStyleOption(self) -> QStyleOptionSlider:
|
||||
opt = QStyleOptionSlider()
|
||||
self.initStyleOption(opt)
|
||||
opt.sliderValue = 0
|
||||
opt.sliderPosition = 0
|
||||
return opt
|
||||
|
||||
def _getBarColor(self):
|
||||
return self._style.brush(self._getStyleOption())
|
||||
|
||||
def _setBarColor(self, color):
|
||||
self._style.brush_active = color
|
||||
|
||||
barColor = Property(QtGui.QBrush, _getBarColor, _setBarColor)
|
||||
|
||||
def _drawBar(self, painter: QStylePainter, opt: QStyleOptionSlider):
|
||||
|
||||
brush = self._style.brush(opt)
|
||||
|
||||
r_bar = self._barRect(opt)
|
||||
if isinstance(brush, QtGui.QGradient):
|
||||
brush.setStart(r_bar.topLeft())
|
||||
brush.setFinalStop(r_bar.bottomRight())
|
||||
|
||||
painter.setPen(self._style.pen(opt))
|
||||
painter.setBrush(brush)
|
||||
painter.drawRect(r_bar)
|
||||
|
||||
def paintEvent(self, a0: QtGui.QPaintEvent) -> None:
|
||||
"""Paint the slider."""
|
||||
# initialize painter and options
|
||||
painter = QStylePainter(self)
|
||||
opt = self._getStyleOption()
|
||||
|
||||
# draw groove and ticks
|
||||
opt.subControls = QStyle.SC_SliderGroove | QStyle.SC_SliderTickmarks
|
||||
painter.drawComplexControl(QStyle.CC_Slider, opt)
|
||||
|
||||
if self._should_draw_bar:
|
||||
self._drawBar(painter, opt)
|
||||
|
||||
# draw handles
|
||||
opt.subControls = QStyle.SC_SliderHandle
|
||||
hidx = -1
|
||||
pidx = -1
|
||||
if self._pressedControl[0] == "handle":
|
||||
pidx = self._pressedControl[1]
|
||||
elif self._hoverControl[0] == "handle":
|
||||
hidx = self._hoverControl[1]
|
||||
for idx, pos in enumerate(self._position):
|
||||
opt.sliderPosition = self._pre_set_hook(pos)
|
||||
|
||||
if idx == pidx: # make pressed handles appear sunken
|
||||
opt.state |= QStyle.State_Sunken
|
||||
else:
|
||||
opt.state = opt.state & ~QStyle.State_Sunken
|
||||
if idx == hidx:
|
||||
opt.activeSubControls = QStyle.SC_SliderHandle
|
||||
else:
|
||||
opt.activeSubControls = QStyle.SC_None
|
||||
painter.drawComplexControl(QStyle.CC_Slider, opt)
|
||||
|
||||
def event(self, ev: QEvent) -> bool:
|
||||
if ev.type() == QEvent.WindowActivate:
|
||||
self.update()
|
||||
if ev.type() == QEvent.StyleChange:
|
||||
update_styles_from_stylesheet(self)
|
||||
if ev.type() in (QEvent.HoverEnter, QEvent.HoverLeave, QEvent.HoverMove):
|
||||
old_hover = self._hoverControl
|
||||
self._hoverControl = self._getControlAtPos(ev.pos())
|
||||
if self._hoverControl != old_hover:
|
||||
self.update() # TODO: restrict to the rect of old_hover
|
||||
return super().event(ev)
|
||||
|
||||
def mousePressEvent(self, ev: QtGui.QMouseEvent) -> None:
|
||||
if self.minimum() == self.maximum() or ev.buttons() ^ ev.button():
|
||||
ev.ignore()
|
||||
return
|
||||
|
||||
ev.accept()
|
||||
# FIXME: why not working on other styles?
|
||||
# set_buttons = self.style().styleHint(QStyle.SH_Slider_AbsoluteSetButtons)
|
||||
set_buttons = Qt.LeftButton | Qt.MiddleButton
|
||||
|
||||
# If the mouse button used is allowed to set the value
|
||||
if ev.buttons() & set_buttons == ev.button():
|
||||
opt = self._getStyleOption()
|
||||
|
||||
self._pressedControl = self._getControlAtPos(ev.pos(), opt, True)
|
||||
|
||||
if self._pressedControl[0] == "handle":
|
||||
offset = self._handle_offset(opt)
|
||||
new_pos = self._pixelPosToRangeValue(self._pick(ev.pos() - offset))
|
||||
self._setSliderPositionAt(self._pressedControl[1], new_pos)
|
||||
self.triggerAction(QSlider.SliderMove)
|
||||
self.setRepeatAction(QSlider.SliderNoAction)
|
||||
self.update()
|
||||
|
||||
if self._pressedControl[0] == "handle":
|
||||
self.setRepeatAction(QSlider.SliderNoAction) # why again?
|
||||
sr = self._handleRects(opt, self._pressedControl[1])
|
||||
self._clickOffset = self._pick(ev.pos() - sr.topLeft())
|
||||
self.update()
|
||||
self.setSliderDown(True)
|
||||
elif self._pressedControl[0] == "bar":
|
||||
self.setRepeatAction(QSlider.SliderNoAction) # why again?
|
||||
self._clickOffset = self._pixelPosToRangeValue(self._pick(ev.pos()))
|
||||
self._sldPosAtPress = tuple(self._position)
|
||||
self.update()
|
||||
self.setSliderDown(True)
|
||||
|
||||
def mouseMoveEvent(self, ev: QtGui.QMouseEvent) -> None:
|
||||
# TODO: add pixelMetric(QStyle::PM_MaximumDragDistance, &opt, this);
|
||||
if self._pressedControl[0] == "handle":
|
||||
ev.accept()
|
||||
new = self._pixelPosToRangeValue(self._pick(ev.pos()) - self._clickOffset)
|
||||
self._setSliderPositionAt(self._pressedControl[1], new)
|
||||
elif self._pressedControl[0] == "bar":
|
||||
ev.accept()
|
||||
|
||||
delta = self._clickOffset - self._pixelPosToRangeValue(self._pick(ev.pos()))
|
||||
self._offsetAllPositions(-delta, self._sldPosAtPress)
|
||||
else:
|
||||
ev.ignore()
|
||||
return
|
||||
|
||||
def mouseReleaseEvent(self, ev: QtGui.QMouseEvent) -> None:
|
||||
if self._pressedControl[0] == "None" or ev.buttons():
|
||||
ev.ignore()
|
||||
return
|
||||
ev.accept()
|
||||
old_pressed = self._pressedControl
|
||||
self._pressedControl = self._NULL_CTRL
|
||||
self.setRepeatAction(QSlider.SliderNoAction)
|
||||
if old_pressed[0] in ("handle", "bar"):
|
||||
self.setSliderDown(False)
|
||||
self.update() # TODO: restrict to the rect of old_pressed
|
||||
|
||||
def triggerAction(self, action: QSlider.SliderAction) -> None:
|
||||
super().triggerAction(action) # TODO: probably need to override.
|
||||
self.setValue(self._position)
|
||||
|
||||
def setRange(self, min: int, max: int) -> None:
|
||||
super().setRange(min, max)
|
||||
self.setValue(self._value) # re-bound
|
||||
|
||||
def _handleRects(
|
||||
self, opt: QStyleOptionSlider = None, handle_index: int = None
|
||||
) -> QRect:
|
||||
"""Return the QRect for all handles."""
|
||||
if opt is None:
|
||||
opt = self._getStyleOption()
|
||||
|
||||
style = self.style().proxy()
|
||||
|
||||
if handle_index is not None: # get specific handle rect
|
||||
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 = self._pre_set_hook(p)
|
||||
r = style.subControlRect(
|
||||
QStyle.CC_Slider, opt, QStyle.SC_SliderHandle, self
|
||||
)
|
||||
rects.append(r)
|
||||
return rects
|
||||
|
||||
def _grooveRect(self, opt: QStyleOptionSlider) -> QRect:
|
||||
"""Return the QRect for the slider groove."""
|
||||
style = self.style().proxy()
|
||||
return style.subControlRect(QStyle.CC_Slider, opt, QStyle.SC_SliderGroove, self)
|
||||
|
||||
def _barRect(self, opt: QStyleOptionSlider, r_groove: QRect = None) -> QRect:
|
||||
"""Return the QRect for the bar between the outer handles."""
|
||||
if r_groove is None:
|
||||
r_groove = self._grooveRect(opt)
|
||||
r_bar = QRectF(r_groove)
|
||||
hdl_low, *_, hdl_high = self._handleRects(opt)
|
||||
|
||||
thickness = self._style.thickness(opt)
|
||||
offset = self._style.offset(opt)
|
||||
|
||||
if opt.orientation == Qt.Horizontal:
|
||||
r_bar.setTop(r_bar.center().y() - thickness / 2 + offset)
|
||||
r_bar.setHeight(thickness)
|
||||
r_bar.setLeft(hdl_low.center().x())
|
||||
r_bar.setRight(hdl_high.center().x())
|
||||
else:
|
||||
r_bar.setLeft(r_bar.center().x() - thickness / 2 + offset)
|
||||
r_bar.setWidth(thickness)
|
||||
r_bar.setBottom(hdl_low.center().y())
|
||||
r_bar.setTop(hdl_high.center().y())
|
||||
|
||||
return r_bar
|
||||
|
||||
def _getControlAtPos(
|
||||
self, pos: QPoint, opt: QStyleOptionSlider = None, closest_handle=False
|
||||
) -> ControlType:
|
||||
"""Update self._pressedControl based on ev.pos()."""
|
||||
if not opt:
|
||||
opt = self._getStyleOption()
|
||||
|
||||
event_position = self._pick(pos)
|
||||
bar_idx = 0
|
||||
hdl_idx = 0
|
||||
dist = float("inf")
|
||||
|
||||
if isinstance(pos, QPointF):
|
||||
pos = QPoint(pos.x(), pos.y())
|
||||
# TODO: this should be reversed, to prefer higher value handles
|
||||
for i, hdl in enumerate(self._handleRects(opt)):
|
||||
if hdl.contains(pos):
|
||||
return ("handle", i) # TODO: use enum for 'handle'
|
||||
hdl_center = self._pick(hdl.center())
|
||||
abs_dist = abs(event_position - hdl_center)
|
||||
if abs_dist < dist:
|
||||
dist = abs_dist
|
||||
hdl_idx = i
|
||||
if event_position > hdl_center:
|
||||
bar_idx += 1
|
||||
else:
|
||||
if closest_handle:
|
||||
if bar_idx == 0:
|
||||
# the click was below the minimum slider
|
||||
return ("handle", 0)
|
||||
elif bar_idx == len(self._position):
|
||||
# the click was above the maximum slider
|
||||
return ("handle", len(self._position) - 1)
|
||||
if self._bar_moves_all:
|
||||
# the click was in an internal segment
|
||||
return ("bar", bar_idx)
|
||||
elif closest_handle:
|
||||
return ("handle", hdl_idx)
|
||||
|
||||
return self._NULL_CTRL
|
||||
|
||||
def _handle_offset(self, opt: QStyleOptionSlider) -> QPoint:
|
||||
# to take half of the slider off for the setSliderPosition call we use the
|
||||
# center - topLeft
|
||||
handle_rect = self._handleRects(opt, 0)
|
||||
return handle_rect.center() - handle_rect.topLeft()
|
||||
|
||||
# from QSliderPrivate::pixelPosToRangeValue
|
||||
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:
|
||||
sliderLength = handle_rect.width()
|
||||
sliderMin = groove_rect.x()
|
||||
sliderMax = groove_rect.right() - sliderLength + 1
|
||||
else:
|
||||
sliderLength = handle_rect.height()
|
||||
sliderMin = groove_rect.y()
|
||||
sliderMax = groove_rect.bottom() - sliderLength + 1
|
||||
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()
|
||||
|
||||
def _min_max_bound(self, val: int) -> int:
|
||||
return _bound(self.minimum(), self.maximum(), val)
|
||||
|
||||
def _neighbor_bound(self, val: int, index: int, _lst: List[int]) -> int:
|
||||
# make sure we don't go lower than any preceding index:
|
||||
min_dist = self._post_get_hook(self.singleStep())
|
||||
if index > 0:
|
||||
val = max(_lst[index - 1] + min_dist, val)
|
||||
# make sure we don't go higher than any following index:
|
||||
if index < (len(_lst) - 1):
|
||||
val = min(_lst[index + 1] - min_dist, val)
|
||||
return val
|
||||
|
||||
def wheelEvent(self, e: QtGui.QWheelEvent) -> None:
|
||||
e.ignore()
|
||||
vertical = bool(e.angleDelta().y())
|
||||
delta = e.angleDelta().y() if vertical else e.angleDelta().x()
|
||||
if e.inverted():
|
||||
delta *= -1
|
||||
|
||||
orientation = Qt.Vertical if vertical else Qt.Horizontal
|
||||
if self._scrollByDelta(orientation, e.modifiers(), delta):
|
||||
e.accept()
|
||||
|
||||
def _scrollByDelta(self, orientation, modifiers, delta: int) -> bool:
|
||||
steps_to_scroll = 0
|
||||
pg_step = self.pageStep()
|
||||
|
||||
# in Qt scrolling to the right gives negative values.
|
||||
if orientation == Qt.Horizontal:
|
||||
delta *= -1
|
||||
offset = delta / 120
|
||||
if modifiers & Qt.ShiftModifier:
|
||||
# Scroll one page regardless of delta:
|
||||
steps_to_scroll = _bound(-pg_step, pg_step, int(offset * pg_step))
|
||||
self._offset_accum = 0
|
||||
elif modifiers & Qt.ControlModifier:
|
||||
# Scroll one page regardless of delta:
|
||||
_range = self._pre_set_hook(self.maximum()) - self._pre_set_hook(
|
||||
self.minimum()
|
||||
)
|
||||
steps_to_scroll = offset * _range * self._control_fraction
|
||||
self._offset_accum = 0
|
||||
else:
|
||||
# Calculate how many lines to scroll. Depending on what delta is (and
|
||||
# offset), we might end up with a fraction (e.g. scroll 1.3 lines). We can
|
||||
# only scroll whole lines, so we keep the reminder until next event.
|
||||
wheel_scroll_lines = QApplication.wheelScrollLines()
|
||||
steps_to_scrollF = wheel_scroll_lines * offset * self._effectiveSingleStep()
|
||||
|
||||
# Check if wheel changed direction since last event:
|
||||
if self._offset_accum != 0 and (offset / self._offset_accum) < 0:
|
||||
self._offset_accum = 0
|
||||
|
||||
self._offset_accum += steps_to_scrollF
|
||||
|
||||
# Don't scroll more than one page in any case:
|
||||
steps_to_scroll = _bound(-pg_step, pg_step, int(self._offset_accum))
|
||||
|
||||
self._offset_accum -= int(self._offset_accum)
|
||||
|
||||
if steps_to_scroll == 0:
|
||||
# We moved less than a line, but might still have accumulated partial
|
||||
# scroll, unless we already are at one of the ends.
|
||||
effective_offset = self._offset_accum
|
||||
if self.invertedControls():
|
||||
effective_offset *= -1
|
||||
if effective_offset > 0 and max(self._value) < self.maximum():
|
||||
return True
|
||||
if effective_offset < 0 and min(self._value) < self.minimum():
|
||||
return True
|
||||
self._offset_accum = 0
|
||||
return False
|
||||
|
||||
if self.invertedControls():
|
||||
steps_to_scroll *= -1
|
||||
|
||||
_prev_value = self.value()
|
||||
|
||||
if modifiers & Qt.AltModifier:
|
||||
self._spreadAllPositions(shrink=steps_to_scroll < 0)
|
||||
else:
|
||||
self._offsetAllPositions(self._post_get_hook(steps_to_scroll))
|
||||
self.triggerAction(QSlider.SliderMove)
|
||||
|
||||
if _prev_value == self.value():
|
||||
self._offset_accum = 0
|
||||
return False
|
||||
return True
|
||||
|
||||
def _effectiveSingleStep(self) -> int:
|
||||
return self.singleStep() * self._repeatMultiplier
|
||||
|
||||
def keyPressEvent(self, ev: QtGui.QKeyEvent) -> None:
|
||||
return # TODO
|
||||
|
||||
|
||||
def _bound(min_: int, max_: int, value: int) -> int:
|
||||
"""Return value bounded by min_ and max_."""
|
||||
return max(min_, min(max_, value))
|
||||
|
||||
|
||||
QRangeSlider.__doc__ += "\n" + textwrap.indent(QSlider.__doc__, " ")
|
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
import re
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import TYPE_CHECKING, Union
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .qtcompat import PYQT_VERSION
|
||||
from .qtcompat.QtCore import Qt
|
||||
@@ -16,23 +18,23 @@ from .qtcompat.QtGui import (
|
||||
from .qtcompat.QtWidgets import QApplication, QSlider, QStyleOptionSlider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._qrangeslider import QRangeSlider
|
||||
from ._generic_range_slider import _GenericRangeSlider
|
||||
|
||||
|
||||
@dataclass
|
||||
class RangeSliderStyle:
|
||||
brush_active: str = None
|
||||
brush_inactive: str = None
|
||||
brush_disabled: str = None
|
||||
pen_active: str = None
|
||||
pen_inactive: str = None
|
||||
pen_disabled: str = None
|
||||
vertical_thickness: float = None
|
||||
horizontal_thickness: float = None
|
||||
tick_offset: float = None
|
||||
tick_bar_alpha: float = None
|
||||
v_offset: float = None
|
||||
h_offset: float = None
|
||||
brush_active: str | None = None
|
||||
brush_inactive: str | None = None
|
||||
brush_disabled: str | None = None
|
||||
pen_active: str | None = None
|
||||
pen_inactive: str | None = None
|
||||
pen_disabled: str | None = None
|
||||
vertical_thickness: float | None = None
|
||||
horizontal_thickness: float | None = None
|
||||
tick_offset: float | None = None
|
||||
tick_bar_alpha: float | None = None
|
||||
v_offset: float | None = None
|
||||
h_offset: float | None = None
|
||||
has_stylesheet: bool = False
|
||||
|
||||
def brush(self, opt: QStyleOptionSlider) -> QBrush:
|
||||
@@ -70,7 +72,7 @@ class RangeSliderStyle:
|
||||
|
||||
return QBrush(val)
|
||||
|
||||
def pen(self, opt: QStyleOptionSlider) -> Union[Qt.PenStyle, QColor]:
|
||||
def pen(self, opt: QStyleOptionSlider) -> Qt.PenStyle | QColor:
|
||||
cg = opt.palette.currentColorGroup()
|
||||
attr = {
|
||||
QPalette.Active: "pen_active", # 0
|
||||
@@ -226,7 +228,7 @@ rgba_pattern = re.compile(
|
||||
)
|
||||
|
||||
|
||||
def parse_color(color: str, default_attr) -> Union[QColor, QGradient]:
|
||||
def parse_color(color: str, default_attr) -> QColor | QGradient:
|
||||
qc = QColor(color)
|
||||
if qc.isValid():
|
||||
return qc
|
||||
@@ -256,7 +258,7 @@ def parse_color(color: str, default_attr) -> Union[QColor, QGradient]:
|
||||
return QColor(getattr(SYSTEM_STYLE, default_attr))
|
||||
|
||||
|
||||
def update_styles_from_stylesheet(obj: "QRangeSlider"):
|
||||
def update_styles_from_stylesheet(obj: _GenericRangeSlider):
|
||||
qss = obj.styleSheet()
|
||||
|
||||
parent = obj.parent()
|
42
qtrangeslider/_sliders.py
Normal file
42
qtrangeslider/_sliders.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from ._generic_range_slider import _GenericRangeSlider
|
||||
from ._generic_slider import _GenericSlider
|
||||
from .qtcompat.QtCore import Signal
|
||||
|
||||
|
||||
class _IntMixin:
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._singleStep = 1
|
||||
|
||||
def _type_cast(self, value) -> int:
|
||||
return int(round(value))
|
||||
|
||||
|
||||
class _FloatMixin:
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._singleStep = 0.01
|
||||
self._pageStep = 0.1
|
||||
|
||||
def _type_cast(self, value) -> float:
|
||||
return float(value)
|
||||
|
||||
|
||||
class QDoubleSlider(_FloatMixin, _GenericSlider[float]):
|
||||
pass
|
||||
|
||||
|
||||
class QIntSlider(_IntMixin, _GenericSlider[int]):
|
||||
# mostly just an example... use QSlider instead.
|
||||
valueChanged = Signal(int)
|
||||
|
||||
|
||||
class QRangeSlider(_IntMixin, _GenericRangeSlider):
|
||||
pass
|
||||
|
||||
|
||||
class QDoubleRangeSlider(_FloatMixin, QRangeSlider):
|
||||
pass
|
||||
|
||||
|
||||
# QRangeSlider.__doc__ += "\n" + textwrap.indent(QSlider.__doc__, " ")
|
0
qtrangeslider/_tests/__init__.py
Normal file
0
qtrangeslider/_tests/__init__.py
Normal file
70
qtrangeslider/_tests/_testutil.py
Normal file
70
qtrangeslider/_tests/_testutil.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from contextlib import suppress
|
||||
from distutils.version import LooseVersion
|
||||
from platform import system
|
||||
|
||||
import pytest
|
||||
|
||||
from qtrangeslider.qtcompat import QT_VERSION
|
||||
from qtrangeslider.qtcompat.QtCore import QEvent, QPoint, QPointF, Qt
|
||||
from qtrangeslider.qtcompat.QtGui import QMouseEvent, QWheelEvent
|
||||
|
||||
QT_VERSION = LooseVersion(QT_VERSION)
|
||||
|
||||
SYS_DARWIN = system() == "Darwin"
|
||||
|
||||
skip_on_linux_qt6 = pytest.mark.skipif(
|
||||
system() == "Linux" and QT_VERSION >= LooseVersion("6.0"),
|
||||
reason="hover events not working on linux pyqt6",
|
||||
)
|
||||
|
||||
|
||||
def _mouse_event(pos=QPointF(), type_=QEvent.MouseMove):
|
||||
"""Create a mouse event of `type_` at `pos`."""
|
||||
return QMouseEvent(type_, QPointF(pos), Qt.LeftButton, Qt.LeftButton, Qt.NoModifier)
|
||||
|
||||
|
||||
def _wheel_event(arc):
|
||||
"""Create a wheel event with `arc`."""
|
||||
with suppress(TypeError):
|
||||
return QWheelEvent(
|
||||
QPointF(),
|
||||
QPointF(),
|
||||
QPoint(arc, arc),
|
||||
QPoint(arc, arc),
|
||||
Qt.NoButton,
|
||||
Qt.NoModifier,
|
||||
Qt.ScrollBegin,
|
||||
False,
|
||||
Qt.MouseEventSynthesizedByQt,
|
||||
)
|
||||
with suppress(TypeError):
|
||||
return QWheelEvent(
|
||||
QPointF(),
|
||||
QPointF(),
|
||||
QPoint(-arc, -arc),
|
||||
QPoint(-arc, -arc),
|
||||
1,
|
||||
Qt.Vertical,
|
||||
Qt.NoButton,
|
||||
Qt.NoModifier,
|
||||
Qt.ScrollBegin,
|
||||
False,
|
||||
Qt.MouseEventSynthesizedByQt,
|
||||
)
|
||||
|
||||
return QWheelEvent(
|
||||
QPointF(),
|
||||
QPointF(),
|
||||
QPoint(arc, arc),
|
||||
QPoint(arc, arc),
|
||||
1,
|
||||
Qt.Vertical,
|
||||
Qt.NoButton,
|
||||
Qt.NoModifier,
|
||||
)
|
||||
|
||||
|
||||
def _linspace(start, stop, n):
|
||||
h = (stop - start) / (n - 1)
|
||||
for i in range(n):
|
||||
yield start + h * i
|
@@ -62,15 +62,13 @@ def test_double_sliders(ds):
|
||||
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.setValue((20.23, 40.23))
|
||||
ds.assert_val_eq((20.23, 40.23))
|
||||
ds.assert_val_type()
|
||||
|
||||
ds.setDecimals(4)
|
||||
assert ds.minimum() == 10
|
||||
assert ds.maximum() == 99
|
||||
assert ds.singleStep() == 1
|
||||
@@ -78,16 +76,11 @@ def test_double_sliders(ds):
|
||||
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
|
||||
@@ -96,7 +89,6 @@ def test_double_sliders(ds):
|
||||
|
||||
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
|
||||
@@ -108,8 +100,6 @@ def test_double_sliders_small(ds):
|
||||
|
||||
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
|
||||
|
157
qtrangeslider/_tests/test_generic_slider.py
Normal file
157
qtrangeslider/_tests/test_generic_slider.py
Normal file
@@ -0,0 +1,157 @@
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from qtrangeslider._generic_slider import _GenericSlider
|
||||
from qtrangeslider.qtcompat.QtCore import QEvent, QPoint, QPointF, Qt
|
||||
from qtrangeslider.qtcompat.QtGui import QHoverEvent
|
||||
from qtrangeslider.qtcompat.QtWidgets import QStyle, QStyleOptionSlider
|
||||
|
||||
from ._testutil import _linspace, _mouse_event, _wheel_event, skip_on_linux_qt6
|
||||
|
||||
|
||||
@pytest.fixture(params=[Qt.Horizontal, Qt.Vertical])
|
||||
def gslider(qtbot, request):
|
||||
slider = _GenericSlider(request.param)
|
||||
qtbot.addWidget(slider)
|
||||
assert slider.value() == 0
|
||||
assert slider.minimum() == 0
|
||||
assert slider.maximum() == 99
|
||||
yield slider
|
||||
slider.initStyleOption(QStyleOptionSlider())
|
||||
|
||||
|
||||
def test_change_floatslider_range(gslider: _GenericSlider, qtbot):
|
||||
with qtbot.waitSignals([gslider.rangeChanged, gslider.valueChanged]):
|
||||
gslider.setMinimum(10)
|
||||
|
||||
assert gslider.value() == 10 == gslider.minimum()
|
||||
assert gslider.maximum() == 99
|
||||
|
||||
with qtbot.waitSignal(gslider.rangeChanged):
|
||||
gslider.setMaximum(90)
|
||||
assert gslider.value() == 10 == gslider.minimum()
|
||||
assert gslider.maximum() == 90
|
||||
|
||||
with qtbot.waitSignals([gslider.rangeChanged, gslider.valueChanged]):
|
||||
gslider.setRange(20, 40)
|
||||
assert gslider.value() == 20 == gslider.minimum()
|
||||
assert gslider.maximum() == 40
|
||||
|
||||
with qtbot.waitSignal(gslider.valueChanged):
|
||||
gslider.setValue(30)
|
||||
assert gslider.value() == 30
|
||||
|
||||
with qtbot.waitSignals([gslider.rangeChanged, gslider.valueChanged]):
|
||||
gslider.setMaximum(25)
|
||||
assert gslider.value() == 25 == gslider.maximum()
|
||||
assert gslider.minimum() == 20
|
||||
|
||||
|
||||
def test_float_values(gslider: _GenericSlider, qtbot):
|
||||
with qtbot.waitSignal(gslider.rangeChanged):
|
||||
gslider.setRange(0.25, 0.75)
|
||||
assert gslider.minimum() == 0.25
|
||||
assert gslider.maximum() == 0.75
|
||||
|
||||
with qtbot.waitSignal(gslider.valueChanged):
|
||||
gslider.setValue(0.55)
|
||||
assert gslider.value() == 0.55
|
||||
|
||||
with qtbot.waitSignal(gslider.valueChanged):
|
||||
gslider.setValue(1.55)
|
||||
assert gslider.value() == 0.75 == gslider.maximum()
|
||||
|
||||
|
||||
def test_ticks(gslider: _GenericSlider, qtbot):
|
||||
gslider.setTickInterval(0.3)
|
||||
assert gslider.tickInterval() == 0.3
|
||||
gslider.setTickPosition(gslider.TicksAbove)
|
||||
gslider.show()
|
||||
|
||||
|
||||
def test_show(gslider, qtbot):
|
||||
gslider.show()
|
||||
|
||||
|
||||
def test_press_move_release(gslider: _GenericSlider, qtbot):
|
||||
assert gslider._pressedControl == QStyle.SubControl.SC_None
|
||||
|
||||
opt = QStyleOptionSlider()
|
||||
gslider.initStyleOption(opt)
|
||||
style = gslider.style()
|
||||
hrect = style.subControlRect(QStyle.CC_Slider, opt, QStyle.SC_SliderHandle)
|
||||
handle_pos = gslider.mapToGlobal(hrect.center())
|
||||
|
||||
with qtbot.waitSignal(gslider.sliderPressed):
|
||||
qtbot.mousePress(gslider, Qt.LeftButton, pos=handle_pos)
|
||||
|
||||
assert gslider._pressedControl == QStyle.SubControl.SC_SliderHandle
|
||||
|
||||
with qtbot.waitSignals([gslider.sliderMoved, gslider.valueChanged]):
|
||||
shift = QPoint(0, -8) if gslider.orientation() == Qt.Vertical else QPoint(8, 0)
|
||||
gslider.mouseMoveEvent(_mouse_event(handle_pos + shift))
|
||||
|
||||
with qtbot.waitSignal(gslider.sliderReleased):
|
||||
qtbot.mouseRelease(gslider, Qt.LeftButton, pos=handle_pos)
|
||||
|
||||
assert gslider._pressedControl == QStyle.SubControl.SC_None
|
||||
|
||||
gslider.show()
|
||||
with qtbot.waitSignal(gslider.sliderPressed):
|
||||
qtbot.mousePress(gslider, Qt.LeftButton, pos=handle_pos)
|
||||
|
||||
|
||||
@skip_on_linux_qt6
|
||||
def test_hover(gslider: _GenericSlider):
|
||||
|
||||
opt = QStyleOptionSlider()
|
||||
gslider.initStyleOption(opt)
|
||||
style = gslider.style()
|
||||
hrect = style.subControlRect(QStyle.CC_Slider, opt, QStyle.SC_SliderHandle)
|
||||
handle_pos = QPointF(gslider.mapToGlobal(hrect.center()))
|
||||
|
||||
assert gslider._hoverControl == QStyle.SubControl.SC_None
|
||||
|
||||
gslider.event(QHoverEvent(QEvent.HoverEnter, handle_pos, QPointF()))
|
||||
assert gslider._hoverControl == QStyle.SubControl.SC_SliderHandle
|
||||
|
||||
gslider.event(QHoverEvent(QEvent.HoverLeave, QPointF(-1000, -1000), handle_pos))
|
||||
assert gslider._hoverControl == QStyle.SubControl.SC_None
|
||||
|
||||
|
||||
def test_wheel(gslider: _GenericSlider, qtbot):
|
||||
with qtbot.waitSignal(gslider.valueChanged):
|
||||
gslider.wheelEvent(_wheel_event(120))
|
||||
|
||||
gslider.wheelEvent(_wheel_event(0))
|
||||
|
||||
|
||||
def test_position(gslider: _GenericSlider, qtbot):
|
||||
gslider.setSliderPosition(21.2)
|
||||
assert gslider.sliderPosition() == 21.2
|
||||
|
||||
|
||||
def test_steps(gslider: _GenericSlider, qtbot):
|
||||
gslider.setSingleStep(0.1)
|
||||
assert gslider.singleStep() == 0.1
|
||||
|
||||
gslider.setSingleStep(1.5e20)
|
||||
assert gslider.singleStep() == 1.5e20
|
||||
|
||||
gslider.setPageStep(0.2)
|
||||
assert gslider.pageStep() == 0.2
|
||||
|
||||
gslider.setPageStep(1.5e30)
|
||||
assert gslider.pageStep() == 1.5e30
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mag", list(range(4, 37, 4)) + list(range(-4, -37, -4)))
|
||||
def test_slider_extremes(gslider: _GenericSlider, mag, qtbot):
|
||||
_mag = 10 ** mag
|
||||
with qtbot.waitSignal(gslider.rangeChanged):
|
||||
gslider.setRange(-_mag, _mag)
|
||||
for i in _linspace(-_mag, _mag, 10):
|
||||
gslider.setValue(i)
|
||||
assert math.isclose(gslider.value(), i, rel_tol=1e-8)
|
||||
gslider.initStyleOption(QStyleOptionSlider())
|
156
qtrangeslider/_tests/test_range_slider.py
Normal file
156
qtrangeslider/_tests/test_range_slider.py
Normal file
@@ -0,0 +1,156 @@
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from qtrangeslider import QDoubleRangeSlider, QRangeSlider
|
||||
from qtrangeslider.qtcompat.QtCore import QEvent, QPoint, QPointF, Qt
|
||||
from qtrangeslider.qtcompat.QtGui import QHoverEvent
|
||||
from qtrangeslider.qtcompat.QtWidgets import QStyle, QStyleOptionSlider
|
||||
|
||||
from ._testutil import _linspace, _mouse_event, _wheel_event, skip_on_linux_qt6
|
||||
|
||||
|
||||
@pytest.fixture(params=[Qt.Horizontal, Qt.Vertical])
|
||||
def gslider(qtbot, request):
|
||||
slider = QDoubleRangeSlider(request.param)
|
||||
qtbot.addWidget(slider)
|
||||
assert slider.value() == (20, 80)
|
||||
assert slider.minimum() == 0
|
||||
assert slider.maximum() == 99
|
||||
yield slider
|
||||
slider.initStyleOption(QStyleOptionSlider())
|
||||
|
||||
|
||||
def test_change_floatslider_range(gslider: QRangeSlider, qtbot):
|
||||
with qtbot.waitSignals([gslider.rangeChanged, gslider.valueChanged]):
|
||||
gslider.setMinimum(30)
|
||||
|
||||
assert gslider.value()[0] == 30 == gslider.minimum()
|
||||
assert gslider.maximum() == 99
|
||||
|
||||
with qtbot.waitSignal(gslider.rangeChanged):
|
||||
gslider.setMaximum(70)
|
||||
assert gslider.value()[0] == 30 == gslider.minimum()
|
||||
assert gslider.value()[1] == 70 == gslider.maximum()
|
||||
|
||||
with qtbot.waitSignals([gslider.rangeChanged, gslider.valueChanged]):
|
||||
gslider.setRange(40, 60)
|
||||
assert gslider.value()[0] == 40 == gslider.minimum()
|
||||
assert gslider.maximum() == 60
|
||||
|
||||
with qtbot.waitSignal(gslider.valueChanged):
|
||||
gslider.setValue([40, 50])
|
||||
assert gslider.value()[0] == 40 == gslider.minimum()
|
||||
assert gslider.value()[1] == 50
|
||||
|
||||
with qtbot.waitSignals([gslider.rangeChanged, gslider.valueChanged]):
|
||||
gslider.setMaximum(45)
|
||||
assert gslider.value()[0] == 40 == gslider.minimum()
|
||||
assert gslider.value()[1] == 45 == gslider.maximum()
|
||||
|
||||
|
||||
def test_float_values(gslider: QRangeSlider, qtbot):
|
||||
with qtbot.waitSignal(gslider.rangeChanged):
|
||||
gslider.setRange(0.1, 0.9)
|
||||
assert gslider.minimum() == 0.1
|
||||
assert gslider.maximum() == 0.9
|
||||
|
||||
with qtbot.waitSignal(gslider.valueChanged):
|
||||
gslider.setValue([0.4, 0.6])
|
||||
assert gslider.value() == (0.4, 0.6)
|
||||
|
||||
with qtbot.waitSignal(gslider.valueChanged):
|
||||
gslider.setValue([0, 1.9])
|
||||
assert gslider.value()[0] == 0.1 == gslider.minimum()
|
||||
assert gslider.value()[1] == 0.9 == gslider.maximum()
|
||||
|
||||
|
||||
def test_position(gslider: QRangeSlider, qtbot):
|
||||
gslider.setSliderPosition([10, 80])
|
||||
assert gslider.sliderPosition() == (10, 80)
|
||||
|
||||
|
||||
def test_steps(gslider: QRangeSlider, qtbot):
|
||||
gslider.setSingleStep(0.1)
|
||||
assert gslider.singleStep() == 0.1
|
||||
|
||||
gslider.setSingleStep(1.5e20)
|
||||
assert gslider.singleStep() == 1.5e20
|
||||
|
||||
gslider.setPageStep(0.2)
|
||||
assert gslider.pageStep() == 0.2
|
||||
|
||||
gslider.setPageStep(1.5e30)
|
||||
assert gslider.pageStep() == 1.5e30
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mag", list(range(4, 37, 4)) + list(range(-4, -37, -4)))
|
||||
def test_slider_extremes(gslider: QRangeSlider, mag, qtbot):
|
||||
_mag = 10 ** mag
|
||||
with qtbot.waitSignal(gslider.rangeChanged):
|
||||
gslider.setRange(-_mag, _mag)
|
||||
for i in _linspace(-_mag, _mag, 10):
|
||||
gslider.setValue((i, _mag))
|
||||
assert math.isclose(gslider.value()[0], i, rel_tol=1e-8)
|
||||
gslider.initStyleOption(QStyleOptionSlider())
|
||||
|
||||
|
||||
def test_ticks(gslider: QRangeSlider, qtbot):
|
||||
gslider.setTickInterval(0.3)
|
||||
assert gslider.tickInterval() == 0.3
|
||||
gslider.setTickPosition(gslider.TicksAbove)
|
||||
gslider.show()
|
||||
|
||||
|
||||
def test_show(gslider, qtbot):
|
||||
gslider.show()
|
||||
|
||||
|
||||
def test_press_move_release(gslider: QRangeSlider, qtbot):
|
||||
assert gslider._pressedControl == QStyle.SubControl.SC_None
|
||||
|
||||
opt = QStyleOptionSlider()
|
||||
gslider.initStyleOption(opt)
|
||||
style = gslider.style()
|
||||
hrect = style.subControlRect(QStyle.CC_Slider, opt, QStyle.SC_SliderHandle)
|
||||
handle_pos = gslider.mapToGlobal(hrect.center())
|
||||
|
||||
with qtbot.waitSignal(gslider.sliderPressed):
|
||||
qtbot.mousePress(gslider, Qt.LeftButton, pos=handle_pos)
|
||||
|
||||
assert gslider._pressedControl == QStyle.SubControl.SC_SliderHandle
|
||||
|
||||
with qtbot.waitSignals([gslider.sliderMoved, gslider.valueChanged]):
|
||||
shift = QPoint(0, -8) if gslider.orientation() == Qt.Vertical else QPoint(8, 0)
|
||||
gslider.mouseMoveEvent(_mouse_event(handle_pos + shift))
|
||||
|
||||
with qtbot.waitSignal(gslider.sliderReleased):
|
||||
qtbot.mouseRelease(gslider, Qt.LeftButton, pos=handle_pos)
|
||||
|
||||
assert gslider._pressedControl == QStyle.SubControl.SC_None
|
||||
|
||||
gslider.show()
|
||||
with qtbot.waitSignal(gslider.sliderPressed):
|
||||
qtbot.mousePress(gslider, Qt.LeftButton, pos=handle_pos)
|
||||
|
||||
|
||||
@skip_on_linux_qt6
|
||||
def test_hover(gslider: QRangeSlider):
|
||||
|
||||
hrect = gslider._handleRect(0)
|
||||
handle_pos = QPointF(gslider.mapToGlobal(hrect.center()))
|
||||
|
||||
assert gslider._hoverControl == QStyle.SubControl.SC_None
|
||||
|
||||
gslider.event(QHoverEvent(QEvent.HoverEnter, handle_pos, QPointF()))
|
||||
assert gslider._hoverControl == QStyle.SubControl.SC_SliderHandle
|
||||
|
||||
gslider.event(QHoverEvent(QEvent.HoverLeave, QPointF(-1000, -1000), handle_pos))
|
||||
assert gslider._hoverControl == QStyle.SubControl.SC_None
|
||||
|
||||
|
||||
def test_wheel(gslider: QRangeSlider, qtbot):
|
||||
with qtbot.waitSignal(gslider.valueChanged):
|
||||
gslider.wheelEvent(_wheel_event(120))
|
||||
|
||||
gslider.wheelEvent(_wheel_event(0))
|
222
qtrangeslider/_tests/test_single_value_sliders.py
Normal file
222
qtrangeslider/_tests/test_single_value_sliders.py
Normal file
@@ -0,0 +1,222 @@
|
||||
import math
|
||||
from contextlib import suppress
|
||||
from distutils.version import LooseVersion
|
||||
|
||||
import pytest
|
||||
|
||||
from qtrangeslider import QDoubleSlider, QLabeledDoubleSlider, QLabeledSlider
|
||||
from qtrangeslider._generic_slider import _GenericSlider
|
||||
from qtrangeslider.qtcompat.QtCore import QEvent, QPoint, QPointF, Qt
|
||||
from qtrangeslider.qtcompat.QtGui import QHoverEvent
|
||||
from qtrangeslider.qtcompat.QtWidgets import QSlider, QStyle, QStyleOptionSlider
|
||||
|
||||
from ._testutil import (
|
||||
QT_VERSION,
|
||||
SYS_DARWIN,
|
||||
_linspace,
|
||||
_mouse_event,
|
||||
_wheel_event,
|
||||
skip_on_linux_qt6,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(params=[Qt.Horizontal, Qt.Vertical], ids=["horizontal", "vertical"])
|
||||
def orientation(request):
|
||||
return request.param
|
||||
|
||||
|
||||
START_MI_MAX_VAL = (0, 99, 0)
|
||||
TEST_SLIDERS = [QDoubleSlider, QLabeledSlider, QLabeledDoubleSlider]
|
||||
|
||||
|
||||
def _assert_value_in_range(sld):
|
||||
val = sld.value()
|
||||
if isinstance(val, (int, float)):
|
||||
val = (val,)
|
||||
assert all(sld.minimum() <= v <= sld.maximum() for v in val)
|
||||
|
||||
|
||||
@pytest.fixture(params=TEST_SLIDERS)
|
||||
def sld(request, qtbot, orientation):
|
||||
Cls = request.param
|
||||
slider = Cls(orientation)
|
||||
slider.setRange(*START_MI_MAX_VAL[:2])
|
||||
slider.setValue(START_MI_MAX_VAL[2])
|
||||
qtbot.addWidget(slider)
|
||||
assert (slider.minimum(), slider.maximum(), slider.value()) == START_MI_MAX_VAL
|
||||
_assert_value_in_range(slider)
|
||||
yield slider
|
||||
_assert_value_in_range(slider)
|
||||
with suppress(AttributeError):
|
||||
slider.initStyleOption(QStyleOptionSlider())
|
||||
|
||||
|
||||
def called_with(*expected_result):
|
||||
"""Use in check_params_cbs to assert that a callback is called as expected.
|
||||
|
||||
e.g. `called_with(20, 50)` returns a callback that checks that the callback
|
||||
is called with the arguments (20, 50)
|
||||
"""
|
||||
|
||||
def check_emitted_values(*values):
|
||||
return values == expected_result
|
||||
|
||||
return check_emitted_values
|
||||
|
||||
|
||||
def test_change_floatslider_range(sld: _GenericSlider, qtbot):
|
||||
BOTH = [sld.rangeChanged, sld.valueChanged]
|
||||
|
||||
for signals, checks, funcname, args in [
|
||||
(BOTH, [called_with(10, 99), called_with(10)], "setMinimum", (10,)),
|
||||
([sld.rangeChanged], [called_with(10, 90)], "setMaximum", (90,)),
|
||||
(BOTH, [called_with(20, 40), called_with(20)], "setRange", (20, 40)),
|
||||
([sld.valueChanged], [called_with(30)], "setValue", (30,)),
|
||||
(BOTH, [called_with(20, 25), called_with(25)], "setMaximum", (25,)),
|
||||
([sld.valueChanged], [called_with(23)], "setValue", (23,)),
|
||||
]:
|
||||
with qtbot.waitSignals(signals, check_params_cbs=checks, timeout=500):
|
||||
getattr(sld, funcname)(*args)
|
||||
_assert_value_in_range(sld)
|
||||
|
||||
|
||||
def test_float_values(sld: _GenericSlider, qtbot):
|
||||
if type(sld) is QLabeledSlider:
|
||||
pytest.skip()
|
||||
for signals, checks, funcname, args in [
|
||||
(sld.rangeChanged, called_with(0.1, 0.9), "setRange", (0.1, 0.9)),
|
||||
(sld.valueChanged, called_with(0.4), "setValue", (0.4,)),
|
||||
(sld.valueChanged, called_with(0.1), "setValue", (0,)),
|
||||
(sld.valueChanged, called_with(0.9), "setValue", (1.9,)),
|
||||
]:
|
||||
with qtbot.waitSignal(signals, check_params_cb=checks, timeout=400):
|
||||
getattr(sld, funcname)(*args)
|
||||
_assert_value_in_range(sld)
|
||||
|
||||
|
||||
def test_ticks(sld: _GenericSlider, qtbot):
|
||||
sld.setTickInterval(3)
|
||||
assert sld.tickInterval() == 3
|
||||
sld.setTickPosition(QSlider.TicksAbove)
|
||||
sld.show()
|
||||
|
||||
|
||||
# FIXME: this isn't testing labeled sliders as it needs to be ...
|
||||
@pytest.mark.skipif(not SYS_DARWIN, reason="mousePress only working on mac")
|
||||
def test_press_move_release(sld: _GenericSlider, qtbot):
|
||||
|
||||
_real_sld = getattr(sld, "_slider", sld)
|
||||
|
||||
with suppress(AttributeError): # for QSlider
|
||||
assert _real_sld._pressedControl == QStyle.SubControl.SC_None
|
||||
|
||||
opt = QStyleOptionSlider()
|
||||
_real_sld.initStyleOption(opt)
|
||||
style = _real_sld.style()
|
||||
hrect = style.subControlRect(QStyle.CC_Slider, opt, QStyle.SC_SliderHandle)
|
||||
handle_pos = _real_sld.mapToGlobal(hrect.center())
|
||||
|
||||
with qtbot.waitSignal(_real_sld.sliderPressed, timeout=300):
|
||||
qtbot.mousePress(_real_sld, Qt.LeftButton, pos=handle_pos)
|
||||
|
||||
with suppress(AttributeError):
|
||||
assert sld._pressedControl == QStyle.SubControl.SC_SliderHandle
|
||||
|
||||
with qtbot.waitSignals(
|
||||
[_real_sld.sliderMoved, _real_sld.valueChanged], timeout=300
|
||||
):
|
||||
shift = (
|
||||
QPoint(0, -8) if _real_sld.orientation() == Qt.Vertical else QPoint(8, 0)
|
||||
)
|
||||
_real_sld.mouseMoveEvent(_mouse_event(handle_pos + shift))
|
||||
|
||||
with qtbot.waitSignal(_real_sld.sliderReleased, timeout=300):
|
||||
qtbot.mouseRelease(_real_sld, Qt.LeftButton, pos=handle_pos)
|
||||
|
||||
with suppress(AttributeError):
|
||||
assert _real_sld._pressedControl == QStyle.SubControl.SC_None
|
||||
|
||||
sld.show()
|
||||
with qtbot.waitSignal(_real_sld.sliderPressed, timeout=300):
|
||||
qtbot.mousePress(_real_sld, Qt.LeftButton, pos=handle_pos)
|
||||
|
||||
|
||||
@skip_on_linux_qt6
|
||||
def test_hover(sld: _GenericSlider):
|
||||
|
||||
_real_sld = getattr(sld, "_slider", sld)
|
||||
|
||||
opt = QStyleOptionSlider()
|
||||
_real_sld.initStyleOption(opt)
|
||||
hrect = _real_sld.style().subControlRect(
|
||||
QStyle.CC_Slider, opt, QStyle.SC_SliderHandle
|
||||
)
|
||||
handle_pos = QPointF(sld.mapToGlobal(hrect.center()))
|
||||
|
||||
with suppress(AttributeError): # for QSlider
|
||||
assert _real_sld._hoverControl == QStyle.SubControl.SC_None
|
||||
|
||||
_real_sld.event(QHoverEvent(QEvent.HoverEnter, handle_pos, QPointF()))
|
||||
with suppress(AttributeError): # for QSlider
|
||||
assert _real_sld._hoverControl == QStyle.SubControl.SC_SliderHandle
|
||||
|
||||
_real_sld.event(QHoverEvent(QEvent.HoverLeave, QPointF(-1000, -1000), handle_pos))
|
||||
with suppress(AttributeError): # for QSlider
|
||||
assert _real_sld._hoverControl == QStyle.SubControl.SC_None
|
||||
|
||||
|
||||
def test_wheel(sld: _GenericSlider, qtbot):
|
||||
|
||||
if type(sld) is QLabeledSlider and QT_VERSION < LooseVersion("5.12"):
|
||||
pytest.skip()
|
||||
|
||||
_real_sld = getattr(sld, "_slider", sld)
|
||||
with qtbot.waitSignal(sld.valueChanged, timeout=400):
|
||||
_real_sld.wheelEvent(_wheel_event(120))
|
||||
|
||||
_real_sld.wheelEvent(_wheel_event(0))
|
||||
|
||||
|
||||
def test_position(sld: _GenericSlider, qtbot):
|
||||
sld.setSliderPosition(21)
|
||||
assert sld.sliderPosition() == 21
|
||||
|
||||
if type(sld) is not QLabeledSlider:
|
||||
sld.setSliderPosition(21.5)
|
||||
assert sld.sliderPosition() == 21.5
|
||||
|
||||
|
||||
def test_steps(sld: _GenericSlider, qtbot):
|
||||
|
||||
sld.setSingleStep(11)
|
||||
assert sld.singleStep() == 11
|
||||
|
||||
sld.setPageStep(16)
|
||||
assert sld.pageStep() == 16
|
||||
|
||||
if type(sld) is not QLabeledSlider:
|
||||
|
||||
sld.setSingleStep(0.1)
|
||||
assert sld.singleStep() == 0.1
|
||||
|
||||
sld.setSingleStep(1.5e20)
|
||||
assert sld.singleStep() == 1.5e20
|
||||
|
||||
sld.setPageStep(0.2)
|
||||
assert sld.pageStep() == 0.2
|
||||
|
||||
sld.setPageStep(1.5e30)
|
||||
assert sld.pageStep() == 1.5e30
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mag", list(range(4, 37, 4)) + list(range(-4, -37, -4)))
|
||||
def test_slider_extremes(sld: _GenericSlider, mag, qtbot):
|
||||
if type(sld) is QLabeledSlider:
|
||||
pytest.skip()
|
||||
|
||||
_mag = 10 ** mag
|
||||
with qtbot.waitSignal(sld.rangeChanged, timeout=400):
|
||||
sld.setRange(-_mag, _mag)
|
||||
for i in _linspace(-_mag, _mag, 10):
|
||||
sld.setValue(i)
|
||||
assert math.isclose(sld.value(), i, rel_tol=1e-8)
|
@@ -3,6 +3,7 @@ import platform
|
||||
import pytest
|
||||
|
||||
from qtrangeslider import QRangeSlider
|
||||
from qtrangeslider._generic_range_slider import SC_BAR, SC_HANDLE, SC_NONE
|
||||
from qtrangeslider.qtcompat import API_NAME
|
||||
from qtrangeslider.qtcompat.QtCore import Qt
|
||||
|
||||
@@ -18,6 +19,26 @@ def test_basic(qtbot, orientation):
|
||||
qtbot.addWidget(rs)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("orientation", ["Horizontal", "Vertical"])
|
||||
def test_value(qtbot, orientation):
|
||||
rs = QRangeSlider(getattr(Qt, orientation))
|
||||
qtbot.addWidget(rs)
|
||||
rs.setValue([10, 20])
|
||||
assert rs.value() == (10, 20)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("orientation", ["Horizontal", "Vertical"])
|
||||
def test_range(qtbot, orientation):
|
||||
rs = QRangeSlider(getattr(Qt, orientation))
|
||||
qtbot.addWidget(rs)
|
||||
rs.setValue([10, 20])
|
||||
assert rs.value() == (10, 20)
|
||||
rs.setRange(15, 20)
|
||||
assert rs.value() == (15, 20)
|
||||
assert rs.minimum() == 15
|
||||
assert rs.maximum() == 20
|
||||
|
||||
|
||||
@skipmouse
|
||||
def test_drag_handles(qtbot):
|
||||
rs = QRangeSlider(Qt.Horizontal)
|
||||
@@ -28,11 +49,11 @@ def test_drag_handles(qtbot):
|
||||
rs.show()
|
||||
|
||||
# press the left handle
|
||||
opt = rs._getStyleOption()
|
||||
pos = rs._handleRects(opt, 0).center()
|
||||
pos = rs._handleRect(0).center()
|
||||
with qtbot.waitSignal(rs.sliderPressed):
|
||||
qtbot.mousePress(rs, Qt.LeftButton, pos=pos)
|
||||
assert rs._pressedControl == ("handle", 0)
|
||||
assert rs._pressedControl == SC_HANDLE
|
||||
assert rs._pressedIndex == 0
|
||||
|
||||
# drag the left handle
|
||||
with qtbot.waitSignals([rs.sliderMoved] * 13): # couple less signals
|
||||
@@ -45,13 +66,14 @@ def test_drag_handles(qtbot):
|
||||
|
||||
# check the values
|
||||
assert rs.value()[0] > 30
|
||||
assert rs._pressedControl == rs._NULL_CTRL
|
||||
assert rs._pressedControl == SC_NONE
|
||||
|
||||
# press the right handle
|
||||
pos = rs._handleRects(opt, 1).center()
|
||||
pos = rs._handleRect(1).center()
|
||||
with qtbot.waitSignal(rs.sliderPressed):
|
||||
qtbot.mousePress(rs, Qt.LeftButton, pos=pos)
|
||||
assert rs._pressedControl == ("handle", 1)
|
||||
assert rs._pressedControl == SC_HANDLE
|
||||
assert rs._pressedIndex == 1
|
||||
|
||||
# drag the right handle
|
||||
with qtbot.waitSignals([rs.sliderMoved] * 13): # couple less signals
|
||||
@@ -63,7 +85,7 @@ def test_drag_handles(qtbot):
|
||||
|
||||
# check the values
|
||||
assert rs.value()[1] < 70
|
||||
assert rs._pressedControl == rs._NULL_CTRL
|
||||
assert rs._pressedControl == SC_NONE
|
||||
|
||||
|
||||
@skipmouse
|
||||
@@ -76,15 +98,15 @@ def test_drag_handles_beyond_edge(qtbot):
|
||||
rs.show()
|
||||
|
||||
# press the right handle
|
||||
opt = rs._getStyleOption()
|
||||
pos = rs._handleRects(opt, 1).center()
|
||||
pos = rs._handleRect(1).center()
|
||||
with qtbot.waitSignal(rs.sliderPressed):
|
||||
qtbot.mousePress(rs, Qt.LeftButton, pos=pos)
|
||||
assert rs._pressedControl == ("handle", 1)
|
||||
assert rs._pressedControl == SC_HANDLE
|
||||
assert rs._pressedIndex == 1
|
||||
|
||||
# drag the handle off the right edge and make sure the value gets to the max
|
||||
for _ in range(5):
|
||||
pos.setX(pos.x() + 20)
|
||||
for _ in range(7):
|
||||
pos.setX(pos.x() + 10)
|
||||
qtbot.mouseMove(rs, pos)
|
||||
|
||||
with qtbot.waitSignal(rs.sliderReleased):
|
||||
@@ -106,7 +128,8 @@ def test_bar_drag_beyond_edge(qtbot):
|
||||
pos = rs.rect().center()
|
||||
with qtbot.waitSignal(rs.sliderPressed):
|
||||
qtbot.mousePress(rs, Qt.LeftButton, pos=pos)
|
||||
assert rs._pressedControl == ("bar", 1)
|
||||
assert rs._pressedControl == SC_BAR
|
||||
assert rs._pressedIndex == 1
|
||||
|
||||
# drag the handle off the right edge and make sure the value gets to the max
|
||||
for _ in range(15):
|
||||
|
11
tox.ini
11
tox.ini
@@ -1,6 +1,6 @@
|
||||
# For more information about tox, see https://tox.readthedocs.io/en/latest/
|
||||
[tox]
|
||||
envlist = py{37,38,39}-{linux,macos,windows}-{pyqt5,pyside2,pyqt6,pyside6}
|
||||
envlist = py{37,38,39}-{linux,macos,windows}-{pyqt5,pyside2,pyqt6,pyside6},py37-{linux,macos,windows}-{pyqt511,pyside511}
|
||||
toxworkdir=/tmp/.tox
|
||||
|
||||
[gh-actions]
|
||||
@@ -24,6 +24,8 @@ BACKEND =
|
||||
pyside2: pyside2
|
||||
pyqt6: pyqt6
|
||||
pyside6: pyside6
|
||||
pyqt511: pyqt511
|
||||
pyside511: pyside511
|
||||
|
||||
[testenv]
|
||||
platform =
|
||||
@@ -33,11 +35,14 @@ platform =
|
||||
passenv = CI GITHUB_ACTIONS DISPLAY XAUTHORITY
|
||||
deps =
|
||||
pytest-xvfb ; sys_platform == 'linux'
|
||||
pyqt511: pyqt5==5.11.*
|
||||
pyside511: pyside2==5.11.*
|
||||
extras =
|
||||
testing
|
||||
pyqt5: pyqt5
|
||||
pyside2: pyside2
|
||||
pyqt6: pyqt6
|
||||
pyside6: pyside6
|
||||
commands_pre = pip install -U pytest-qt@git+https://github.com/The-Compiler/pytest-qt.git@pyqt6
|
||||
commands = pytest -v --color=yes --cov=qtrangeslider --cov-report=xml
|
||||
commands_pre =
|
||||
pyqt6,pyside6: pip install -U pytest-qt@git+https://github.com/pytest-dev/pytest-qt.git
|
||||
commands = pytest --color=yes --cov=qtrangeslider --cov-report=xml {posargs}
|
||||
|
Reference in New Issue
Block a user