Files
superqt/qtrangeslider/_qrangeslider.py
Talley Lambert 9fde133977 rename
2021-04-24 21:07:40 -04:00

544 lines
20 KiB
Python

import re
from typing import List, Sequence, Tuple
from .qtcompat import QtGui
from .qtcompat.QtCore import QEvent, QPoint, QPointF, QRect, QRectF, Qt, Signal
from .qtcompat.QtWidgets import (
QApplication,
QSlider,
QStyle,
QStyleOptionSlider,
QStylePainter,
QWidget,
)
Control = Tuple[str, int]
def _bound(min_: int, max_: int, value: int) -> int:
"""Return value bounded by min_ and max_."""
return max(min_, min(max_, value))
class QRangeSlider(QSlider):
# 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, orientation=Qt.Horizontal, parent: QWidget = None):
super().__init__(orientation, parent)
# list of values
self._value: List[int] = [20, 80]
# list of current positions of each handle. Must be same length as _value
# If tracking is enabled (the default) this will be identical to _value
self._position: List[int] = [20, 80]
self._pressedControl: Control = self.NULL_CTRL
self._hoverControl: Control = 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_stiff = True
# whether clicking on the bar moves all handles, or just the nearest handle.
self._bar_moves_all = True
# for keyboard nav
self._repeatMultiplier = 1 # TODO
# for wheel nav
self._offset_accum = 0
# color
self._bar_color_active = QtGui.QColor("#3B88FD")
self._bar_color_inactive = QtGui.QColor("#8F8F8F")
self._bar_color_disabled = QtGui.QColor("#BBBBBB")
self._bar_height = 3
self._bar_width = 3
# try to parse QSS for the bar height and color
self._parse_stylesheet = True
def value(self) -> Tuple[int, ...]:
return tuple(self._value)
def setValue(self, val: Sequence[int]) -> None:
if not isinstance(val, 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(tuple(self._value))
def sliderPosition(self) -> Tuple[int, ...]:
return tuple(self._position)
def setSliderPosition(self, sld_idx: int, pos: int) -> None:
pos = self._min_max_bound(pos)
# prevent sliders from moving beyond their neighbors
pos = self._neighbor_bound(pos, sld_idx, self._position)
if pos == self._position[sld_idx]:
return
self._position[sld_idx] = pos
if not self.hasTracking():
self.update()
if self.isSliderDown():
self.sliderMoved.emit(tuple(self._position))
if self.hasTracking():
self.triggerAction(QSlider.SliderMove)
def _getStyleOption(self) -> QStyleOptionSlider:
opt = QStyleOptionSlider()
self.initStyleOption(opt)
opt.sliderValue = 0
opt.sliderPosition = 0
return opt
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)
# draw bar
r_bar = self._barRect(opt)
if self._bar_color_active is not None: # FIXME
if self.palette().currentColorGroup() == QtGui.QPalette.Active:
color = self._bar_color_active
elif self.palette().currentColorGroup() == QtGui.QPalette.Inactive:
color = self._bar_color_inactive
else:
color = self._bar_color_disabled
else:
color = self.palette().color(QtGui.QPalette.Highlight)
if isinstance(color, QtGui.QGradient):
color.setStart(r_bar.topLeft())
color.setFinalStop(r_bar.bottomRight())
painter.fillRect(r_bar, color)
# 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 = 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.StyleChange:
self._traverseStyleSheet()
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.setSliderPosition(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._clickOffset = self._pixelPosToRangeValue(self._pick(ev.pos()))
self._sldPosAtPress = tuple(self._position)
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.setSliderPosition(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 _offsetAllPositions(self, offset: int, ref=None) -> None:
if ref is None:
ref = self._position
if self._bar_is_stiff:
_new = [i - offset for i in ref]
# FIXME: if there is an overflow ... it should still hit the edge.
if all(self.minimum() <= i <= self.maximum() for i in _new):
for i, n in enumerate(_new):
self.setSliderPosition(i, n) # TODO: without for loop
else:
for i, n in enumerate(ref):
self.setSliderPosition(i, n - offset) # TODO: without for loop
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] == "handle":
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, handle_index: int = None) -> QRect:
"""Return the QRect for all handles."""
style = self.style().proxy()
if handle_index is not None: # get specific handle rect
opt.sliderPosition = self._position[handle_index]
return style.subControlRect(
QStyle.CC_Slider, opt, QStyle.SC_SliderHandle, self
)
else:
rects = []
for p in self._position:
opt.sliderPosition = p
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)
tp = self.tickPosition()
if tp & QSlider.TicksAbove:
displace = 4
elif tp & QSlider.TicksBelow:
displace = -4
else:
displace = 0
if opt.orientation == Qt.Horizontal:
r_bar.setTop(r_bar.center().y() - self._bar_height / 2 + displace)
r_bar.setHeight(self._bar_height)
r_bar.setLeft(hdl_low.center().x())
r_bar.setRight(hdl_high.center().x())
else:
r_bar.setLeft(r_bar.center().x() - self._bar_width / 2 + displace)
r_bar.setWidth(self._bar_width)
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
) -> Control:
"""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
return QStyle.sliderValueFromPosition(
self.minimum(),
self.maximum(),
pos - sliderMin,
sliderMax - sliderMin,
opt.upsideDown,
)
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:
if index > 0:
val = max(_lst[index - 1], val)
# make sure we don't go higher than any following index:
if index < len(_lst) - 1:
val = min(_lst[index + 1], 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: Qt.KeyboardModifiers, 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.ControlModifier or 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
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()
self._offsetAllPositions(-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 _traverseStyleSheet(self):
qss = self.styleSheet()
p = self
while p.parent():
qss = p.styleSheet() + qss
p = p.parent()
qss = QApplication.instance().styleSheet() + qss
# Find bar color
# TODO: optional horizontal or vertical
match = re.search(r"Slider::sub-page:?([^{\s]*)?\s*{\s*([^}]+)}", qss, re.S)
if match:
orientation, content = match.groups()
for line in reversed(content.splitlines()):
bgrd = re.search(r"background(-color)?:\s*([^;]+)", line)
if bgrd:
self._bar_color_active = parse_color(bgrd.groups()[-1])
# TODO: bar color inactive?
# TODO: bar color disabled?
class_name = type(self).__name__
_ss = f"\n{class_name}::sub-page:{orientation}{{background: none}}"
# TODO: block double event
self.setStyleSheet(qss + _ss)
break
# Find bar height/width
for orient, dim in (("horizontal", "height"), ("vertical", "width")):
match = re.search(rf"Slider::groove:{orient}\s*{{\s*([^}}]+)}}", qss, re.S)
if match:
for line in reversed(match.groups()[0].splitlines()):
bgrd = re.search(rf"{dim}\s*:\s*(\d+)", line)
if bgrd:
setattr(self, f"_bar_{dim}", float(bgrd.groups()[-1]))
qlineargrad_pattern = re.compile(
r"""
qlineargradient\(
x1:\s*(?P<x1>\d*\.?\d+),\s*
y1:\s*(?P<y1>\d*\.?\d+),\s*
x2:\s*(?P<x2>\d*\.?\d+),\s*
y2:\s*(?P<y2>\d*\.?\d+),\s*
stop:0\s*(?P<stop0>\S+),.*
stop:1\s*(?P<stop1>\S+)
\)""",
re.X,
)
qradial_pattern = re.compile(
r"""
qradialgradient\(
cx:\s*(?P<cx>\d*\.?\d+),\s*
cy:\s*(?P<cy>\d*\.?\d+),\s*
radius:\s*(?P<radius>\d*\.?\d+),\s*
fx:\s*(?P<fx>\d*\.?\d+),\s*
fy:\s*(?P<fy>\d*\.?\d+),\s*
stop:0\s*(?P<stop0>\S+),.*
stop:1\s*(?P<stop1>\S+)
\)""",
re.X,
)
def parse_color(color: str):
qc = QtGui.QColor(color)
if qc.isValid():
return qc
# try linear gradient:
match = qlineargrad_pattern.match(color)
if match:
grad = QtGui.QLinearGradient(*[float(i) for i in match.groups()[:4]])
grad.setColorAt(0, QtGui.QColor(match.groupdict()["stop0"]))
grad.setColorAt(1, QtGui.QColor(match.groupdict()["stop1"]))
return grad
# try linear gradient:
match = qradial_pattern.match(color)
print("match", match.groupdict())
if match:
grad = QtGui.QRadialGradient(*[float(i) for i in match.groups()[:5]])
grad.setColorAt(0, QtGui.QColor(match.groupdict()["stop0"]))
grad.setColorAt(1, QtGui.QColor(match.groupdict()["stop1"]))
return grad
# fallback to dark gray
return QtGui.QColor("#333")