mirror of
https://github.com/pyapp-kit/superqt.git
synced 2025-12-16 03:00:05 +01:00
544 lines
20 KiB
Python
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")
|