From d858ad4180e0410bee09269369a1bd3f23513624 Mon Sep 17 00:00:00 2001 From: Robert Jordens Date: Wed, 16 Mar 2016 15:13:31 +0100 Subject: [PATCH] scanwidget: rewrite, pending drawing area (3de1505) --- artiq/gui/entries.py | 16 +- artiq/gui/scanwidget.py | 537 ++++++++++++++-------------------------- 2 files changed, 195 insertions(+), 358 deletions(-) diff --git a/artiq/gui/entries.py b/artiq/gui/entries.py index 161f3f342..b6a467fa3 100644 --- a/artiq/gui/entries.py +++ b/artiq/gui/entries.py @@ -158,8 +158,6 @@ class _RangeScan(LayoutWidget): scanner.setMinimumSize(150, 0) scanner.setSizePolicy(QtWidgets.QSizePolicy( QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed)) - disable_scroll_wheel(scanner.axis) - disable_scroll_wheel(scanner.slider) self.addWidget(scanner, 0, 0, -1, 1) self.min = ScientificSpinBox() @@ -191,17 +189,17 @@ class _RangeScan(LayoutWidget): def update_npoints(value): state["npoints"] = value - scanner.setNumPoints(value) + scanner.setNum(value) - scanner.sigStartMoved.connect(self.min.setValue) - scanner.sigNumChanged.connect(self.npoints.setValue) - scanner.sigStopMoved.connect(self.max.setValue) + scanner.startChanged.connect(self.min.setValue) + scanner.numChanged.connect(self.npoints.setValue) + scanner.stopChanged.connect(self.max.setValue) self.min.valueChanged.connect(update_min) self.npoints.valueChanged.connect(update_npoints) self.max.valueChanged.connect(update_max) - self.min.setValue(state["min"]/scale) - self.npoints.setValue(state["npoints"]) - self.max.setValue(state["max"]/scale) + scanner.setStart(state["min"]/scale) + scanner.setNum(state["npoints"]) + scanner.setStop(state["max"]/scale) apply_properties(self.min) apply_properties(self.max) diff --git a/artiq/gui/scanwidget.py b/artiq/gui/scanwidget.py index 6edb57fdd..77715321e 100644 --- a/artiq/gui/scanwidget.py +++ b/artiq/gui/scanwidget.py @@ -1,7 +1,7 @@ import logging from PyQt5 import QtGui, QtCore, QtWidgets -from numpy import linspace +import numpy as np from .ticker import Ticker @@ -9,119 +9,111 @@ from .ticker import Ticker logger = logging.getLogger(__name__) -class ScanAxis(QtWidgets.QWidget): - def __init__(self): - QtWidgets.QWidget.__init__(self) - self.proxy = None - 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) +class ScanWidget(QtWidgets.QSlider): + startChanged = QtCore.pyqtSignal(float) + stopChanged = QtCore.pyqtSignal(float) + numChanged = QtCore.pyqtSignal(int) - def paintEvent(self, ev): - 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): + def __init__(self, zoomFactor=1.05, zoomMargin=.1, dynamicRange=1e9): QtWidgets.QSlider.__init__(self, QtCore.Qt.Horizontal) - self.startVal = None - self.stopVal = None - self.offset = None - self.position = None - self.pressed = None + self.zoomMargin = zoomMargin + self.dynamicRange = dynamicRange + self.zoomFactor = zoomFactor - self.setRange(0, (1 << 15) - 1) + self.ticker = Ticker() - # We need fake sliders to keep around so that we can dynamically - # 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.dummyStopSlider = QtWidgets.QSlider() - self.dummyStartSlider.setStyleSheet( - "QSlider::handle {background:blue}") - self.dummyStopSlider.setStyleSheet( - "QSlider::handle {background:red}") + self.menu = QtWidgets.QMenu(self) + action = QtWidgets.QAction("&View Range", self) + action.triggered.connect(self.viewRange) + self.menu.addAction(action) + action = QtWidgets.QAction("&Snap Range", self) + action.triggered.connect(self.snapRange) + self.menu.addAction(action) - 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() self.initStyleOption(opt) - gr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt, - QtWidgets.QStyle.SC_SliderGroove, - self) - rangeVal = QtWidgets.QStyle.sliderValueFromPosition( - self.minimum(), self.maximum(), pos - gr.x(), - self.effectiveWidth(), opt.upsideDown) - return rangeVal + g = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt, + QtWidgets.QStyle.SC_SliderGroove, + self) + h = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt, + QtWidgets.QStyle.SC_SliderHandle, + self) + slider_left = g.x() + h.width()/2 + slider_scale = (self.maximum() - self.minimum())/( + g.width() - h.width()) - def rangeValueToPixelPos(self, val): - opt = QtWidgets.QStyleOptionSlider() - self.initStyleOption(opt) - pixel = QtWidgets.QStyle.sliderPositionFromValue( - self.minimum(), self.maximum(), val, self.effectiveWidth(), - opt.upsideDown) - return pixel + self._axisView = axis_left, axis_scale + self._sliderView = ((axis_left - slider_left)*slider_scale, + axis_scale*slider_scale) + self.update() - # When calculating conversions to/from pixel space, not all of the slider's - # width is actually usable, because the slider handle has a nonzero width. - # We use this function as a helper when the axis needs slider information. - def handleWidth(self): - opt = QtWidgets.QStyleOptionSlider() - self.initStyleOption(opt) - sr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt, - QtWidgets.QStyle.SC_SliderHandle, - self) - return sr.width() + def setStart(self, val): + if self._start == val: + return + self._start = val + self.update() + self.startChanged.emit(val) - def effectiveWidth(self): - opt = QtWidgets.QStyleOptionSlider() - self.initStyleOption(opt) - gr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt, - QtWidgets.QStyle.SC_SliderGroove, - self) - return gr.width() - self.handleWidth() + def setStop(self, val): + if self._stop == val: + return + self._stop = val + self.update() + self.stopChanged.emit(val) + + 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): + a, b = self._sliderView + val = a + val*b + if not (self.minimum() <= val <= self.maximum()): + return None opt = QtWidgets.QStyleOptionSlider() self.initStyleOption(opt) opt.sliderPosition = val @@ -130,284 +122,131 @@ class ScanSlider(QtWidgets.QSlider): return opt 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) + if not opt: + return False control = self.style().hitTestComplexControl( QtWidgets.QStyle.CC_Slider, opt, pos, self) if control != QtWidgets.QStyle.SC_SliderHandle: return False - sr = self.style().subControlRect( - QtWidgets.QStyle.CC_Slider, opt, - QtWidgets.QStyle.SC_SliderHandle, self) - self.offset = pos.x() - sr.topLeft().x() + sr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt, + QtWidgets.QStyle.SC_SliderHandle, + self) + self._offset = pos.x() - sr.center().x() self.setSliderDown(True) - # Needed? - self.update(sr) 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): if ev.buttons() ^ ev.button(): ev.ignore() return - # Prefer stopVal in the default case. - if self._hitHandle(ev.pos(), self.stopVal): - self.pressed = "stop" - elif self._hitHandle(ev.pos(), self.startVal): - self.pressed = "start" + if self._hitHandle(ev.pos(), self._stop): + self._pressed = "stop" + elif self._hitHandle(ev.pos(), self._start): + self._pressed = "start" else: - self.pressed = None - ev.accept() + self._pressed = None def mouseMoveEvent(self, ev): - if not self.pressed: + if not self._pressed: ev.ignore() return - - opt = QtWidgets.QStyleOptionSlider() - self.initStyleOption(opt) - - # 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) + val = self._pixelToAxis(ev.pos().x() - self._offset) + if self._pressed == "stop": + self._stop = val + self.update() if self.hasTracking(): - self.sigStartMoved.emit(self.startVal) - elif self.pressed == "stop": - self.setStopPosition(newPos) + self.stopChanged.emit(val) + elif self._pressed == "start": + self._start = val + self.update() if self.hasTracking(): - self.sigStopMoved.emit(self.stopVal) - - ev.accept() + self.startChanged.emit(val) def mouseReleaseEvent(self, ev): QtWidgets.QSlider.mouseReleaseEvent(self, ev) - self.setSliderDown(False) # AbstractSlider needs this - if not self.hasTracking(): - if self.pressed == "start": - self.sigStartMoved.emit(self.startVal) - elif self.pressed == "stop": - self.sigStopMoved.emit(self.stopVal) - self.pressed = None + self.setSliderDown(False) + if self._pressed == "start": + self.startChanged.emit(self._start) + elif self._pressed == "stop": + self.stopChanged.emit(self._stop) + self._pressed = None - def paintEvent(self, ev): - # Use the pre-parsed, styled sliders. - startPainter = QtWidgets.QStylePainter(self, self.dummyStartSlider) - stopPainter = QtWidgets.QStylePainter(self, self.dummyStopSlider) - # Only draw handles that are not railed - 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: + def _zoom(self, z, x): + a, b = self._axisView + scale = z*b + left = x - z*(x - a) + if z > 1 and abs(left - self.width()/2) > self.dynamicRange: return - sliderX = self.realToRange(val) - 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) + self._setView(left, scale) def wheelEvent(self, ev): - y = ev.angleDelta().y() + y = ev.angleDelta().y()/120. 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: - z = self.zoomFactor**(y / 120.) - self._handleZoom(z, ev.x() - self.slider.handleWidth()/2) - ev.accept() + self.setNum(max(1, self._num + y)) + elif ev.modifiers() & QtCore.Qt.ControlModifier: + if y: + self._zoom(self.zoomFactor**y, ev.x()) else: ev.ignore() def resizeEvent(self, ev): - if 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: + if not ev.oldSize().isValid(): 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): - if ev.type() == QtCore.QEvent.Wheel: - ev.ignore() - return True - if ev.type() == QtCore.QEvent.Resize: - ev.ignore() - return True - return False + def paintEvent(self, ev): + self._paintSliders() + self._paintAxis() + + def _paintAxis(self): + painter = QtGui.QPainter(self) + qfm = QtGui.QFontMetrics(painter.font()) + 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)