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:
Talley Lambert
2021-06-02 17:23:05 -04:00
committed by GitHub
parent b12e5471a0
commit 15e3af4985
24 changed files with 1608 additions and 789 deletions

View File

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

View File

@@ -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
View 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_()

View File

@@ -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_()

View File

@@ -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
View 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_()

View File

@@ -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))

View File

@@ -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",

View File

@@ -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)

View 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])

View 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

View File

@@ -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))

View File

@@ -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()

View File

@@ -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__, " ")

View File

@@ -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
View 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__, " ")

View File

View 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

View File

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

View 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())

View 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))

View 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)

View File

@@ -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
View File

@@ -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}