scanwidget: changes as of 19a1d40 (closes #330)

This commit is contained in:
Robert Jördens 2016-03-14 11:45:26 +01:00
parent 8ad799a850
commit 1dc72635a0
1 changed files with 135 additions and 245 deletions

View File

@ -62,23 +62,20 @@ class ScanSlider(QtWidgets.QSlider):
def __init__(self): def __init__(self):
QtWidgets.QSlider.__init__(self, QtCore.Qt.Horizontal) QtWidgets.QSlider.__init__(self, QtCore.Qt.Horizontal)
self.startPos = 0 # Pos and Val can differ in event handling. self.startVal = None
# perhaps prevPos and currPos is more accurate. self.stopVal = None
self.stopPos = 99 self.offset = None
self.startVal = 0 # lower self.position = None
self.stopVal = 99 # upper self.pressed = None
self.offset = 0
self.position = 0
self.upperPressed = QtWidgets.QStyle.SC_None
self.lowerPressed = QtWidgets.QStyle.SC_None
self.firstMovement = False # State var for handling slider overlap.
self.blockTracking = False
self.setMinimum(0) self.setRange(0, (1 << 15) - 1)
self.setMaximum(4095)
# We need fake sliders to keep around so that we can dynamically # We need fake sliders to keep around so that we can dynamically
# set the stylesheets for drawing each slider later. See paintEvent. # set the stylesheets for drawing each slider later. See paintEvent.
# QPalettes would be nicer to use, since palette entries can be set
# individually for each slider handle, but Windows 7 does not
# use them. This seems to be the only way to override the colors
# regardless of platform.
self.dummyStartSlider = QtWidgets.QSlider() self.dummyStartSlider = QtWidgets.QSlider()
self.dummyStopSlider = QtWidgets.QSlider() self.dummyStopSlider = QtWidgets.QSlider()
self.dummyStartSlider.setStyleSheet( self.dummyStartSlider.setStyleSheet(
@ -86,19 +83,6 @@ class ScanSlider(QtWidgets.QSlider):
self.dummyStopSlider.setStyleSheet( self.dummyStopSlider.setStyleSheet(
"QSlider::handle {background:red}") "QSlider::handle {background:red}")
# We basically superimpose two QSliders on top of each other, discarding
# the state that remains constant between the two when drawing.
# Everything except the handles remain constant.
def initHandleStyleOption(self, opt, handle):
self.initStyleOption(opt)
if handle == "start":
opt.sliderPosition = self.startPos
opt.sliderValue = self.startVal
elif handle == "stop":
opt.sliderPosition = self.stopPos
opt.sliderValue = self.stopVal
# We get the range of each slider separately.
def pixelPosToRangeValue(self, pos): def pixelPosToRangeValue(self, pos):
opt = QtWidgets.QStyleOptionSlider() opt = QtWidgets.QStyleOptionSlider()
self.initStyleOption(opt) self.initStyleOption(opt)
@ -137,98 +121,59 @@ class ScanSlider(QtWidgets.QSlider):
self) self)
return gr.width() - self.handleWidth() return gr.width() - self.handleWidth()
def handleMousePress(self, pos, control, val, handle): def _getStyleOptionSlider(self, val):
opt = QtWidgets.QStyleOptionSlider()
self.initHandleStyleOption(opt, handle)
startAtEdges = (handle == "start" and
(self.startVal == self.minimum() or
self.startVal == self.maximum()))
stopAtEdges = (handle == "stop" and
(self.stopVal == self.minimum() or
self.stopVal == self.maximum()))
# If chosen slider at edge, treat it as non-interactive.
if startAtEdges or stopAtEdges:
return QtWidgets.QStyle.SC_None
oldControl = control
control = self.style().hitTestComplexControl(
QtWidgets.QStyle.CC_Slider, opt, pos, self)
sr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt,
QtWidgets.QStyle.SC_SliderHandle,
self)
if control == QtWidgets.QStyle.SC_SliderHandle:
# no pick()- slider orientation static
self.offset = pos.x() - sr.topLeft().x()
self.setSliderDown(True)
# emit
# Needed?
if control != oldControl:
self.update(sr)
return control
def drawHandle(self, painter, handle):
opt = QtWidgets.QStyleOptionSlider() opt = QtWidgets.QStyleOptionSlider()
self.initStyleOption(opt) self.initStyleOption(opt)
self.initHandleStyleOption(opt, handle) opt.sliderPosition = val
opt.sliderValue = val
opt.subControls = QtWidgets.QStyle.SC_SliderHandle opt.subControls = QtWidgets.QStyle.SC_SliderHandle
painter.drawComplexControl(QtWidgets.QStyle.CC_Slider, opt) return opt
def setSpan(self, low, high): def _hitHandle(self, pos, val):
# TODO: Is this necessary? QStyle::sliderPositionFromValue appears # If chosen slider at edge, treat it as non-interactive.
# to clamp already. if not (self.minimum() < val < self.maximum()):
low = min(max(self.minimum(), low), self.maximum()) return False
high = min(max(self.minimum(), high), self.maximum()) opt = self._getStyleOptionSlider(val)
control = self.style().hitTestComplexControl(
if low != self.startVal or high != self.stopVal: QtWidgets.QStyle.CC_Slider, opt, pos, self)
if low != self.startVal: if control != QtWidgets.QStyle.SC_SliderHandle:
self.startVal = low return False
self.startPos = low sr = self.style().subControlRect(
if high != self.stopVal: QtWidgets.QStyle.CC_Slider, opt,
self.stopVal = high QtWidgets.QStyle.SC_SliderHandle, self)
self.stopPos = high self.offset = pos.x() - sr.topLeft().x()
self.update() self.setSliderDown(True)
# Needed?
self.update(sr)
return True
def setStartPosition(self, val): def setStartPosition(self, val):
if val != self.startPos: if val == self.startVal:
self.startPos = val return
if not self.hasTracking(): self.startVal = val
self.update() self.update()
if self.isSliderDown():
self.sigStartMoved.emit(self.startPos)
if self.hasTracking() and not self.blockTracking:
self.setSpan(self.startPos, self.stopVal)
def setStopPosition(self, val): def setStopPosition(self, val):
if val != self.stopPos: if val == self.stopVal:
self.stopPos = val return
if not self.hasTracking(): self.stopVal = val
self.update() self.update()
if self.isSliderDown():
self.sigStopMoved.emit(self.stopPos)
if self.hasTracking() and not self.blockTracking:
self.setSpan(self.startVal, self.stopPos)
def mousePressEvent(self, ev): def mousePressEvent(self, ev):
if ev.buttons() ^ ev.button(): if ev.buttons() ^ ev.button():
ev.ignore() ev.ignore()
return return
# Prefer stopVal in the default case. # Prefer stopVal in the default case.
self.upperPressed = self.handleMousePress( if self._hitHandle(ev.pos(), self.stopVal):
ev.pos(), self.upperPressed, self.stopVal, "stop") self.pressed = "stop"
if self.upperPressed != QtWidgets.QStyle.SC_SliderHandle: elif self._hitHandle(ev.pos(), self.startVal):
self.lowerPressed = self.handleMousePress( self.pressed = "start"
ev.pos(), self.upperPressed, self.startVal, "start") else:
self.pressed = None
# State that is needed to handle the case where two sliders are equal.
self.firstMovement = True
ev.accept() ev.accept()
def mouseMoveEvent(self, ev): def mouseMoveEvent(self, ev):
if (self.lowerPressed != QtWidgets.QStyle.SC_SliderHandle and if not self.pressed:
self.upperPressed != QtWidgets.QStyle.SC_SliderHandle):
ev.ignore() ev.ignore()
return return
@ -246,49 +191,38 @@ class ScanSlider(QtWidgets.QSlider):
if not r.contains(ev.pos()): if not r.contains(ev.pos()):
newPos = self.position newPos = self.position
if self.firstMovement: if self.pressed == "start":
if self.startPos == self.stopPos:
# StopSlider is preferred, except in the case where
# start == max possible value the slider can take.
if self.startPos == self.maximum():
self.lowerPressed = QtWidgets.QStyle.SC_SliderHandle
self.upperPressed = QtWidgets.QStyle.SC_None
self.firstMovement = False
if self.lowerPressed == QtWidgets.QStyle.SC_SliderHandle:
self.setStartPosition(newPos) self.setStartPosition(newPos)
if self.hasTracking():
if self.upperPressed == QtWidgets.QStyle.SC_SliderHandle: self.sigStartMoved.emit(self.startVal)
elif self.pressed == "stop":
self.setStopPosition(newPos) self.setStopPosition(newPos)
if self.hasTracking():
self.sigStopMoved.emit(self.stopVal)
ev.accept() ev.accept()
def mouseReleaseEvent(self, ev): def mouseReleaseEvent(self, ev):
QtWidgets.QSlider.mouseReleaseEvent(self, ev) QtWidgets.QSlider.mouseReleaseEvent(self, ev)
self.setSliderDown(False) # AbstractSlider needs this self.setSliderDown(False) # AbstractSlider needs this
self.lowerPressed = QtWidgets.QStyle.SC_None if not self.hasTracking():
self.upperPressed = QtWidgets.QStyle.SC_None if self.pressed == "start":
self.sigStartMoved.emit(self.startVal)
elif self.pressed == "stop":
self.sigStopMoved.emit(self.stopVal)
self.pressed = None
def paintEvent(self, ev): def paintEvent(self, ev):
# Use QStylePainters to make redrawing as painless as possible. # Use the pre-parsed, styled sliders.
# Paint on the custom widget, using the attributes of the fake
# slider references we keep around. setStyleSheet within paintEvent
# leads to heavy performance penalties (and recursion?).
# QPalettes would be nicer to use, since palette entries can be set
# individually for each slider handle, but Windows 7 does not
# use them. This seems to be the only way to override the colors
# regardless of platform.
startPainter = QtWidgets.QStylePainter(self, self.dummyStartSlider) startPainter = QtWidgets.QStylePainter(self, self.dummyStartSlider)
stopPainter = QtWidgets.QStylePainter(self, self.dummyStopSlider) stopPainter = QtWidgets.QStylePainter(self, self.dummyStopSlider)
# Only draw handles that are not railed
# Handles
# Qt will snap sliders to 0 or maximum() if given a desired pixel
# location outside the mapped range. So we manually just don't draw
# the handles if they are at 0 or max.
if self.minimum() < self.startVal < self.maximum(): if self.minimum() < self.startVal < self.maximum():
self.drawHandle(startPainter, "start") opt = self._getStyleOptionSlider(self.startVal)
startPainter.drawComplexControl(QtWidgets.QStyle.CC_Slider, opt)
if self.minimum() < self.stopVal < self.maximum(): if self.minimum() < self.stopVal < self.maximum():
self.drawHandle(stopPainter, "stop") opt = self._getStyleOptionSlider(self.stopVal)
stopPainter.drawComplexControl(QtWidgets.QStyle.CC_Slider, opt)
# real (Sliders) => pixel (one pixel movement of sliders would increment by X) # real (Sliders) => pixel (one pixel movement of sliders would increment by X)
@ -320,27 +254,20 @@ class ScanWidget(QtWidgets.QWidget):
snapRangeAct.triggered.connect(self.snapRange) snapRangeAct.triggered.connect(self.snapRange)
self.menu.addAction(snapRangeAct) self.menu.addAction(snapRangeAct)
self.realStart = -1. self.realStart = None
self.realStop = 1. self.realStop = None
self.numPoints = 11 self.numPoints = None
self.zoomMargin = zoomMargin self.zoomMargin = zoomMargin
self.dynamicRange = dynamicRange self.dynamicRange = dynamicRange
self.zoomFactor = zoomFactor self.zoomFactor = zoomFactor
# Transform that maps the spinboxes to a pixel position on the
# axis. 0 to axis.width() exclusive indicate positions which will be
# displayed on the axis.
# Because the axis's width will change when placed within a layout,
# the realToPixelTransform will initially be invalid. It will be set
# properly during the first resizeEvent, with the below transform.
self.realToPixelTransform = -self.axis.width()/2, 1. self.realToPixelTransform = -self.axis.width()/2, 1.
self.invalidOldSizeExpected = True
# Connect event observers. # Connect event observers.
axis.installEventFilter(self) axis.installEventFilter(self)
slider.installEventFilter(self) slider.installEventFilter(self)
slider.sigStopMoved.connect(self.handleStopMoved) slider.sigStopMoved.connect(self._handleStopMoved)
slider.sigStartMoved.connect(self.handleStartMoved) slider.sigStartMoved.connect(self._handleStartMoved)
def contextMenuEvent(self, ev): def contextMenuEvent(self, ev):
self.menu.popup(ev.globalPos()) self.menu.popup(ev.globalPos())
@ -353,7 +280,6 @@ class ScanWidget(QtWidgets.QWidget):
rawVal = min(max(-(1 << 31), rawVal), (1 << 31) - 1) rawVal = min(max(-(1 << 31), rawVal), (1 << 31) - 1)
return rawVal return rawVal
# Get a point from pixel units to what the sliders display.
def pixelToReal(self, val): def pixelToReal(self, val):
a, b = self.realToPixelTransform a, b = self.realToPixelTransform
return val/b + a return val/b + a
@ -366,42 +292,38 @@ class ScanWidget(QtWidgets.QWidget):
pixelVal = self.realToPixel(val) pixelVal = self.realToPixel(val)
return self.slider.pixelPosToRangeValue(pixelVal) return self.slider.pixelPosToRangeValue(pixelVal)
def setView(self, left, scale):
self.realToPixelTransform = left, scale
sliderX = self.realToRange(self.realStop)
self.slider.setStopPosition(sliderX)
sliderX = self.realToRange(self.realStart)
self.slider.setStartPosition(sliderX)
self.axis.update()
def setStop(self, val): def setStop(self, val):
if self.realStop == val:
return
sliderX = self.realToRange(val) sliderX = self.realToRange(val)
self.slider.setStopPosition(sliderX) self.slider.setStopPosition(sliderX)
self.realStop = val self.realStop = val
self.axis.update() # Number of points ticks changed positions. self.axis.update() # Number of points ticks changed positions.
self.sigStopMoved.emit(val)
def setStart(self, val): def setStart(self, val):
if self.realStart == val:
return
sliderX = self.realToRange(val) sliderX = self.realToRange(val)
self.slider.setStartPosition(sliderX) self.slider.setStartPosition(sliderX)
self.realStart = val self.realStart = val
self.axis.update() self.axis.update()
self.sigStartMoved.emit(val)
def setNumPoints(self, val): def setNumPoints(self, val):
if self.numPoints == val:
return
self.numPoints = val self.numPoints = val
self.axis.update() self.axis.update()
self.sigNumChanged.emit(val)
def handleStopMoved(self, rangeVal):
# FIXME: this relies on the event being fed back and ending up calling
# setStop()
self.sigStopMoved.emit(self.rangeToReal(rangeVal))
def handleStartMoved(self, rangeVal):
# FIXME: this relies on the event being fed back and ending up calling
# setStart()
self.sigStartMoved.emit(self.rangeToReal(rangeVal))
def handleZoom(self, zoomFactor, mouseXPos):
newScale = self.realToPixelTransform[1] * zoomFactor
refReal = self.pixelToReal(mouseXPos)
newLeft = refReal - mouseXPos/newScale
newZero = newLeft*newScale + self.slider.effectiveWidth()/2
if zoomFactor > 1 and abs(newZero) > self.dynamicRange:
return
self.realToPixelTransform = newLeft, newScale
self.setStop(self.realStop)
self.setStart(self.realStart)
def viewRange(self): def viewRange(self):
newScale = self.slider.effectiveWidth()/abs( newScale = self.slider.effectiveWidth()/abs(
@ -411,53 +333,39 @@ class ScanWidget(QtWidgets.QWidget):
if newCenter: if newCenter:
newScale = min(newScale, self.dynamicRange/abs(newCenter)) newScale = min(newScale, self.dynamicRange/abs(newCenter))
newLeft = newCenter - self.slider.effectiveWidth()/2/newScale newLeft = newCenter - self.slider.effectiveWidth()/2/newScale
self.realToPixelTransform = newLeft, newScale self.setView(newLeft, newScale)
self.setStop(self.realStop)
self.setStart(self.realStart)
self.axis.update() # Axis normally takes care to update itself during
# zoom. In this code path however, the zoom didn't arrive via the axis
# widget, so we need to notify manually.
# This function is called if the axis width, slider width, and slider
# positions are in an inconsistent state, to initialize the widget.
# This function handles handles the slider positions. Slider and axis
# handle its own width changes; proxy watches for axis width resizeEvent to
# alter mapping from real to pixel space.
def viewRangeInit(self):
currRangeReal = abs(self.realStop - self.realStart)
if currRangeReal == 0:
self.setStop(self.realStop)
self.setStart(self.realStart)
# Ill-formed snap range- move the sliders anyway,
# because we arrived here during widget
# initialization, where the slider positions are likely invalid.
# This will force the sliders to have positions on the axis
# which reflect the start/stop values currently set.
else:
self.viewRange()
# Notify spinboxes manually, since slider wasn't clicked and will
# therefore not emit signals.
self.sigStopMoved.emit(self.realStop)
self.sigStartMoved.emit(self.realStart)
def snapRange(self): def snapRange(self):
lowRange = self.zoomMargin lowRange = self.zoomMargin
highRange = 1 - self.zoomMargin highRange = 1 - self.zoomMargin
newStart = self.pixelToReal(lowRange * self.slider.effectiveWidth()) newStart = self.pixelToReal(lowRange * self.slider.effectiveWidth())
newStop = self.pixelToReal(highRange * self.slider.effectiveWidth()) newStop = self.pixelToReal(highRange * self.slider.effectiveWidth())
# Signals won't fire unless slider was actually grabbed, so self.setStart(newStart)
# manually update so the spinboxes know that knew values were set. self.setStop(newStop)
# self.realStop/Start and the sliders themselves will be updated as a
# consequence of ValueChanged signal in spinboxes. The slider widget
# has guards against recursive signals in setSpan().
# FIXME: this relies on the events being fed back and ending up
# calling setStart() and setStop()
self.sigStopMoved.emit(newStop)
self.sigStartMoved.emit(newStart)
def wheelEvent(self, ev): def _handleStartMoved(self, rangeVal):
val = self.rangeToReal(rangeVal)
self.realStart = val
self.axis.update()
self.sigStartMoved.emit(val)
def _handleStopMoved(self, rangeVal):
val = self.rangeToReal(rangeVal)
self.realStop = val
self.axis.update()
self.sigStopMoved.emit(val)
def _handleZoom(self, zoomFactor, mouseXPos):
newScale = self.realToPixelTransform[1] * zoomFactor
refReal = self.pixelToReal(mouseXPos)
newLeft = refReal - mouseXPos/newScale
newZero = newLeft*newScale + self.slider.effectiveWidth()/2
if zoomFactor > 1 and abs(newZero) > self.dynamicRange:
return
self.setView(newLeft, newScale)
def _wheelEvent(self, ev):
y = ev.angleDelta().y() y = ev.angleDelta().y()
if y:
if ev.modifiers() & QtCore.Qt.ShiftModifier: if ev.modifiers() & QtCore.Qt.ShiftModifier:
# If shift+scroll, modify number of points. # If shift+scroll, modify number of points.
# TODO: This is not perfect. For high-resolution touchpads you # TODO: This is not perfect. For high-resolution touchpads you
@ -465,26 +373,25 @@ class ScanWidget(QtWidgets.QWidget):
# That would also match the wheel behavior of an integer # That would also match the wheel behavior of an integer
# spinbox. # spinbox.
z = int(y / 120.) z = int(y / 120.)
# FIXME: this relies on the event being fed back and ending up if z:
# calling setNumPoints() self.setNumPoints(max(1, self.numPoints + z))
self.sigNumChanged.emit(self.numPoints + z)
self.axis.update()
ev.accept() ev.accept()
elif ev.modifiers() & QtCore.Qt.ControlModifier: elif ev.modifiers() & QtCore.Qt.ControlModifier:
z = self.zoomFactor**(y / 120.)
# Remove the slider-handle shift correction, b/c none of the # Remove the slider-handle shift correction, b/c none of the
# other widgets know about it. If we have the mouse directly # other widgets know about it. If we have the mouse directly
# over a tick during a zoom, it should appear as if we are # over a tick during a zoom, it should appear as if we are
# doing zoom relative to the ticks which live in axis # doing zoom relative to the ticks which live in axis
# pixel-space, not slider pixel-space. # pixel-space, not slider pixel-space.
self.handleZoom(z, ev.x() - self.slider.handleWidth()/2) if y:
z = self.zoomFactor**(y / 120.)
self._handleZoom(z, ev.x() - self.slider.handleWidth()/2)
ev.accept() ev.accept()
else: else:
ev.ignore() ev.ignore()
def eventFilter(self, obj, ev): def eventFilter(self, obj, ev):
if ev.type() == QtCore.QEvent.Wheel: if ev.type() == QtCore.QEvent.Wheel:
self.wheelEvent(ev) self._wheelEvent(ev)
return True return True
if not (obj is self.axis and ev.type() == QtCore.QEvent.Resize): if not (obj is self.axis and ev.type() == QtCore.QEvent.Resize):
return False return False
@ -497,24 +404,7 @@ class ScanWidget(QtWidgets.QWidget):
center = (self.realStop + self.realStart)/2 center = (self.realStop + self.realStart)/2
if center: if center:
newScale = min(newScale, self.dynamicRange/abs(center)) newScale = min(newScale, self.dynamicRange/abs(center))
self.realToPixelTransform = oldLeft, newScale self.setView(oldLeft, newScale)
else: else:
# TODO: self.axis.width() is invalid during object self.viewRange()
# construction. The width will change when placed in a
# layout WITHOUT a resizeEvent. Why?
oldLeft = -ev.size().width()/2
newScale = 1.0
self.realToPixelTransform = oldLeft, newScale
# We need to reinitialize the pixel transform b/c the old width
# of the axis is no longer valid. When we have a valid transform,
# we can then viewRange based on the desired real values.
# The slider handle values are invalid before this point as well;
# we set them to the correct value here, regardless of whether
# the slider has already resized itsef or not.
self.viewRangeInit()
self.invalidOldSizeExpected = False
# Slider will update independently, making sure that the old
# slider positions are preserved. Because of this, we can be
# confident that the new slider position will still map to the
# same positions in the new axis-space.
return False return False