scanwidget: rewrite, pending drawing area (3de1505)

This commit is contained in:
Robert Jördens 2016-03-16 15:13:31 +01:00
parent 0e1f75ec49
commit d858ad4180
2 changed files with 195 additions and 358 deletions

View File

@ -158,8 +158,6 @@ class _RangeScan(LayoutWidget):
scanner.setMinimumSize(150, 0) scanner.setMinimumSize(150, 0)
scanner.setSizePolicy(QtWidgets.QSizePolicy( scanner.setSizePolicy(QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed)) QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed))
disable_scroll_wheel(scanner.axis)
disable_scroll_wheel(scanner.slider)
self.addWidget(scanner, 0, 0, -1, 1) self.addWidget(scanner, 0, 0, -1, 1)
self.min = ScientificSpinBox() self.min = ScientificSpinBox()
@ -191,17 +189,17 @@ class _RangeScan(LayoutWidget):
def update_npoints(value): def update_npoints(value):
state["npoints"] = value state["npoints"] = value
scanner.setNumPoints(value) scanner.setNum(value)
scanner.sigStartMoved.connect(self.min.setValue) scanner.startChanged.connect(self.min.setValue)
scanner.sigNumChanged.connect(self.npoints.setValue) scanner.numChanged.connect(self.npoints.setValue)
scanner.sigStopMoved.connect(self.max.setValue) scanner.stopChanged.connect(self.max.setValue)
self.min.valueChanged.connect(update_min) self.min.valueChanged.connect(update_min)
self.npoints.valueChanged.connect(update_npoints) self.npoints.valueChanged.connect(update_npoints)
self.max.valueChanged.connect(update_max) self.max.valueChanged.connect(update_max)
self.min.setValue(state["min"]/scale) scanner.setStart(state["min"]/scale)
self.npoints.setValue(state["npoints"]) scanner.setNum(state["npoints"])
self.max.setValue(state["max"]/scale) scanner.setStop(state["max"]/scale)
apply_properties(self.min) apply_properties(self.min)
apply_properties(self.max) apply_properties(self.max)

View File

@ -1,7 +1,7 @@
import logging import logging
from PyQt5 import QtGui, QtCore, QtWidgets from PyQt5 import QtGui, QtCore, QtWidgets
from numpy import linspace import numpy as np
from .ticker import Ticker from .ticker import Ticker
@ -9,119 +9,111 @@ from .ticker import Ticker
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ScanAxis(QtWidgets.QWidget): class ScanWidget(QtWidgets.QSlider):
def __init__(self): startChanged = QtCore.pyqtSignal(float)
QtWidgets.QWidget.__init__(self) stopChanged = QtCore.pyqtSignal(float)
self.proxy = None numChanged = QtCore.pyqtSignal(int)
self.sizePolicy().setControlType(QtWidgets.QSizePolicy.ButtonBox)
self.ticker = Ticker()
qfm = QtGui.QFontMetrics(QtGui.QFont())
lineSpacing = qfm.lineSpacing()
descent = qfm.descent()
self.setMinimumHeight(2*lineSpacing + descent + 5 + 5)
def paintEvent(self, ev): def __init__(self, zoomFactor=1.05, zoomMargin=.1, dynamicRange=1e9):
painter = QtGui.QPainter(self)
qfm = QtGui.QFontMetrics(painter.font())
avgCharWidth = qfm.averageCharWidth()
lineSpacing = qfm.lineSpacing()
descent = qfm.descent()
painter.setRenderHint(QtGui.QPainter.Antialiasing)
# The center of the slider handles should reflect what's displayed
# on the spinboxes.
painter.translate(self.proxy.slider.handleWidth()/2, self.height() - 5)
painter.drawLine(0, 0, self.width(), 0)
realLeft = self.proxy.pixelToReal(0)
realRight = self.proxy.pixelToReal(self.width())
ticks, prefix, labels = self.ticker(realLeft, realRight)
painter.drawText(0, -5-descent-lineSpacing, prefix)
pen = QtGui.QPen()
pen.setWidth(2)
painter.setPen(pen)
for t, l in zip(ticks, labels):
t = self.proxy.realToPixel(t)
painter.drawLine(t, 0, t, -5)
painter.drawText(t - len(l)/2*avgCharWidth, -5-descent, l)
sliderStartPixel = self.proxy.realToPixel(self.proxy.realStart)
sliderStopPixel = self.proxy.realToPixel(self.proxy.realStop)
pixels = linspace(sliderStartPixel, sliderStopPixel,
self.proxy.numPoints)
for p in pixels:
p_int = int(p)
painter.drawLine(p_int, 0, p_int, 5)
ev.accept()
# Basic ideas from https://gist.github.com/Riateche/27e36977f7d5ea72cf4f
class ScanSlider(QtWidgets.QSlider):
sigStartMoved = QtCore.pyqtSignal(int)
sigStopMoved = QtCore.pyqtSignal(int)
def __init__(self):
QtWidgets.QSlider.__init__(self, QtCore.Qt.Horizontal) QtWidgets.QSlider.__init__(self, QtCore.Qt.Horizontal)
self.startVal = None self.zoomMargin = zoomMargin
self.stopVal = None self.dynamicRange = dynamicRange
self.offset = None self.zoomFactor = zoomFactor
self.position = None
self.pressed = None
self.setRange(0, (1 << 15) - 1) self.ticker = Ticker()
# We need fake sliders to keep around so that we can dynamically self.menu = QtWidgets.QMenu(self)
# set the stylesheets for drawing each slider later. See paintEvent. action = QtWidgets.QAction("&View Range", self)
# QPalettes would be nicer to use, since palette entries can be set action.triggered.connect(self.viewRange)
# individually for each slider handle, but Windows 7 does not self.menu.addAction(action)
# use them. This seems to be the only way to override the colors action = QtWidgets.QAction("&Snap Range", self)
# regardless of platform. action.triggered.connect(self.snapRange)
self.dummyStartSlider = QtWidgets.QSlider() self.menu.addAction(action)
self.dummyStopSlider = QtWidgets.QSlider()
self.dummyStartSlider.setStyleSheet(
"QSlider::handle {background:blue}")
self.dummyStopSlider.setStyleSheet(
"QSlider::handle {background:red}")
def pixelPosToRangeValue(self, pos): self._startSlider = QtWidgets.QSlider()
self._startSlider.setStyleSheet("QSlider::handle {background:blue}")
self._stopSlider = QtWidgets.QSlider()
self._stopSlider.setStyleSheet("QSlider::handle {background:red}")
self.setRange(0, 4095)
self._start, self._stop, self._num = None, None, None
self._axisView, self._sliderView = None, None
self._offset, self._pressed = None, None
def contextMenuEvent(self, ev):
self.menu.popup(ev.globalPos())
def _axisToPixel(self, val):
a, b = self._axisView
return a + val*b
def _pixelToAxis(self, val):
a, b = self._axisView
return (val - a)/b
def _setView(self, axis_left, axis_scale):
opt = QtWidgets.QStyleOptionSlider() opt = QtWidgets.QStyleOptionSlider()
self.initStyleOption(opt) self.initStyleOption(opt)
gr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt, g = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt,
QtWidgets.QStyle.SC_SliderGroove, QtWidgets.QStyle.SC_SliderGroove,
self) self)
rangeVal = QtWidgets.QStyle.sliderValueFromPosition( h = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt,
self.minimum(), self.maximum(), pos - gr.x(), QtWidgets.QStyle.SC_SliderHandle,
self.effectiveWidth(), opt.upsideDown) self)
return rangeVal slider_left = g.x() + h.width()/2
slider_scale = (self.maximum() - self.minimum())/(
g.width() - h.width())
def rangeValueToPixelPos(self, val): self._axisView = axis_left, axis_scale
opt = QtWidgets.QStyleOptionSlider() self._sliderView = ((axis_left - slider_left)*slider_scale,
self.initStyleOption(opt) axis_scale*slider_scale)
pixel = QtWidgets.QStyle.sliderPositionFromValue( self.update()
self.minimum(), self.maximum(), val, self.effectiveWidth(),
opt.upsideDown)
return pixel
# When calculating conversions to/from pixel space, not all of the slider's def setStart(self, val):
# width is actually usable, because the slider handle has a nonzero width. if self._start == val:
# We use this function as a helper when the axis needs slider information. return
def handleWidth(self): self._start = val
opt = QtWidgets.QStyleOptionSlider() self.update()
self.initStyleOption(opt) self.startChanged.emit(val)
sr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt,
QtWidgets.QStyle.SC_SliderHandle,
self)
return sr.width()
def effectiveWidth(self): def setStop(self, val):
opt = QtWidgets.QStyleOptionSlider() if self._stop == val:
self.initStyleOption(opt) return
gr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt, self._stop = val
QtWidgets.QStyle.SC_SliderGroove, self.update()
self) self.stopChanged.emit(val)
return gr.width() - self.handleWidth()
def setNum(self, val):
if self._num == val:
return
self._num = val
self.update()
self.numChanged.emit(val)
def viewRange(self):
center = (self._stop + self._start)/2
scale = self.width()*(1 - 2*self.zoomMargin)
if self._stop != self._start:
scale /= abs(self._stop - self._start)
if center:
scale = min(scale, self.dynamicRange/abs(center))
else:
scale = self.dynamicRange
if center:
scale /= abs(center)
left = self.width()/2 - center*scale
self._setView(left, scale)
def snapRange(self):
self.setStart(self._pixelToAxis(self.zoomMargin*self.width()))
self.setStop(self._pixelToAxis((1 - self.zoomMargin)*self.width()))
def _getStyleOptionSlider(self, val): def _getStyleOptionSlider(self, val):
a, b = self._sliderView
val = a + val*b
if not (self.minimum() <= val <= self.maximum()):
return None
opt = QtWidgets.QStyleOptionSlider() opt = QtWidgets.QStyleOptionSlider()
self.initStyleOption(opt) self.initStyleOption(opt)
opt.sliderPosition = val opt.sliderPosition = val
@ -130,284 +122,131 @@ class ScanSlider(QtWidgets.QSlider):
return opt return opt
def _hitHandle(self, pos, val): def _hitHandle(self, pos, val):
# If chosen slider at edge, treat it as non-interactive.
if not (self.minimum() < val < self.maximum()):
return False
opt = self._getStyleOptionSlider(val) opt = self._getStyleOptionSlider(val)
if not opt:
return False
control = self.style().hitTestComplexControl( control = self.style().hitTestComplexControl(
QtWidgets.QStyle.CC_Slider, opt, pos, self) QtWidgets.QStyle.CC_Slider, opt, pos, self)
if control != QtWidgets.QStyle.SC_SliderHandle: if control != QtWidgets.QStyle.SC_SliderHandle:
return False return False
sr = self.style().subControlRect( sr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt,
QtWidgets.QStyle.CC_Slider, opt, QtWidgets.QStyle.SC_SliderHandle,
QtWidgets.QStyle.SC_SliderHandle, self) self)
self.offset = pos.x() - sr.topLeft().x() self._offset = pos.x() - sr.center().x()
self.setSliderDown(True) self.setSliderDown(True)
# Needed?
self.update(sr)
return True return True
def setStartPosition(self, val):
if val == self.startVal:
return
self.startVal = val
self.update()
def setStopPosition(self, val):
if val == self.stopVal:
return
self.stopVal = val
self.update()
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. if self._hitHandle(ev.pos(), self._stop):
if self._hitHandle(ev.pos(), self.stopVal): self._pressed = "stop"
self.pressed = "stop" elif self._hitHandle(ev.pos(), self._start):
elif self._hitHandle(ev.pos(), self.startVal): self._pressed = "start"
self.pressed = "start"
else: else:
self.pressed = None self._pressed = None
ev.accept()
def mouseMoveEvent(self, ev): def mouseMoveEvent(self, ev):
if not self.pressed: if not self._pressed:
ev.ignore() ev.ignore()
return return
val = self._pixelToAxis(ev.pos().x() - self._offset)
opt = QtWidgets.QStyleOptionSlider() if self._pressed == "stop":
self.initStyleOption(opt) self._stop = val
self.update()
# This code seems to be needed so that returning the slider to the
# previous position is honored if a drag distance is exceeded.
m = self.style().pixelMetric(QtWidgets.QStyle.PM_MaximumDragDistance,
opt, self)
newPos = self.pixelPosToRangeValue(ev.pos().x() - self.offset)
if m >= 0:
r = self.rect().adjusted(-m, -m, m, m)
if not r.contains(ev.pos()):
newPos = self.position
if self.pressed == "start":
self.setStartPosition(newPos)
if self.hasTracking(): if self.hasTracking():
self.sigStartMoved.emit(self.startVal) self.stopChanged.emit(val)
elif self.pressed == "stop": elif self._pressed == "start":
self.setStopPosition(newPos) self._start = val
self.update()
if self.hasTracking(): if self.hasTracking():
self.sigStopMoved.emit(self.stopVal) self.startChanged.emit(val)
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)
if not self.hasTracking(): if self._pressed == "start":
if self.pressed == "start": self.startChanged.emit(self._start)
self.sigStartMoved.emit(self.startVal) elif self._pressed == "stop":
elif self.pressed == "stop": self.stopChanged.emit(self._stop)
self.sigStopMoved.emit(self.stopVal) self._pressed = None
self.pressed = None
def paintEvent(self, ev): def _zoom(self, z, x):
# Use the pre-parsed, styled sliders. a, b = self._axisView
startPainter = QtWidgets.QStylePainter(self, self.dummyStartSlider) scale = z*b
stopPainter = QtWidgets.QStylePainter(self, self.dummyStopSlider) left = x - z*(x - a)
# Only draw handles that are not railed if z > 1 and abs(left - self.width()/2) > self.dynamicRange:
if self.minimum() < self.startVal < self.maximum():
opt = self._getStyleOptionSlider(self.startVal)
startPainter.drawComplexControl(QtWidgets.QStyle.CC_Slider, opt)
if self.minimum() < self.stopVal < self.maximum():
opt = self._getStyleOptionSlider(self.stopVal)
stopPainter.drawComplexControl(QtWidgets.QStyle.CC_Slider, opt)
# real (Sliders) => pixel (one pixel movement of sliders would increment by X)
# => range (minimum granularity that sliders understand).
class ScanWidget(QtWidgets.QWidget):
sigStartMoved = QtCore.pyqtSignal(float)
sigStopMoved = QtCore.pyqtSignal(float)
sigNumChanged = QtCore.pyqtSignal(int)
def __init__(self, zoomFactor=1.05, zoomMargin=.1, dynamicRange=1e9):
QtWidgets.QWidget.__init__(self)
self.slider = slider = ScanSlider()
self.axis = axis = ScanAxis()
axis.proxy = self
# Layout.
layout = QtWidgets.QVBoxLayout()
layout.setSpacing(0)
layout.addWidget(axis)
layout.addWidget(slider)
self.setLayout(layout)
# Context menu entries
self.menu = QtWidgets.QMenu(self)
viewRangeAct = QtWidgets.QAction("&View Range", self)
viewRangeAct.triggered.connect(self.viewRange)
self.menu.addAction(viewRangeAct)
snapRangeAct = QtWidgets.QAction("&Snap Range", self)
snapRangeAct.triggered.connect(self.snapRange)
self.menu.addAction(snapRangeAct)
self.realStart = None
self.realStop = None
self.numPoints = None
self.zoomMargin = zoomMargin
self.dynamicRange = dynamicRange
self.zoomFactor = zoomFactor
self.realToPixelTransform = -self.axis.width()/2, 1.
# Connect event observers.
axis.installEventFilter(self)
slider.installEventFilter(self)
slider.sigStopMoved.connect(self._handleStopMoved)
slider.sigStartMoved.connect(self._handleStartMoved)
def contextMenuEvent(self, ev):
self.menu.popup(ev.globalPos())
# pixel vals for sliders: 0 to slider_width - 1
def realToPixel(self, val):
a, b = self.realToPixelTransform
rawVal = b*(val - a)
# Clamp pixel values to 32 bits, b/c Qt will otherwise wrap values.
rawVal = min(max(-(1 << 31), rawVal), (1 << 31) - 1)
return rawVal
def pixelToReal(self, val):
a, b = self.realToPixelTransform
return val/b + a
def rangeToReal(self, val):
pixelVal = self.slider.rangeValueToPixelPos(val)
return self.pixelToReal(pixelVal)
def realToRange(self, val):
pixelVal = self.realToPixel(val)
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):
if self.realStop == val:
return return
sliderX = self.realToRange(val) self._setView(left, scale)
self.slider.setStopPosition(sliderX)
self.realStop = val
self.axis.update() # Number of points ticks changed positions.
self.sigStopMoved.emit(val)
def setStart(self, val):
if self.realStart == val:
return
sliderX = self.realToRange(val)
self.slider.setStartPosition(sliderX)
self.realStart = val
self.axis.update()
self.sigStartMoved.emit(val)
def setNumPoints(self, val):
if self.numPoints == val:
return
self.numPoints = val
self.axis.update()
self.sigNumChanged.emit(val)
def viewRange(self):
newScale = self.slider.effectiveWidth()/abs(
self.realStop - self.realStart)
newScale *= 1 - 2*self.zoomMargin
newCenter = (self.realStop + self.realStart)/2
if newCenter:
newScale = min(newScale, self.dynamicRange/abs(newCenter))
newLeft = newCenter - self.slider.effectiveWidth()/2/newScale
self.setView(newLeft, newScale)
def snapRange(self):
lowRange = self.zoomMargin
highRange = 1 - self.zoomMargin
newStart = self.pixelToReal(lowRange * self.slider.effectiveWidth())
newStop = self.pixelToReal(highRange * self.slider.effectiveWidth())
self.setStart(newStart)
self.setStop(newStop)
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): def wheelEvent(self, ev):
y = ev.angleDelta().y() y = ev.angleDelta().y()/120.
if ev.modifiers() & QtCore.Qt.ShiftModifier: if ev.modifiers() & QtCore.Qt.ShiftModifier:
# If shift+scroll, modify number of points.
# TODO: This is not perfect. For high-resolution touchpads you
# get many small events with y < 120 which should accumulate.
# That would also match the wheel behavior of an integer
# spinbox.
z = int(y / 120.)
if z:
self.setNumPoints(max(1, self.numPoints + z))
ev.accept()
elif ev.modifiers() & QtCore.Qt.ControlModifier:
# Remove the slider-handle shift correction, b/c none of the
# other widgets know about it. If we have the mouse directly
# over a tick during a zoom, it should appear as if we are
# doing zoom relative to the ticks which live in axis
# pixel-space, not slider pixel-space.
if y: if y:
z = self.zoomFactor**(y / 120.) self.setNum(max(1, self._num + y))
self._handleZoom(z, ev.x() - self.slider.handleWidth()/2) elif ev.modifiers() & QtCore.Qt.ControlModifier:
ev.accept() if y:
self._zoom(self.zoomFactor**y, ev.x())
else: else:
ev.ignore() ev.ignore()
def resizeEvent(self, ev): def resizeEvent(self, ev):
if ev.oldSize().isValid(): if not ev.oldSize().isValid():
oldLeft = self.pixelToReal(0)
refWidth = ev.oldSize().width() - self.slider.handleWidth()
refRight = self.pixelToReal(refWidth)
newWidth = ev.size().width() - self.slider.handleWidth()
newScale = newWidth/(refRight - oldLeft)
center = (self.realStop + self.realStart)/2
if center:
newScale = min(newScale, self.dynamicRange/abs(center))
self.setView(oldLeft, newScale)
else:
self.viewRange() self.viewRange()
return
a, b = self._axisView
scale = b*ev.size().width()/ev.oldSize().width()
center = (self._stop + self._start)/2
if center:
scale = min(scale, self.dynamicRange/abs(center))
left = a*scale/b
self._setView(left, scale)
def eventFilter(self, obj, ev): def paintEvent(self, ev):
if ev.type() == QtCore.QEvent.Wheel: self._paintSliders()
ev.ignore() self._paintAxis()
return True
if ev.type() == QtCore.QEvent.Resize: def _paintAxis(self):
ev.ignore() painter = QtGui.QPainter(self)
return True qfm = QtGui.QFontMetrics(painter.font())
return False avgCharWidth = qfm.averageCharWidth()
lineSpacing = qfm.lineSpacing()
descent = qfm.descent()
ascent = qfm.ascent()
height = qfm.height()
# painter.setRenderHint(QtGui.QPainter.Antialiasing)
# TODO: make drawable area big enough and move axis higher
painter.translate(0, ascent - 15)
ticks, prefix, labels = self.ticker(self._pixelToAxis(0),
self._pixelToAxis(self.width()))
painter.drawText(0, 0, prefix)
pen = QtGui.QPen()
pen.setWidth(2)
painter.setPen(pen)
painter.translate(0, lineSpacing)
for t, l in zip(ticks, labels):
t = self._axisToPixel(t)
painter.drawLine(t, descent, t, height/2)
painter.drawText(t - len(l)/2*avgCharWidth, 0, l)
painter.drawLine(0, height/2, self.width(), height/2)
painter.translate(0, height)
for p in np.linspace(self._axisToPixel(self._start),
self._axisToPixel(self._stop),
self._num):
# TODO: is drawing far outside the viewport dangerous?
painter.drawLine(p, 0, p, -height/2)
def _paintSliders(self):
startPainter = QtWidgets.QStylePainter(self, self._startSlider)
stopPainter = QtWidgets.QStylePainter(self, self._stopSlider)
opt = self._getStyleOptionSlider(self._start)
if opt:
startPainter.drawComplexControl(QtWidgets.QStyle.CC_Slider, opt)
opt = self._getStyleOptionSlider(self._stop)
if opt:
stopPainter.drawComplexControl(QtWidgets.QStyle.CC_Slider, opt)