From 3ed82886019f054d7b5d5b679b5e099eb99cb6ec Mon Sep 17 00:00:00 2001 From: Robert Jordens Date: Thu, 25 Feb 2016 20:34:04 +0100 Subject: [PATCH 1/8] scanwidget: add from current git --- artiq/gui/scanwidget.py | 545 ++++++++++++++++++++++++++++++++ artiq/gui/scientific_spinbox.py | 71 +++++ artiq/gui/ticker.py | 136 ++++++++ 3 files changed, 752 insertions(+) create mode 100644 artiq/gui/scanwidget.py create mode 100644 artiq/gui/scientific_spinbox.py create mode 100644 artiq/gui/ticker.py diff --git a/artiq/gui/scanwidget.py b/artiq/gui/scanwidget.py new file mode 100644 index 000000000..20facb2c0 --- /dev/null +++ b/artiq/gui/scanwidget.py @@ -0,0 +1,545 @@ +from PyQt5 import QtGui, QtCore, QtWidgets + +from .ticker import Ticker + + +class ScanAxis(QtWidgets.QWidget): + sigZoom = QtCore.pyqtSignal(float, int) + + def __init__(self, zoomFactor): + QtWidgets.QWidget.__init__(self) + self.proxy = None + self.sizePolicy().setControlType(QtWidgets.QSizePolicy.ButtonBox) + self.ticker = Ticker() + self.zoomFactor = zoomFactor + + def paintEvent(self, ev): + painter = QtGui.QPainter(self) + font = painter.font() + avgCharWidth = QtGui.QFontMetrics(font).averageCharWidth() + 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) + realMin = self.proxy.pixelToReal(0) + realMax = self.proxy.pixelToReal(self.width()) + + ticks, prefix, labels = self.ticker(realMin, realMax) + for t, l in zip(ticks, labels): + t = self.proxy.realToPixel(t) + textCenter = (len(l)/2.0)*avgCharWidth + painter.drawLine(t, 5, t, -5) + painter.drawText(t - textCenter, -10, l) + painter.resetTransform() + painter.drawText(0, 10, prefix) + # TODO: + # QtWidgets.QWidget.paintEvent(self, ev)? + # ev.accept() ? + + def wheelEvent(self, ev): + y = ev.angleDelta().y() + if y: + z = self.zoomFactor**(y / 120.) + # 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. + self.sigZoom.emit(z, ev.x() - self.proxy.slider.handleWidth()/2) + self.update() + ev.accept() + + +# Basic ideas from https://gist.github.com/Riateche/27e36977f7d5ea72cf4f +class ScanSlider(QtWidgets.QSlider): + sigMinMoved = QtCore.pyqtSignal(int) + sigMaxMoved = QtCore.pyqtSignal(int) + noSlider, minSlider, maxSlider = range(3) + maxStyle = "QSlider::handle {background:red}" + minStyle = "QSlider::handle {background:blue}" + + def __init__(self): + QtWidgets.QSlider.__init__(self, QtCore.Qt.Horizontal) + self.minPos = 0 # Pos and Val can differ in event handling. + # perhaps prevPos and currPos is more accurate. + self.maxPos = 99 + self.minVal = 0 # lower + self.maxVal = 99 # upper + self.offset = 0 + self.position = 0 + self.lastPressed = ScanSlider.noSlider + self.selectedHandle = ScanSlider.minSlider + self.upperPressed = QtWidgets.QStyle.SC_None + self.lowerPressed = QtWidgets.QStyle.SC_None + self.firstMovement = False # State var for handling slider overlap. + self.blockTracking = False + + # We need fake sliders to keep around so that we can dynamically + # set the stylesheets for drawing each slider later. See paintEvent. + self.dummyMinSlider = QtWidgets.QSlider() + self.dummyMaxSlider = QtWidgets.QSlider() + self.dummyMinSlider.setStyleSheet(ScanSlider.minStyle) + self.dummyMaxSlider.setStyleSheet(ScanSlider.maxStyle) + + # 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 == ScanSlider.minSlider: + opt.sliderPosition = self.minPos + opt.sliderValue = self.minVal + elif handle == ScanSlider.maxSlider: + opt.sliderPosition = self.maxPos + opt.sliderValue = self.maxVal + else: + pass # AssertionErrors + + # We get the range of each slider separately. + def pixelPosToRangeValue(self, pos): + opt = QtWidgets.QStyleOptionSlider() + self.initStyleOption(opt) + + gr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt, + QtWidgets.QStyle.SC_SliderGroove, + self) + sr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt, + QtWidgets.QStyle.SC_SliderHandle, + self) + + sliderLength = sr.width() + sliderMin = gr.x() + # For historical reasons right() returns left()+width() - 1 + # x() is equivalent to left(). + sliderMax = gr.right() - sliderLength + 1 + return QtWidgets.QStyle.sliderValueFromPosition( + self.minimum(), self.maximum(), pos - sliderMin, + sliderMax - sliderMin, opt.upsideDown) + + def rangeValueToPixelPos(self, val): + opt = QtWidgets.QStyleOptionSlider() + self.initStyleOption(opt) + + gr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt, + QtWidgets.QStyle.SC_SliderGroove, + self) + sr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt, + QtWidgets.QStyle.SC_SliderHandle, + self) + + sliderLength = sr.width() + sliderMin = gr.x() + sliderMax = gr.right() - sliderLength + 1 + + pixel = QtWidgets.QStyle.sliderPositionFromValue( + self.minimum(), self.maximum(), val, sliderMax - sliderMin, + opt.upsideDown) + return pixel + + # 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 effectiveWidth(self): + opt = QtWidgets.QStyleOptionSlider() + self.initStyleOption(opt) + gr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt, + QtWidgets.QStyle.SC_SliderGroove, + self) + sliderLength = self.handleWidth() + sliderMin = gr.x() + sliderMax = gr.right() - sliderLength + 1 + return sliderMax - sliderMin + + # If groove and axis are not aligned (and they should be), we can use + # this function to calculate the offset between them. + def grooveX(self): + opt = QtWidgets.QStyleOptionSlider() + self.initStyleOption(opt) + gr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt, + QtWidgets.QStyle.SC_SliderGroove, + self) + return gr.x() + + def handleMousePress(self, pos, control, val, handle): + opt = QtWidgets.QStyleOptionSlider() + self.initHandleStyleOption(opt, handle) + 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.lastPressed = handle + self.setSliderDown(True) + self.selectedHandle = handle + # emit + + # Needed? + if control != oldControl: + self.update(sr) + return control + + def drawHandle(self, painter, handle): + opt = QtWidgets.QStyleOptionSlider() + self.initStyleOption(opt) + self.initHandleStyleOption(opt, handle) + opt.subControls = QtWidgets.QStyle.SC_SliderHandle + painter.drawComplexControl(QtWidgets.QStyle.CC_Slider, opt) + + # def triggerAction(self, action, slider): + # if action == QtWidgets.QAbstractSlider.SliderSingleStepAdd: + # if + + def setLowerValue(self, val): + self.setSpan(val, self.maxVal) + + def setUpperValue(self, val): + self.setSpan(self.minVal, val) + + def setSpan(self, lower, upper): + def bound(min, curr, max): + if curr < min: + return min + elif curr > max: + return max + else: + return curr + + low = bound(self.minimum(), lower, self.maximum()) + high = bound(self.minimum(), upper, self.maximum()) + + if low != self.minVal or high != self.maxVal: + if low != self.minVal: + self.minVal = low + self.minPos = low + # emit + if high != self.maxVal: + self.maxVal = high + self.maxPos = high + # emit + # emit spanChanged + self.update() + + def setLowerPosition(self, val): + if val != self.minPos: + self.minPos = val + if not self.hasTracking(): + self.update() + if self.isSliderDown(): + self.sigMinMoved.emit(self.minPos) + if self.hasTracking() and not self.blockTracking: + self.setLowerValue(val) + + def setUpperPosition(self, val): + if val != self.maxPos: + self.maxPos = val + if not self.hasTracking(): + self.update() + if self.isSliderDown(): + self.sigMaxMoved.emit(self.maxPos) + if self.hasTracking() and not self.blockTracking: + self.setUpperValue(val) + + def mousePressEvent(self, ev): + if self.minimum() == self.maximum() or (ev.buttons() ^ ev.button()): + ev.ignore() + return + + # Prefer maxVal in the default case. + self.upperPressed = self.handleMousePress( + ev.pos(), self.upperPressed, self.maxVal, ScanSlider.maxSlider) + if self.upperPressed != QtWidgets.QStyle.SC_SliderHandle: + self.lowerPressed = self.handleMousePress( + ev.pos(), self.upperPressed, self.minVal, ScanSlider.minSlider) + + # State that is needed to handle the case where two sliders are equal. + self.firstMovement = True + ev.accept() + + def mouseMoveEvent(self, ev): + if (self.lowerPressed != QtWidgets.QStyle.SC_SliderHandle and + self.upperPressed != QtWidgets.QStyle.SC_SliderHandle): + 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.firstMovement: + if self.minPos == self.maxPos: + # MaxSlider is preferred, except in the case where min == max + # possible value the slider can take. + if self.minPos == self.maximum(): + self.lowerPressed = QtWidgets.QStyle.SC_SliderHandle + self.upperPressed = QtWidgets.QStyle.SC_None + self.firstMovement = False + + if self.lowerPressed == QtWidgets.QStyle.SC_SliderHandle: + newPos = min(newPos, self.maxVal) + self.setLowerPosition(newPos) + + if self.upperPressed == QtWidgets.QStyle.SC_SliderHandle: + newPos = max(newPos, self.minVal) + self.setUpperPosition(newPos) + + ev.accept() + + def mouseReleaseEvent(self, ev): + QtWidgets.QSlider.mouseReleaseEvent(self, ev) + self.setSliderDown(False) # AbstractSlider needs this + self.lowerPressed = QtWidgets.QStyle.SC_None + self.upperPressed = QtWidgets.QStyle.SC_None + + def paintEvent(self, ev): + # Use QStylePainters to make redrawing as painless as possible. + painter = QtWidgets.QStylePainter(self) + # 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. + minPainter = QtWidgets.QStylePainter(self, self.dummyMinSlider) + maxPainter = QtWidgets.QStylePainter(self, self.dummyMaxSlider) + + # Groove + opt = QtWidgets.QStyleOptionSlider() + self.initStyleOption(opt) + opt.sliderValue = 0 + opt.sliderPosition = 0 + opt.subControls = QtWidgets.QStyle.SC_SliderGroove + painter.drawComplexControl(QtWidgets.QStyle.CC_Slider, opt) + + # Handles + self.drawHandle(minPainter, ScanSlider.minSlider) + self.drawHandle(maxPainter, ScanSlider.maxSlider) + + +# real (Sliders) => pixel (one pixel movement of sliders would increment by X) +# => range (minimum granularity that sliders understand). +class ScanProxy(QtCore.QObject): + sigMinMoved = QtCore.pyqtSignal(float) + sigMaxMoved = QtCore.pyqtSignal(float) + + def __init__(self, slider, axis, rangeFactor): + QtCore.QObject.__init__(self) + self.axis = axis + self.slider = slider + self.realMin = 0 + self.realMax = 0 + self.numPoints = 10 + self.rangeFactor = rangeFactor + + # 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.calculateNewRealToPixel( + -self.axis.width()/2, 1.0) + self.invalidOldSizeExpected = True + self.axis.installEventFilter(self) + + # What real value should map to the axis/slider left? This doesn't depend + # on any public members so we can make decisions about centering during + # resize and zoom events. + def calculateNewRealToPixel(self, targetLeft, targetScale): + return QtGui.QTransform.fromScale(targetScale, 1).translate( + -targetLeft, 0) + + # pixel vals for sliders: 0 to slider_width - 1 + def realToPixel(self, val): + return (QtCore.QPointF(val, 0) * self.realToPixelTransform).x() + + # Get a point from pixel units to what the sliders display. + def pixelToReal(self, val): + (revXform, invertible) = self.realToPixelTransform.inverted() + if not invertible: + revXform = (QtGui.QTransform.fromTranslate( + -self.realToPixelTransform.dx(), 0) * + QtGui.QTransform.fromScale( + 1/self.realToPixelTransform.m11(), 0)) + realPoint = QtCore.QPointF(val, 0) * revXform + return realPoint.x() + + def rangeToReal(self, val): + # gx = self.slider.grooveX() + # ax = self.axis.x() + # assert gx == ax, "gx: {}, ax: {}".format(gx, ax) + pixelVal = self.slider.rangeValueToPixelPos(val) + return self.pixelToReal(pixelVal) + + def realToRange(self, val): + pixelVal = self.realToPixel(val) + return self.slider.pixelPosToRangeValue(pixelVal) + + def moveMax(self, val): + sliderX = self.realToRange(val) + self.slider.setUpperPosition(sliderX) + self.realMax = val + + def moveMin(self, val): + sliderX = self.realToRange(val) + self.slider.setLowerPosition(sliderX) + self.realMin = val + + def handleMaxMoved(self, rangeVal): + self.sigMaxMoved.emit(self.rangeToReal(rangeVal)) + + def handleMinMoved(self, rangeVal): + self.sigMinMoved.emit(self.rangeToReal(rangeVal)) + + def handleZoom(self, zoomFactor, mouseXPos): + newScale = self.realToPixelTransform.m11() * zoomFactor + refReal = self.pixelToReal(mouseXPos) + newLeft = refReal - mouseXPos/newScale + self.realToPixelTransform = self.calculateNewRealToPixel( + newLeft, newScale) + self.moveMax(self.realMax) + self.moveMin(self.realMin) + + def zoomToFit(self): + currRangeReal = abs(self.realMax - self.realMin) + assert self.rangeFactor > 2 + proportion = self.rangeFactor/(self.rangeFactor - 2) + newScale = self.slider.effectiveWidth()/(proportion*currRangeReal) + newLeft = self.realMin - self.slider.effectiveWidth() \ + / (self.rangeFactor*newScale) + self.realToPixelTransform = self.calculateNewRealToPixel( + newLeft, newScale) + self.printTransform() + self.moveMax(self.realMax) + self.moveMin(self.realMin) + self.axis.update() + + def fitToView(self): + lowRange = 1.0/self.rangeFactor + highRange = (self.rangeFactor - 1)/self.rangeFactor + newMin = self.pixelToReal(lowRange * self.slider.effectiveWidth()) + newMax = self.pixelToReal(highRange * self.slider.effectiveWidth()) + sliderRange = self.slider.maximum() - self.slider.minimum() + assert sliderRange > 0 + self.moveMin(newMin) + self.moveMax(newMax) + # Signals won't fire unless slider was actually grabbed, so + # manually update so the spinboxes know that knew values were set. + # self.realMax/Min will be updated as a consequence of ValueChanged + # signal in spinboxes. + self.sigMaxMoved.emit(newMax) + self.sigMinMoved.emit(newMin) + + def eventFilter(self, obj, ev): + if obj != self.axis: + return False + if ev.type() != QtCore.QEvent.Resize: + return False + oldLeft = self.pixelToReal(0) + if ev.oldSize().isValid(): + refWidth = ev.oldSize().width() - self.slider.handleWidth() + refRight = self.pixelToReal(refWidth) + newWidth = ev.size().width() - self.slider.handleWidth() + assert refRight > oldLeft + newScale = newWidth/(refRight - oldLeft) + else: + # TODO: self.axis.width() is invalid during object + # construction. The width will change when placed in a + # layout WITHOUT a resizeEvent. Why? + oldLeft = -ev.size().width()/2 + newScale = 1.0 + self.invalidOldSizeExpected = False + self.realToPixelTransform = self.calculateNewRealToPixel( + oldLeft, newScale) + # assert self.pixelToReal(0) == oldLeft, \ + # "{}, {}".format(self.pixelToReal(0), oldLeft) + # 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 + + def printTransform(self): + print("m11: {}, dx: {}".format( + self.realToPixelTransform.m11(), self.realToPixelTransform.dx())) + (inverted, invertible) = self.realToPixelTransform.inverted() + print("m11: {}, dx: {}, singular: {}".format( + inverted.m11(), inverted.dx(), not invertible)) + + +class ScanWidget(QtWidgets.QWidget): + sigMinMoved = QtCore.pyqtSignal(float) + sigMaxMoved = QtCore.pyqtSignal(float) + + def __init__(self, zoomFactor=1.05, rangeFactor=6): + QtWidgets.QWidget.__init__(self) + slider = ScanSlider() + axis = ScanAxis(zoomFactor) + zoomFitButton = QtWidgets.QPushButton("View Range") + fitViewButton = QtWidgets.QPushButton("Snap Range") + self.proxy = ScanProxy(slider, axis, rangeFactor) + axis.proxy = self.proxy + + # Layout. + layout = QtWidgets.QGridLayout() + # Default size will cause axis to disappear otherwise. + layout.setRowMinimumHeight(0, 40) + layout.addWidget(axis, 0, 0, 1, -1) + layout.addWidget(slider, 1, 0, 1, -1) + layout.addWidget(zoomFitButton, 2, 0) + layout.addWidget(fitViewButton, 2, 1) + self.setLayout(layout) + + # Connect signals + slider.sigMaxMoved.connect(self.proxy.handleMaxMoved) + slider.sigMinMoved.connect(self.proxy.handleMinMoved) + self.proxy.sigMaxMoved.connect(self.sigMaxMoved) + self.proxy.sigMinMoved.connect(self.sigMinMoved) + axis.sigZoom.connect(self.proxy.handleZoom) + fitViewButton.clicked.connect(self.fitToView) + zoomFitButton.clicked.connect(self.zoomToFit) + + # Connect event observers. + + # Spinbox and button slots. Any time the spinboxes change, ScanWidget + # mirrors it and passes the information to the proxy. + def setMax(self, val): + self.proxy.moveMax(val) + + def setMin(self, val): + self.proxy.moveMin(val) + + def setNumPoints(self, val): + pass + + def zoomToFit(self): + self.proxy.zoomToFit() + + def fitToView(self): + self.proxy.fitToView() + + def reset(self): + self.proxy.reset() diff --git a/artiq/gui/scientific_spinbox.py b/artiq/gui/scientific_spinbox.py new file mode 100644 index 000000000..65cbba669 --- /dev/null +++ b/artiq/gui/scientific_spinbox.py @@ -0,0 +1,71 @@ +import re +from PyQt5 import QtGui, QtWidgets + +# after +# http://jdreaver.com/posts/2014-07-28-scientific-notation-spin-box-pyside.html + + +_inf = float("inf") +# Regular expression to find floats. Match groups are the whole string, the +# whole coefficient, the decimal part of the coefficient, and the exponent +# part. +_float_re = re.compile(r"(([+-]?\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?)") + + +def valid_float_string(string): + match = _float_re.search(string) + if match: + return match.groups()[0] == string + return False + + +class FloatValidator(QtGui.QValidator): + def validate(self, string, position): + if valid_float_string(string): + return self.Acceptable, string, position + if string == "" or string[position-1] in "eE.-+": + return self.Intermediate, string, position + return self.Invalid, string, position + + def fixup(self, text): + match = _float_re.search(text) + if match: + return match.groups()[0] + return "" + + +class ScientificSpinBox(QtWidgets.QDoubleSpinBox): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setMinimum(-_inf) + self.setMaximum(_inf) + self.validator = FloatValidator() + self.setDecimals(20) + + def validate(self, text, position): + return self.validator.validate(text, position) + + def fixup(self, text): + return self.validator.fixup(text) + + def valueFromText(self, text): + return float(text) + + def textFromValue(self, value): + return format_float(value) + + def stepBy(self, steps): + text = self.cleanText() + groups = _float_re.search(text).groups() + decimal = float(groups[1]) + decimal += steps + new_string = "{:g}".format(decimal) + (groups[3] if groups[3] else "") + self.lineEdit().setText(new_string) + + +def format_float(value): + """Modified form of the 'g' format specifier.""" + string = "{:g}".format(value) + string = string.replace("e+", "e") + string = re.sub("e(-?)0*(\d+)", r"e\1\2", string) + return string diff --git a/artiq/gui/ticker.py b/artiq/gui/ticker.py new file mode 100644 index 000000000..0fb56a70a --- /dev/null +++ b/artiq/gui/ticker.py @@ -0,0 +1,136 @@ +# Robert Jordens , 2016 + +import numpy as np + + +class Ticker: + # TODO: if this turns out to be computationally expensive, then refactor + # such that the log()s and intermediate values are reused. But + # probably the string formatting itself is the limiting factor here. + def __init__(self, min_ticks=3, precision=3, steps=(5, 2, 1, .5)): + """ + min_ticks: minimum number of ticks to generate + The maximum number of ticks is + max(consecutive ratios in steps)*min_ticks + thus 5/2*min_ticks for default steps. + precision: maximum number of significant digits in labels + Also extract common offset and magnitude from ticks + if dynamic range exceeds precision number of digits + (small range on top of large offset). + steps: tick increments at a given magnitude + The .5 catches rounding errors where the calculation + of step_magnitude falls into the wrong exponent bin. + """ + self.min_ticks = min_ticks + self.precision = precision + self.steps = steps + + def step(self, i): + """ + Return recommended step value for interval size `i`. + """ + if not i: + raise ValueError("Need a finite interval") + step = i/self.min_ticks # rational step size for min_ticks + step_magnitude = 10**np.floor(np.log10(step)) + # underlying magnitude for steps + for m in self.steps: + good_step = m*step_magnitude + if good_step <= step: + return good_step + + def ticks(self, a, b): + """ + Return recommended tick values for interval `[a, b[`. + """ + step = self.step(b - a) + a0 = np.ceil(a/step)*step + ticks = np.arange(a0, b, step) + return ticks + + def offset(self, a, step): + """ + Find offset if dynamic range of the interval is large + (small range on large offset). + + If offset is finite, show `offset + value`. + """ + if a == 0.: + return 0. + la = np.floor(np.log10(abs(a))) + lr = np.floor(np.log10(step)) + if la - lr < self.precision: + return 0. + magnitude = 10**(lr - 1 + self.precision) + offset = np.floor(a/magnitude)*magnitude + return offset + + def magnitude(self, a, b, step): + """ + Determine the scaling magnitude. + + If magnitude differs from unity, show `magnitude * value`. + This depends on proper offsetting by `offset()`. + """ + v = np.floor(np.log10(max(abs(a), abs(b)))) + w = np.floor(np.log10(step)) + if v < self.precision and w > -self.precision: + return 1. + return 10**v + + def fix_minus(self, s): + return s.replace("-", "−") # unicode minus + + def format(self, step): + """ + Determine format string to represent step sufficiently accurate. + """ + dynamic = -int(np.floor(np.log10(step))) + dynamic = min(max(0, dynamic), self.precision) + return "{{:1.{:d}f}}".format(dynamic) + + def compact_exponential(self, v): + """ + Format `v` in in compact exponential, stripping redundant elements + (pluses, leading and trailing zeros and decimal point, trailing `e`). + """ + # this is after the matplotlib ScalarFormatter + # without any i18n + significand, exponent = "{:1.10e}".format(v).split("e") + significand = significand.rstrip("0").rstrip(".") + exponent_sign = exponent[0].replace("+", "") + exponent = exponent[1:].lstrip("0") + s = "{:s}e{:s}{:s}".format(significand, exponent_sign, + exponent).rstrip("e") + return self.fix_minus(s) + + def prefix(self, offset, magnitude): + """ + Stringify `offset` and `magnitude`. + + Expects the string to be shown top/left of the value it refers to. + """ + prefix = "" + if offset != 0.: + prefix += self.compact_exponential(offset) + " + " + if magnitude != 1.: + prefix += self.compact_exponential(magnitude) + " × " + return prefix + + def __call__(self, a, b): + """ + Determine ticks, prefix and labels given the interval + `[a, b[`. + + Return tick values, prefix string to be show to the left or + above the labels, and tick labels. + """ + ticks = self.ticks(a, b) + offset = self.offset(a, ticks[1] - ticks[0]) + t = ticks - offset + magnitude = self.magnitude(t[0], t[-1], t[1] - t[0]) + t /= magnitude + prefix = self.prefix(offset, magnitude) + format = self.format(t[1] - t[0]) + labels = [self.fix_minus(format.format(t)) for t in t] + return ticks, prefix, labels From 485fc3bd2772affcd64ba0bf08f92975c7405ad5 Mon Sep 17 00:00:00 2001 From: Robert Jordens Date: Thu, 25 Feb 2016 20:34:18 +0100 Subject: [PATCH 2/8] gui: use scanwidget --- artiq/gui/entries.py | 51 ++++++++++++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/artiq/gui/entries.py b/artiq/gui/entries.py index 92f30959f..8bb4e6fd2 100644 --- a/artiq/gui/entries.py +++ b/artiq/gui/entries.py @@ -4,6 +4,8 @@ from collections import OrderedDict from PyQt5 import QtCore, QtGui, QtWidgets from artiq.gui.tools import LayoutWidget, disable_scroll_wheel +from artiq.gui.scanwidget import ScanWidget +from artiq.gui.scientific_spinbox import ScientificSpinBox logger = logging.getLogger(__name__) @@ -136,6 +138,7 @@ class _RangeScan(LayoutWidget): LayoutWidget.__init__(self) scale = procdesc["scale"] + def apply_properties(spinbox): spinbox.setDecimals(procdesc["ndecimals"]) if procdesc["global_min"] is not None: @@ -151,37 +154,53 @@ class _RangeScan(LayoutWidget): if procdesc["unit"]: spinbox.setSuffix(" " + procdesc["unit"]) - self.addWidget(QtWidgets.QLabel("Min:"), 0, 0) - self.min = QtWidgets.QDoubleSpinBox() + self.scanner = scanner = ScanWidget() + scanner.setSizePolicy(QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed)) + self.addWidget(scanner, 0, 0, -1, 1) + + self.min = ScientificSpinBox() + self.min.setStyleSheet("QDoubleSpinBox {color:blue}") + self.min.setMinimumSize(110, 0) + self.min.setSizePolicy(QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed)) + self.min.setValue(state["min"]/scale) disable_scroll_wheel(self.min) apply_properties(self.min) self.addWidget(self.min, 0, 1) - self.addWidget(QtWidgets.QLabel("Max:"), 1, 0) - self.max = QtWidgets.QDoubleSpinBox() + self.npoints = QtWidgets.QSpinBox() + self.npoints.setMinimum(1) + self.npoints.setValue(state["npoints"]) + disable_scroll_wheel(self.npoints) + self.addWidget(self.npoints, 1, 1) + + self.max = ScientificSpinBox() + self.max.setStyleSheet("QDoubleSpinBox {color:red}") + self.max.setMinimumSize(110, 0) + self.max.setValue(state["max"]/scale) disable_scroll_wheel(self.max) apply_properties(self.max) - self.addWidget(self.max, 1, 1) + self.addWidget(self.max, 2, 1) - self.addWidget(QtWidgets.QLabel("#Points:"), 2, 0) - self.npoints = QtWidgets.QSpinBox() - disable_scroll_wheel(self.npoints) - self.npoints.setMinimum(2) - self.npoints.setValue(10) - self.addWidget(self.npoints, 2, 1) - - self.min.setValue(state["min"]/scale) - self.max.setValue(state["max"]/scale) - self.npoints.setValue(state["npoints"]) def update_min(value): state["min"] = value*scale + def update_max(value): state["min"] = value*scale + def update_npoints(value): state["npoints"] = value + + scanner.sigMinMoved.connect(self.min.setValue) + # scanner.sigNumChanged.connect(self.npoints.setValue) + scanner.sigMaxMoved.connect(self.max.setValue) self.min.valueChanged.connect(update_min) - self.max.valueChanged.connect(update_max) + self.min.valueChanged.connect(scanner.setMin) + # self.npoints.valueChanged.connect(scanner.setNumPoints) self.npoints.valueChanged.connect(update_npoints) + self.max.valueChanged.connect(scanner.setMax) + self.max.valueChanged.connect(update_max) class _ExplicitScan(LayoutWidget): From 7e9978089153ed21ca386db304e3d693a8d551e6 Mon Sep 17 00:00:00 2001 From: Robert Jordens Date: Tue, 8 Mar 2016 12:35:13 +0100 Subject: [PATCH 3/8] scanwidget: wire up signals better, set values late, take scanwidget from 7aa6397 --- artiq/gui/entries.py | 22 +-- artiq/gui/scanwidget.py | 333 ++++++++++++++++++++++++---------------- 2 files changed, 212 insertions(+), 143 deletions(-) diff --git a/artiq/gui/entries.py b/artiq/gui/entries.py index 8bb4e6fd2..7bf60965f 100644 --- a/artiq/gui/entries.py +++ b/artiq/gui/entries.py @@ -164,43 +164,43 @@ class _RangeScan(LayoutWidget): self.min.setMinimumSize(110, 0) self.min.setSizePolicy(QtWidgets.QSizePolicy( QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed)) - self.min.setValue(state["min"]/scale) disable_scroll_wheel(self.min) - apply_properties(self.min) self.addWidget(self.min, 0, 1) self.npoints = QtWidgets.QSpinBox() self.npoints.setMinimum(1) - self.npoints.setValue(state["npoints"]) disable_scroll_wheel(self.npoints) self.addWidget(self.npoints, 1, 1) self.max = ScientificSpinBox() self.max.setStyleSheet("QDoubleSpinBox {color:red}") self.max.setMinimumSize(110, 0) - self.max.setValue(state["max"]/scale) disable_scroll_wheel(self.max) - apply_properties(self.max) self.addWidget(self.max, 2, 1) def update_min(value): state["min"] = value*scale + scanner.setStart(value) def update_max(value): state["min"] = value*scale + scanner.setStop(value) def update_npoints(value): state["npoints"] = value + scanner.setNumPoints(value) - scanner.sigMinMoved.connect(self.min.setValue) - # scanner.sigNumChanged.connect(self.npoints.setValue) - scanner.sigMaxMoved.connect(self.max.setValue) + scanner.sigStartMoved.connect(self.min.setValue) + scanner.sigNumChanged.connect(self.npoints.setValue) + scanner.sigStopMoved.connect(self.max.setValue) self.min.valueChanged.connect(update_min) - self.min.valueChanged.connect(scanner.setMin) - # self.npoints.valueChanged.connect(scanner.setNumPoints) self.npoints.valueChanged.connect(update_npoints) - self.max.valueChanged.connect(scanner.setMax) self.max.valueChanged.connect(update_max) + self.min.setValue(state["min"]/scale) + self.npoints.setValue(state["npoints"]) + self.max.setValue(state["max"]/scale) + apply_properties(self.min) + apply_properties(self.max) class _ExplicitScan(LayoutWidget): diff --git a/artiq/gui/scanwidget.py b/artiq/gui/scanwidget.py index 20facb2c0..57f0d4f08 100644 --- a/artiq/gui/scanwidget.py +++ b/artiq/gui/scanwidget.py @@ -1,14 +1,16 @@ from PyQt5 import QtGui, QtCore, QtWidgets - from .ticker import Ticker +from numpy import linspace class ScanAxis(QtWidgets.QWidget): sigZoom = QtCore.pyqtSignal(float, int) + sigPoints = QtCore.pyqtSignal(int) def __init__(self, zoomFactor): QtWidgets.QWidget.__init__(self) self.proxy = None + self.slider = None # Needed for eventFilter self.sizePolicy().setControlType(QtWidgets.QSizePolicy.ButtonBox) self.ticker = Ticker() self.zoomFactor = zoomFactor @@ -22,54 +24,76 @@ class ScanAxis(QtWidgets.QWidget): # on the spinboxes. painter.translate(self.proxy.slider.handleWidth()/2, self.height() - 5) painter.drawLine(0, 0, self.width(), 0) - realMin = self.proxy.pixelToReal(0) - realMax = self.proxy.pixelToReal(self.width()) + realLeft = self.proxy.pixelToReal(0) + realRight = self.proxy.pixelToReal(self.width()) - ticks, prefix, labels = self.ticker(realMin, realMax) + ticks, prefix, labels = self.ticker(realLeft, realRight) for t, l in zip(ticks, labels): t = self.proxy.realToPixel(t) textCenter = (len(l)/2.0)*avgCharWidth painter.drawLine(t, 5, t, -5) painter.drawText(t - textCenter, -10, l) - painter.resetTransform() - painter.drawText(0, 10, prefix) - # TODO: - # QtWidgets.QWidget.paintEvent(self, ev)? - # ev.accept() ? + + painter.save() + painter.setPen(QtGui.QColor(QtCore.Qt.green)) + 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) + + painter.restore() + painter.drawText(0, -25, prefix) + ev.accept() def wheelEvent(self, ev): y = ev.angleDelta().y() if y: - z = self.zoomFactor**(y / 120.) - # 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. - self.sigZoom.emit(z, ev.x() - self.proxy.slider.handleWidth()/2) + if ev.modifiers() & QtCore.Qt.ShiftModifier: + # If shift+scroll, modify number of points. + z = int(y / 120.) + self.sigPoints.emit(z) + else: + z = self.zoomFactor**(y / 120.) + # 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. + self.sigZoom.emit(z, ev.x() - + self.proxy.slider.handleWidth()/2) self.update() ev.accept() + def eventFilter(self, obj, ev): + if obj != self.slider: + return False + if ev.type() != QtCore.QEvent.Wheel: + return False + self.wheelEvent(ev) + return True # Basic ideas from https://gist.github.com/Riateche/27e36977f7d5ea72cf4f class ScanSlider(QtWidgets.QSlider): - sigMinMoved = QtCore.pyqtSignal(int) - sigMaxMoved = QtCore.pyqtSignal(int) - noSlider, minSlider, maxSlider = range(3) - maxStyle = "QSlider::handle {background:red}" - minStyle = "QSlider::handle {background:blue}" + sigStartMoved = QtCore.pyqtSignal(int) + sigStopMoved = QtCore.pyqtSignal(int) + noSlider, startSlider, stopSlider = range(3) + stopStyle = "QSlider::handle {background:red}" + startStyle = "QSlider::handle {background:blue}" def __init__(self): QtWidgets.QSlider.__init__(self, QtCore.Qt.Horizontal) - self.minPos = 0 # Pos and Val can differ in event handling. + self.startPos = 0 # Pos and Val can differ in event handling. # perhaps prevPos and currPos is more accurate. - self.maxPos = 99 - self.minVal = 0 # lower - self.maxVal = 99 # upper + self.stopPos = 99 + self.startVal = 0 # lower + self.stopVal = 99 # upper self.offset = 0 self.position = 0 self.lastPressed = ScanSlider.noSlider - self.selectedHandle = ScanSlider.minSlider + self.selectedHandle = ScanSlider.startSlider self.upperPressed = QtWidgets.QStyle.SC_None self.lowerPressed = QtWidgets.QStyle.SC_None self.firstMovement = False # State var for handling slider overlap. @@ -77,22 +101,22 @@ class ScanSlider(QtWidgets.QSlider): # We need fake sliders to keep around so that we can dynamically # set the stylesheets for drawing each slider later. See paintEvent. - self.dummyMinSlider = QtWidgets.QSlider() - self.dummyMaxSlider = QtWidgets.QSlider() - self.dummyMinSlider.setStyleSheet(ScanSlider.minStyle) - self.dummyMaxSlider.setStyleSheet(ScanSlider.maxStyle) + self.dummyStartSlider = QtWidgets.QSlider() + self.dummyStopSlider = QtWidgets.QSlider() + self.dummyStartSlider.setStyleSheet(ScanSlider.startStyle) + self.dummyStopSlider.setStyleSheet(ScanSlider.stopStyle) # 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 == ScanSlider.minSlider: - opt.sliderPosition = self.minPos - opt.sliderValue = self.minVal - elif handle == ScanSlider.maxSlider: - opt.sliderPosition = self.maxPos - opt.sliderValue = self.maxVal + if handle == ScanSlider.startSlider: + opt.sliderPosition = self.startPos + opt.sliderValue = self.startVal + elif handle == ScanSlider.stopSlider: + opt.sliderPosition = self.stopPos + opt.sliderValue = self.stopVal else: pass # AssertionErrors @@ -109,13 +133,15 @@ class ScanSlider(QtWidgets.QSlider): self) sliderLength = sr.width() - sliderMin = gr.x() + sliderStart = gr.x() # For historical reasons right() returns left()+width() - 1 # x() is equivalent to left(). - sliderMax = gr.right() - sliderLength + 1 - return QtWidgets.QStyle.sliderValueFromPosition( - self.minimum(), self.maximum(), pos - sliderMin, - sliderMax - sliderMin, opt.upsideDown) + sliderStop = gr.right() - sliderLength + 1 + + rangeVal = QtWidgets.QStyle.sliderValueFromPosition( + self.minimum(), self.maximum(), pos - sliderStart, + sliderStop - sliderStart, opt.upsideDown) + return rangeVal def rangeValueToPixelPos(self, val): opt = QtWidgets.QStyleOptionSlider() @@ -129,11 +155,11 @@ class ScanSlider(QtWidgets.QSlider): self) sliderLength = sr.width() - sliderMin = gr.x() - sliderMax = gr.right() - sliderLength + 1 + sliderStart = gr.x() + sliderStop = gr.right() - sliderLength + 1 pixel = QtWidgets.QStyle.sliderPositionFromValue( - self.minimum(), self.maximum(), val, sliderMax - sliderMin, + self.minimum(), self.maximum(), val, sliderStop - sliderStart, opt.upsideDown) return pixel @@ -155,9 +181,9 @@ class ScanSlider(QtWidgets.QSlider): QtWidgets.QStyle.SC_SliderGroove, self) sliderLength = self.handleWidth() - sliderMin = gr.x() - sliderMax = gr.right() - sliderLength + 1 - return sliderMax - sliderMin + sliderStart = gr.x() + sliderStop = gr.right() - sliderLength + 1 + return sliderStop - sliderStart # If groove and axis are not aligned (and they should be), we can use # this function to calculate the offset between them. @@ -172,6 +198,17 @@ class ScanSlider(QtWidgets.QSlider): def handleMousePress(self, pos, control, val, handle): opt = QtWidgets.QStyleOptionSlider() self.initHandleStyleOption(opt, handle) + startAtEdges = (handle == ScanSlider.startSlider and + (self.startVal == self.minimum() or + self.startVal == self.maximum())) + stopAtEdges = (handle == ScanSlider.stopSlider 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) @@ -202,13 +239,15 @@ class ScanSlider(QtWidgets.QSlider): # if action == QtWidgets.QAbstractSlider.SliderSingleStepAdd: # if - def setLowerValue(self, val): - self.setSpan(val, self.maxVal) + def setStartValue(self, val): + self.setSpan(val, self.stopVal) - def setUpperValue(self, val): - self.setSpan(self.minVal, val) + def setStopValue(self, val): + self.setSpan(self.startVal, val) def setSpan(self, lower, upper): + # TODO: Is bound() necessary? QStyle::sliderPositionFromValue appears + # to clamp already. def bound(min, curr, max): if curr < min: return min @@ -220,49 +259,47 @@ class ScanSlider(QtWidgets.QSlider): low = bound(self.minimum(), lower, self.maximum()) high = bound(self.minimum(), upper, self.maximum()) - if low != self.minVal or high != self.maxVal: - if low != self.minVal: - self.minVal = low - self.minPos = low - # emit - if high != self.maxVal: - self.maxVal = high - self.maxPos = high - # emit - # emit spanChanged + if low != self.startVal or high != self.stopVal: + if low != self.startVal: + self.startVal = low + self.startPos = low + if high != self.stopVal: + self.stopVal = high + self.stopPos = high self.update() - def setLowerPosition(self, val): - if val != self.minPos: - self.minPos = val + def setStartPosition(self, val): + if val != self.startPos: + self.startPos = val if not self.hasTracking(): self.update() if self.isSliderDown(): - self.sigMinMoved.emit(self.minPos) + self.sigStartMoved.emit(self.startPos) if self.hasTracking() and not self.blockTracking: - self.setLowerValue(val) + self.setStartValue(val) - def setUpperPosition(self, val): - if val != self.maxPos: - self.maxPos = val + def setStopPosition(self, val): + if val != self.stopPos: + self.stopPos = val if not self.hasTracking(): self.update() if self.isSliderDown(): - self.sigMaxMoved.emit(self.maxPos) + self.sigStopMoved.emit(self.stopPos) if self.hasTracking() and not self.blockTracking: - self.setUpperValue(val) + self.setStopValue(val) def mousePressEvent(self, ev): if self.minimum() == self.maximum() or (ev.buttons() ^ ev.button()): ev.ignore() return - # Prefer maxVal in the default case. + # Prefer stopVal in the default case. self.upperPressed = self.handleMousePress( - ev.pos(), self.upperPressed, self.maxVal, ScanSlider.maxSlider) + ev.pos(), self.upperPressed, self.stopVal, ScanSlider.stopSlider) if self.upperPressed != QtWidgets.QStyle.SC_SliderHandle: self.lowerPressed = self.handleMousePress( - ev.pos(), self.upperPressed, self.minVal, ScanSlider.minSlider) + ev.pos(), self.upperPressed, self.startVal, + ScanSlider.startSlider) # State that is needed to handle the case where two sliders are equal. self.firstMovement = True @@ -289,21 +326,19 @@ class ScanSlider(QtWidgets.QSlider): newPos = self.position if self.firstMovement: - if self.minPos == self.maxPos: - # MaxSlider is preferred, except in the case where min == max - # possible value the slider can take. - if self.minPos == self.maximum(): + 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: - newPos = min(newPos, self.maxVal) - self.setLowerPosition(newPos) + self.setStartPosition(newPos) if self.upperPressed == QtWidgets.QStyle.SC_SliderHandle: - newPos = max(newPos, self.minVal) - self.setUpperPosition(newPos) + self.setStopPosition(newPos) ev.accept() @@ -323,8 +358,8 @@ class ScanSlider(QtWidgets.QSlider): # 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. - minPainter = QtWidgets.QStylePainter(self, self.dummyMinSlider) - maxPainter = QtWidgets.QStylePainter(self, self.dummyMaxSlider) + startPainter = QtWidgets.QStylePainter(self, self.dummyStartSlider) + stopPainter = QtWidgets.QStylePainter(self, self.dummyStopSlider) # Groove opt = QtWidgets.QStyleOptionSlider() @@ -335,22 +370,28 @@ class ScanSlider(QtWidgets.QSlider): painter.drawComplexControl(QtWidgets.QStyle.CC_Slider, opt) # Handles - self.drawHandle(minPainter, ScanSlider.minSlider) - self.drawHandle(maxPainter, ScanSlider.maxSlider) + # 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.startVal > 0 and self.startVal < self.maximum(): + self.drawHandle(startPainter, ScanSlider.startSlider) + if self.stopVal > 0 and self.stopVal < self.maximum(): + self.drawHandle(stopPainter, ScanSlider.stopSlider) # real (Sliders) => pixel (one pixel movement of sliders would increment by X) # => range (minimum granularity that sliders understand). class ScanProxy(QtCore.QObject): - sigMinMoved = QtCore.pyqtSignal(float) - sigMaxMoved = QtCore.pyqtSignal(float) + sigStartMoved = QtCore.pyqtSignal(float) + sigStopMoved = QtCore.pyqtSignal(float) + sigNumPoints = QtCore.pyqtSignal(int) def __init__(self, slider, axis, rangeFactor): QtCore.QObject.__init__(self) self.axis = axis self.slider = slider - self.realMin = 0 - self.realMax = 0 + self.realStart = 0 + self.realStop = 0 self.numPoints = 10 self.rangeFactor = rangeFactor @@ -363,7 +404,6 @@ class ScanProxy(QtCore.QObject): self.realToPixelTransform = self.calculateNewRealToPixel( -self.axis.width()/2, 1.0) self.invalidOldSizeExpected = True - self.axis.installEventFilter(self) # What real value should map to the axis/slider left? This doesn't depend # on any public members so we can make decisions about centering during @@ -374,7 +414,13 @@ class ScanProxy(QtCore.QObject): # pixel vals for sliders: 0 to slider_width - 1 def realToPixel(self, val): - return (QtCore.QPointF(val, 0) * self.realToPixelTransform).x() + rawVal = (QtCore.QPointF(val, 0) * self.realToPixelTransform).x() + # Clamp pixel values to 32 bits, b/c Qt will otherwise wrap values. + if rawVal < -(2**31): + rawVal = -(2**31) + elif rawVal > (2**31 - 1): + rawVal = (2**31 - 1) + return rawVal # Get a point from pixel units to what the sliders display. def pixelToReal(self, val): @@ -398,21 +444,30 @@ class ScanProxy(QtCore.QObject): pixelVal = self.realToPixel(val) return self.slider.pixelPosToRangeValue(pixelVal) - def moveMax(self, val): + def moveStop(self, val): sliderX = self.realToRange(val) - self.slider.setUpperPosition(sliderX) - self.realMax = val + self.slider.setStopPosition(sliderX) + self.realStop = val + self.axis.update() # Number of points ticks changed positions. - def moveMin(self, val): + def moveStart(self, val): sliderX = self.realToRange(val) - self.slider.setLowerPosition(sliderX) - self.realMin = val + self.slider.setStartPosition(sliderX) + self.realStart = val + self.axis.update() - def handleMaxMoved(self, rangeVal): - self.sigMaxMoved.emit(self.rangeToReal(rangeVal)) + def handleStopMoved(self, rangeVal): + self.sigStopMoved.emit(self.rangeToReal(rangeVal)) - def handleMinMoved(self, rangeVal): - self.sigMinMoved.emit(self.rangeToReal(rangeVal)) + def handleStartMoved(self, rangeVal): + self.sigStartMoved.emit(self.rangeToReal(rangeVal)) + + def handleNumPoints(self, inc): + self.sigNumPoints.emit(self.numPoints + inc) + + def setNumPoints(self, val): + self.numPoints = val + self.axis.update() def handleZoom(self, zoomFactor, mouseXPos): newScale = self.realToPixelTransform.m11() * zoomFactor @@ -420,50 +475,57 @@ class ScanProxy(QtCore.QObject): newLeft = refReal - mouseXPos/newScale self.realToPixelTransform = self.calculateNewRealToPixel( newLeft, newScale) - self.moveMax(self.realMax) - self.moveMin(self.realMin) + self.moveStop(self.realStop) + self.moveStart(self.realStart) def zoomToFit(self): - currRangeReal = abs(self.realMax - self.realMin) - assert self.rangeFactor > 2 + currRangeReal = abs(self.realStop - self.realStart) + # Slider closest to the left should be used to find the new axis left. + if self.realStop < self.realStart: + refSlider = self.realStop + else: + refSlider = self.realStart + if self.rangeFactor <= 2: + return # Ill-formed snap range- do nothing. proportion = self.rangeFactor/(self.rangeFactor - 2) newScale = self.slider.effectiveWidth()/(proportion*currRangeReal) - newLeft = self.realMin - self.slider.effectiveWidth() \ + newLeft = refSlider - self.slider.effectiveWidth() \ / (self.rangeFactor*newScale) self.realToPixelTransform = self.calculateNewRealToPixel( newLeft, newScale) self.printTransform() - self.moveMax(self.realMax) - self.moveMin(self.realMin) - self.axis.update() + self.moveStop(self.realStop) + self.moveStart(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. def fitToView(self): lowRange = 1.0/self.rangeFactor highRange = (self.rangeFactor - 1)/self.rangeFactor - newMin = self.pixelToReal(lowRange * self.slider.effectiveWidth()) - newMax = self.pixelToReal(highRange * self.slider.effectiveWidth()) + newStart = self.pixelToReal(lowRange * self.slider.effectiveWidth()) + newStop = self.pixelToReal(highRange * self.slider.effectiveWidth()) sliderRange = self.slider.maximum() - self.slider.minimum() - assert sliderRange > 0 - self.moveMin(newMin) - self.moveMax(newMax) # Signals won't fire unless slider was actually grabbed, so # manually update so the spinboxes know that knew values were set. - # self.realMax/Min will be updated as a consequence of ValueChanged - # signal in spinboxes. - self.sigMaxMoved.emit(newMax) - self.sigMinMoved.emit(newMin) + # 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(). + if sliderRange > 0: + self.sigStopMoved.emit(newStop) + self.sigStartMoved.emit(newStart) def eventFilter(self, obj, ev): if obj != self.axis: return False if ev.type() != QtCore.QEvent.Resize: return False - oldLeft = self.pixelToReal(0) 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() - assert refRight > oldLeft + # assert refRight > oldLeft newScale = newWidth/(refRight - oldLeft) else: # TODO: self.axis.width() is invalid during object @@ -491,8 +553,9 @@ class ScanProxy(QtCore.QObject): class ScanWidget(QtWidgets.QWidget): - sigMinMoved = QtCore.pyqtSignal(float) - sigMaxMoved = QtCore.pyqtSignal(float) + sigStartMoved = QtCore.pyqtSignal(float) + sigStopMoved = QtCore.pyqtSignal(float) + sigNumChanged = QtCore.pyqtSignal(int) def __init__(self, zoomFactor=1.05, rangeFactor=6): QtWidgets.QWidget.__init__(self) @@ -502,6 +565,8 @@ class ScanWidget(QtWidgets.QWidget): fitViewButton = QtWidgets.QPushButton("Snap Range") self.proxy = ScanProxy(slider, axis, rangeFactor) axis.proxy = self.proxy + axis.slider = slider + slider.setMaximum(1023) # Layout. layout = QtWidgets.QGridLayout() @@ -514,26 +579,30 @@ class ScanWidget(QtWidgets.QWidget): self.setLayout(layout) # Connect signals - slider.sigMaxMoved.connect(self.proxy.handleMaxMoved) - slider.sigMinMoved.connect(self.proxy.handleMinMoved) - self.proxy.sigMaxMoved.connect(self.sigMaxMoved) - self.proxy.sigMinMoved.connect(self.sigMinMoved) + slider.sigStopMoved.connect(self.proxy.handleStopMoved) + slider.sigStartMoved.connect(self.proxy.handleStartMoved) + self.proxy.sigStopMoved.connect(self.sigStopMoved) + self.proxy.sigStartMoved.connect(self.sigStartMoved) + self.proxy.sigNumPoints.connect(self.sigNumChanged) axis.sigZoom.connect(self.proxy.handleZoom) + axis.sigPoints.connect(self.proxy.handleNumPoints) fitViewButton.clicked.connect(self.fitToView) zoomFitButton.clicked.connect(self.zoomToFit) # Connect event observers. + axis.installEventFilter(self.proxy) + slider.installEventFilter(axis) # Spinbox and button slots. Any time the spinboxes change, ScanWidget # mirrors it and passes the information to the proxy. - def setMax(self, val): - self.proxy.moveMax(val) + def setStop(self, val): + self.proxy.moveStop(val) - def setMin(self, val): - self.proxy.moveMin(val) + def setStart(self, val): + self.proxy.moveStart(val) def setNumPoints(self, val): - pass + self.proxy.setNumPoints(val) def zoomToFit(self): self.proxy.zoomToFit() From 59ac56704891b41b451739d291517a3199bb0274 Mon Sep 17 00:00:00 2001 From: Robert Jordens Date: Tue, 8 Mar 2016 16:43:04 +0100 Subject: [PATCH 4/8] scanwidget: disable unmodified wheel on axis and slider --- artiq/gui/scanwidget.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/artiq/gui/scanwidget.py b/artiq/gui/scanwidget.py index 57f0d4f08..9d3b0cb4d 100644 --- a/artiq/gui/scanwidget.py +++ b/artiq/gui/scanwidget.py @@ -559,8 +559,8 @@ class ScanWidget(QtWidgets.QWidget): def __init__(self, zoomFactor=1.05, rangeFactor=6): QtWidgets.QWidget.__init__(self) - slider = ScanSlider() - axis = ScanAxis(zoomFactor) + self.slider = slider = ScanSlider() + self.axis = axis = ScanAxis(zoomFactor) zoomFitButton = QtWidgets.QPushButton("View Range") fitViewButton = QtWidgets.QPushButton("Snap Range") self.proxy = ScanProxy(slider, axis, rangeFactor) From 81bc99905c3172840ce72e656f866e8c5156d98d Mon Sep 17 00:00:00 2001 From: Robert Jordens Date: Tue, 8 Mar 2016 16:49:23 +0100 Subject: [PATCH 5/8] missing parts of 59ac567 --- artiq/gui/entries.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/artiq/gui/entries.py b/artiq/gui/entries.py index 7bf60965f..28a1c6eee 100644 --- a/artiq/gui/entries.py +++ b/artiq/gui/entries.py @@ -157,6 +157,8 @@ class _RangeScan(LayoutWidget): self.scanner = scanner = ScanWidget() 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() From e4b854b8bfeae3d23c657f50eadd037ca8327603 Mon Sep 17 00:00:00 2001 From: Robert Jordens Date: Fri, 11 Mar 2016 15:38:22 +0100 Subject: [PATCH 6/8] scanwidget: apply changes as of 98f0a56 --- artiq/gui/entries.py | 1 + artiq/gui/scanwidget.py | 261 +++++++++++++++------------------------- 2 files changed, 101 insertions(+), 161 deletions(-) diff --git a/artiq/gui/entries.py b/artiq/gui/entries.py index 28a1c6eee..79064e29b 100644 --- a/artiq/gui/entries.py +++ b/artiq/gui/entries.py @@ -155,6 +155,7 @@ class _RangeScan(LayoutWidget): spinbox.setSuffix(" " + procdesc["unit"]) self.scanner = scanner = ScanWidget() + scanner.setMinimumSize(150, 0) scanner.setSizePolicy(QtWidgets.QSizePolicy( QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed)) disable_scroll_wheel(scanner.axis) diff --git a/artiq/gui/scanwidget.py b/artiq/gui/scanwidget.py index 9d3b0cb4d..56cbfac13 100644 --- a/artiq/gui/scanwidget.py +++ b/artiq/gui/scanwidget.py @@ -1,7 +1,8 @@ from PyQt5 import QtGui, QtCore, QtWidgets -from .ticker import Ticker from numpy import linspace +from .ticker import Ticker + class ScanAxis(QtWidgets.QWidget): sigZoom = QtCore.pyqtSignal(float, int) @@ -10,7 +11,6 @@ class ScanAxis(QtWidgets.QWidget): def __init__(self, zoomFactor): QtWidgets.QWidget.__init__(self) self.proxy = None - self.slider = None # Needed for eventFilter self.sizePolicy().setControlType(QtWidgets.QSizePolicy.ButtonBox) self.ticker = Ticker() self.zoomFactor = zoomFactor @@ -26,26 +26,25 @@ class ScanAxis(QtWidgets.QWidget): 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, -25, prefix) + + pen = QtGui.QPen() + pen.setWidth(2) + painter.setPen(pen) + for t, l in zip(ticks, labels): t = self.proxy.realToPixel(t) - textCenter = (len(l)/2.0)*avgCharWidth - painter.drawLine(t, 5, t, -5) - painter.drawText(t - textCenter, -10, l) + painter.drawLine(t, 0, t, -5) + painter.drawText(t - len(l)/2*avgCharWidth, -10, l) - painter.save() - painter.setPen(QtGui.QColor(QtCore.Qt.green)) sliderStartPixel = self.proxy.realToPixel(self.proxy.realStart) sliderStopPixel = self.proxy.realToPixel(self.proxy.realStop) pixels = linspace(sliderStartPixel, sliderStopPixel, - self.proxy.numPoints) + self.proxy.numPoints) for p in pixels: p_int = int(p) painter.drawLine(p_int, 0, p_int, 5) - - painter.restore() - painter.drawText(0, -25, prefix) ev.accept() def wheelEvent(self, ev): @@ -62,26 +61,24 @@ class ScanAxis(QtWidgets.QWidget): # 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. - self.sigZoom.emit(z, ev.x() - - self.proxy.slider.handleWidth()/2) + self.sigZoom.emit( + z, ev.x() - self.proxy.slider.handleWidth()/2) self.update() ev.accept() def eventFilter(self, obj, ev): - if obj != self.slider: + if obj is not self.proxy.slider: return False if ev.type() != QtCore.QEvent.Wheel: return False self.wheelEvent(ev) return True + # Basic ideas from https://gist.github.com/Riateche/27e36977f7d5ea72cf4f class ScanSlider(QtWidgets.QSlider): sigStartMoved = QtCore.pyqtSignal(int) sigStopMoved = QtCore.pyqtSignal(int) - noSlider, startSlider, stopSlider = range(3) - stopStyle = "QSlider::handle {background:red}" - startStyle = "QSlider::handle {background:blue}" def __init__(self): QtWidgets.QSlider.__init__(self, QtCore.Qt.Horizontal) @@ -92,8 +89,6 @@ class ScanSlider(QtWidgets.QSlider): self.stopVal = 99 # upper self.offset = 0 self.position = 0 - self.lastPressed = ScanSlider.noSlider - self.selectedHandle = ScanSlider.startSlider self.upperPressed = QtWidgets.QStyle.SC_None self.lowerPressed = QtWidgets.QStyle.SC_None self.firstMovement = False # State var for handling slider overlap. @@ -103,63 +98,40 @@ class ScanSlider(QtWidgets.QSlider): # set the stylesheets for drawing each slider later. See paintEvent. self.dummyStartSlider = QtWidgets.QSlider() self.dummyStopSlider = QtWidgets.QSlider() - self.dummyStartSlider.setStyleSheet(ScanSlider.startStyle) - self.dummyStopSlider.setStyleSheet(ScanSlider.stopStyle) + self.dummyStartSlider.setStyleSheet( + "QSlider::handle {background:blue}") + self.dummyStopSlider.setStyleSheet( + "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 == ScanSlider.startSlider: + if handle == "start": opt.sliderPosition = self.startPos opt.sliderValue = self.startVal - elif handle == ScanSlider.stopSlider: + elif handle == "stop": opt.sliderPosition = self.stopPos opt.sliderValue = self.stopVal - else: - pass # AssertionErrors # We get the range of each slider separately. def pixelPosToRangeValue(self, pos): opt = QtWidgets.QStyleOptionSlider() self.initStyleOption(opt) - gr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt, QtWidgets.QStyle.SC_SliderGroove, self) - sr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt, - QtWidgets.QStyle.SC_SliderHandle, - self) - - sliderLength = sr.width() - sliderStart = gr.x() - # For historical reasons right() returns left()+width() - 1 - # x() is equivalent to left(). - sliderStop = gr.right() - sliderLength + 1 - rangeVal = QtWidgets.QStyle.sliderValueFromPosition( - self.minimum(), self.maximum(), pos - sliderStart, - sliderStop - sliderStart, opt.upsideDown) + self.minimum(), self.maximum(), pos - gr.x(), + self.effectiveWidth(), opt.upsideDown) return rangeVal def rangeValueToPixelPos(self, val): opt = QtWidgets.QStyleOptionSlider() self.initStyleOption(opt) - - gr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt, - QtWidgets.QStyle.SC_SliderGroove, - self) - sr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt, - QtWidgets.QStyle.SC_SliderHandle, - self) - - sliderLength = sr.width() - sliderStart = gr.x() - sliderStop = gr.right() - sliderLength + 1 - pixel = QtWidgets.QStyle.sliderPositionFromValue( - self.minimum(), self.maximum(), val, sliderStop - sliderStart, + self.minimum(), self.maximum(), val, self.effectiveWidth(), opt.upsideDown) return pixel @@ -180,28 +152,15 @@ class ScanSlider(QtWidgets.QSlider): gr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt, QtWidgets.QStyle.SC_SliderGroove, self) - sliderLength = self.handleWidth() - sliderStart = gr.x() - sliderStop = gr.right() - sliderLength + 1 - return sliderStop - sliderStart - - # If groove and axis are not aligned (and they should be), we can use - # this function to calculate the offset between them. - def grooveX(self): - opt = QtWidgets.QStyleOptionSlider() - self.initStyleOption(opt) - gr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt, - QtWidgets.QStyle.SC_SliderGroove, - self) - return gr.x() + return gr.width() - self.handleWidth() def handleMousePress(self, pos, control, val, handle): opt = QtWidgets.QStyleOptionSlider() self.initHandleStyleOption(opt, handle) - startAtEdges = (handle == ScanSlider.startSlider and + startAtEdges = (handle == "start" and (self.startVal == self.minimum() or self.startVal == self.maximum())) - stopAtEdges = (handle == ScanSlider.stopSlider and + stopAtEdges = (handle == "stop" and (self.stopVal == self.minimum() or self.stopVal == self.maximum())) @@ -218,9 +177,7 @@ class ScanSlider(QtWidgets.QSlider): if control == QtWidgets.QStyle.SC_SliderHandle: # no pick()- slider orientation static self.offset = pos.x() - sr.topLeft().x() - self.lastPressed = handle self.setSliderDown(True) - self.selectedHandle = handle # emit # Needed? @@ -239,25 +196,11 @@ class ScanSlider(QtWidgets.QSlider): # if action == QtWidgets.QAbstractSlider.SliderSingleStepAdd: # if - def setStartValue(self, val): - self.setSpan(val, self.stopVal) - - def setStopValue(self, val): - self.setSpan(self.startVal, val) - - def setSpan(self, lower, upper): - # TODO: Is bound() necessary? QStyle::sliderPositionFromValue appears + def setSpan(self, low, high): + # TODO: Is this necessary? QStyle::sliderPositionFromValue appears # to clamp already. - def bound(min, curr, max): - if curr < min: - return min - elif curr > max: - return max - else: - return curr - - low = bound(self.minimum(), lower, self.maximum()) - high = bound(self.minimum(), upper, self.maximum()) + low = min(max(self.minimum(), low), self.maximum()) + high = min(max(self.minimum(), high), self.maximum()) if low != self.startVal or high != self.stopVal: if low != self.startVal: @@ -276,7 +219,7 @@ class ScanSlider(QtWidgets.QSlider): if self.isSliderDown(): self.sigStartMoved.emit(self.startPos) if self.hasTracking() and not self.blockTracking: - self.setStartValue(val) + self.setSpan(self.startPos, self.stopVal) def setStopPosition(self, val): if val != self.stopPos: @@ -286,7 +229,7 @@ class ScanSlider(QtWidgets.QSlider): if self.isSliderDown(): self.sigStopMoved.emit(self.stopPos) if self.hasTracking() and not self.blockTracking: - self.setStopValue(val) + self.setSpan(self.startVal, self.stopPos) def mousePressEvent(self, ev): if self.minimum() == self.maximum() or (ev.buttons() ^ ev.button()): @@ -295,11 +238,10 @@ class ScanSlider(QtWidgets.QSlider): # Prefer stopVal in the default case. self.upperPressed = self.handleMousePress( - ev.pos(), self.upperPressed, self.stopVal, ScanSlider.stopSlider) + ev.pos(), self.upperPressed, self.stopVal, "stop") if self.upperPressed != QtWidgets.QStyle.SC_SliderHandle: self.lowerPressed = self.handleMousePress( - ev.pos(), self.upperPressed, self.startVal, - ScanSlider.startSlider) + ev.pos(), self.upperPressed, self.startVal, "start") # State that is needed to handle the case where two sliders are equal. self.firstMovement = True @@ -350,7 +292,6 @@ class ScanSlider(QtWidgets.QSlider): def paintEvent(self, ev): # Use QStylePainters to make redrawing as painless as possible. - painter = QtWidgets.QStylePainter(self) # 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?). @@ -361,22 +302,14 @@ class ScanSlider(QtWidgets.QSlider): startPainter = QtWidgets.QStylePainter(self, self.dummyStartSlider) stopPainter = QtWidgets.QStylePainter(self, self.dummyStopSlider) - # Groove - opt = QtWidgets.QStyleOptionSlider() - self.initStyleOption(opt) - opt.sliderValue = 0 - opt.sliderPosition = 0 - opt.subControls = QtWidgets.QStyle.SC_SliderGroove - painter.drawComplexControl(QtWidgets.QStyle.CC_Slider, opt) - # 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.startVal > 0 and self.startVal < self.maximum(): - self.drawHandle(startPainter, ScanSlider.startSlider) + self.drawHandle(startPainter, "start") if self.stopVal > 0 and self.stopVal < self.maximum(): - self.drawHandle(stopPainter, ScanSlider.stopSlider) + self.drawHandle(stopPainter, "stop") # real (Sliders) => pixel (one pixel movement of sliders would increment by X) @@ -386,7 +319,7 @@ class ScanProxy(QtCore.QObject): sigStopMoved = QtCore.pyqtSignal(float) sigNumPoints = QtCore.pyqtSignal(int) - def __init__(self, slider, axis, rangeFactor): + def __init__(self, slider, axis, rangeFactor, dynamicRange): QtCore.QObject.__init__(self) self.axis = axis self.slider = slider @@ -394,6 +327,7 @@ class ScanProxy(QtCore.QObject): self.realStop = 0 self.numPoints = 10 self.rangeFactor = rangeFactor + self.dynamicRange = dynamicRange # Transform that maps the spinboxes to a pixel position on the # axis. 0 to axis.width() exclusive indicate positions which will be @@ -401,42 +335,23 @@ class ScanProxy(QtCore.QObject): # 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.calculateNewRealToPixel( - -self.axis.width()/2, 1.0) + self.realToPixelTransform = self.axis.width()/2, 1. self.invalidOldSizeExpected = True - # What real value should map to the axis/slider left? This doesn't depend - # on any public members so we can make decisions about centering during - # resize and zoom events. - def calculateNewRealToPixel(self, targetLeft, targetScale): - return QtGui.QTransform.fromScale(targetScale, 1).translate( - -targetLeft, 0) - # pixel vals for sliders: 0 to slider_width - 1 def realToPixel(self, val): - rawVal = (QtCore.QPointF(val, 0) * self.realToPixelTransform).x() + a, b = self.realToPixelTransform + rawVal = b*(val + a) # Clamp pixel values to 32 bits, b/c Qt will otherwise wrap values. - if rawVal < -(2**31): - rawVal = -(2**31) - elif rawVal > (2**31 - 1): - rawVal = (2**31 - 1) + rawVal = min(max(-(1 << 31), rawVal), (1 << 31) - 1) return rawVal # Get a point from pixel units to what the sliders display. def pixelToReal(self, val): - (revXform, invertible) = self.realToPixelTransform.inverted() - if not invertible: - revXform = (QtGui.QTransform.fromTranslate( - -self.realToPixelTransform.dx(), 0) * - QtGui.QTransform.fromScale( - 1/self.realToPixelTransform.m11(), 0)) - realPoint = QtCore.QPointF(val, 0) * revXform - return realPoint.x() + a, b = self.realToPixelTransform + return val/b - a def rangeToReal(self, val): - # gx = self.slider.grooveX() - # ax = self.axis.x() - # assert gx == ax, "gx: {}, ax: {}".format(gx, ax) pixelVal = self.slider.rangeValueToPixelPos(val) return self.pixelToReal(pixelVal) @@ -470,11 +385,12 @@ class ScanProxy(QtCore.QObject): self.axis.update() def handleZoom(self, zoomFactor, mouseXPos): - newScale = self.realToPixelTransform.m11() * zoomFactor + newScale = self.realToPixelTransform[1] * zoomFactor refReal = self.pixelToReal(mouseXPos) - newLeft = refReal - mouseXPos/newScale - self.realToPixelTransform = self.calculateNewRealToPixel( - newLeft, newScale) + newLeft = mouseXPos/newScale - refReal + if abs(newLeft*newScale) > self.dynamicRange: + return + self.realToPixelTransform = newLeft, newScale self.moveStop(self.realStop) self.moveStart(self.realStart) @@ -485,21 +401,41 @@ class ScanProxy(QtCore.QObject): refSlider = self.realStop else: refSlider = self.realStart - if self.rangeFactor <= 2: - return # Ill-formed snap range- do nothing. + if self.rangeFactor <= 2 or currRangeReal == 0: + return # Ill-formed snap range- do nothing proportion = self.rangeFactor/(self.rangeFactor - 2) newScale = self.slider.effectiveWidth()/(proportion*currRangeReal) - newLeft = refSlider - self.slider.effectiveWidth() \ - / (self.rangeFactor*newScale) - self.realToPixelTransform = self.calculateNewRealToPixel( - newLeft, newScale) - self.printTransform() + newLeft = (self.slider.effectiveWidth()/(self.rangeFactor*newScale) - + refSlider) + self.realToPixelTransform = newLeft, newScale self.moveStop(self.realStop) self.moveStart(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 zoomToFitInit(self): + currRangeReal = abs(self.realStop - self.realStart) + if self.rangeFactor <= 2 or currRangeReal == 0: + self.moveStop(self.realStop) + self.moveStart(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.zoomToFit() + # 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 fitToView(self): lowRange = 1.0/self.rangeFactor highRange = (self.rangeFactor - 1)/self.rangeFactor @@ -527,15 +463,22 @@ class ScanProxy(QtCore.QObject): newWidth = ev.size().width() - self.slider.handleWidth() # assert refRight > oldLeft newScale = newWidth/(refRight - oldLeft) + self.realToPixelTransform = -oldLeft, newScale else: # TODO: self.axis.width() is invalid during object # 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 zoomToFit 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.zoomToFitInit() self.invalidOldSizeExpected = False - self.realToPixelTransform = self.calculateNewRealToPixel( - oldLeft, newScale) # assert self.pixelToReal(0) == oldLeft, \ # "{}, {}".format(self.pixelToReal(0), oldLeft) # Slider will update independently, making sure that the old @@ -544,26 +487,17 @@ class ScanProxy(QtCore.QObject): # same positions in the new axis-space. return False - def printTransform(self): - print("m11: {}, dx: {}".format( - self.realToPixelTransform.m11(), self.realToPixelTransform.dx())) - (inverted, invertible) = self.realToPixelTransform.inverted() - print("m11: {}, dx: {}, singular: {}".format( - inverted.m11(), inverted.dx(), not invertible)) - class ScanWidget(QtWidgets.QWidget): sigStartMoved = QtCore.pyqtSignal(float) sigStopMoved = QtCore.pyqtSignal(float) sigNumChanged = QtCore.pyqtSignal(int) - def __init__(self, zoomFactor=1.05, rangeFactor=6): + def __init__(self, zoomFactor=1.05, rangeFactor=6, dynamicRange=1e8): QtWidgets.QWidget.__init__(self) self.slider = slider = ScanSlider() self.axis = axis = ScanAxis(zoomFactor) - zoomFitButton = QtWidgets.QPushButton("View Range") - fitViewButton = QtWidgets.QPushButton("Snap Range") - self.proxy = ScanProxy(slider, axis, rangeFactor) + self.proxy = ScanProxy(slider, axis, rangeFactor, dynamicRange) axis.proxy = self.proxy axis.slider = slider slider.setMaximum(1023) @@ -574,11 +508,9 @@ class ScanWidget(QtWidgets.QWidget): layout.setRowMinimumHeight(0, 40) layout.addWidget(axis, 0, 0, 1, -1) layout.addWidget(slider, 1, 0, 1, -1) - layout.addWidget(zoomFitButton, 2, 0) - layout.addWidget(fitViewButton, 2, 1) self.setLayout(layout) - # Connect signals + # Connect signals (minus context menu) slider.sigStopMoved.connect(self.proxy.handleStopMoved) slider.sigStartMoved.connect(self.proxy.handleStartMoved) self.proxy.sigStopMoved.connect(self.sigStopMoved) @@ -586,13 +518,17 @@ class ScanWidget(QtWidgets.QWidget): self.proxy.sigNumPoints.connect(self.sigNumChanged) axis.sigZoom.connect(self.proxy.handleZoom) axis.sigPoints.connect(self.proxy.handleNumPoints) - fitViewButton.clicked.connect(self.fitToView) - zoomFitButton.clicked.connect(self.zoomToFit) # Connect event observers. axis.installEventFilter(self.proxy) slider.installEventFilter(axis) + # Context menu entries + self.zoomToFitAct = QtWidgets.QAction("&View Range", self) + self.fitToViewAct = QtWidgets.QAction("&Snap Range", self) + self.zoomToFitAct.triggered.connect(self.zoomToFit) + self.fitToViewAct.triggered.connect(self.fitToView) + # Spinbox and button slots. Any time the spinboxes change, ScanWidget # mirrors it and passes the information to the proxy. def setStop(self, val): @@ -610,5 +546,8 @@ class ScanWidget(QtWidgets.QWidget): def fitToView(self): self.proxy.fitToView() - def reset(self): - self.proxy.reset() + def contextMenuEvent(self, ev): + menu = QtWidgets.QMenu(self) + menu.addAction(self.zoomToFitAct) + menu.addAction(self.fitToViewAct) + menu.exec(ev.globalPos()) From 7f3e1c989d446be0b156a796c03285c1a3dbc5c0 Mon Sep 17 00:00:00 2001 From: Robert Jordens Date: Fri, 11 Mar 2016 18:29:21 +0100 Subject: [PATCH 7/8] scanwidget: apply changes as of 10439cb --- artiq/gui/scanwidget.py | 90 +++++++++++++++++++++-------------------- 1 file changed, 47 insertions(+), 43 deletions(-) diff --git a/artiq/gui/scanwidget.py b/artiq/gui/scanwidget.py index 56cbfac13..ba6e5f02d 100644 --- a/artiq/gui/scanwidget.py +++ b/artiq/gui/scanwidget.py @@ -1,9 +1,14 @@ +import logging + from PyQt5 import QtGui, QtCore, QtWidgets from numpy import linspace from .ticker import Ticker +logger = logging.getLogger(__name__) + + class ScanAxis(QtWidgets.QWidget): sigZoom = QtCore.pyqtSignal(float, int) sigPoints = QtCore.pyqtSignal(int) @@ -52,6 +57,10 @@ class ScanAxis(QtWidgets.QWidget): if y: 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.) self.sigPoints.emit(z) else: @@ -319,14 +328,14 @@ class ScanProxy(QtCore.QObject): sigStopMoved = QtCore.pyqtSignal(float) sigNumPoints = QtCore.pyqtSignal(int) - def __init__(self, slider, axis, rangeFactor, dynamicRange): + def __init__(self, slider, axis, zoomMargin, dynamicRange): QtCore.QObject.__init__(self) self.axis = axis self.slider = slider self.realStart = 0 self.realStop = 0 self.numPoints = 10 - self.rangeFactor = rangeFactor + self.zoomMargin = zoomMargin self.dynamicRange = dynamicRange # Transform that maps the spinboxes to a pixel position on the @@ -335,13 +344,13 @@ class ScanProxy(QtCore.QObject): # 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 # pixel vals for sliders: 0 to slider_width - 1 def realToPixel(self, val): a, b = self.realToPixelTransform - rawVal = b*(val + a) + 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 @@ -349,7 +358,7 @@ class ScanProxy(QtCore.QObject): # Get a point from pixel units to what the sliders display. def pixelToReal(self, val): a, b = self.realToPixelTransform - return val/b - a + return val/b + a def rangeToReal(self, val): pixelVal = self.slider.rangeValueToPixelPos(val) @@ -387,26 +396,22 @@ class ScanProxy(QtCore.QObject): def handleZoom(self, zoomFactor, mouseXPos): newScale = self.realToPixelTransform[1] * zoomFactor refReal = self.pixelToReal(mouseXPos) - newLeft = mouseXPos/newScale - refReal - if abs(newLeft*newScale) > self.dynamicRange: + 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.moveStop(self.realStop) self.moveStart(self.realStart) - def zoomToFit(self): - currRangeReal = abs(self.realStop - self.realStart) - # Slider closest to the left should be used to find the new axis left. - if self.realStop < self.realStart: - refSlider = self.realStop - else: - refSlider = self.realStart - if self.rangeFactor <= 2 or currRangeReal == 0: - return # Ill-formed snap range- do nothing - proportion = self.rangeFactor/(self.rangeFactor - 2) - newScale = self.slider.effectiveWidth()/(proportion*currRangeReal) - newLeft = (self.slider.effectiveWidth()/(self.rangeFactor*newScale) - - refSlider) + 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.realToPixelTransform = newLeft, newScale self.moveStop(self.realStop) self.moveStart(self.realStart) @@ -419,9 +424,9 @@ class ScanProxy(QtCore.QObject): # 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 zoomToFitInit(self): + def viewRangeInit(self): currRangeReal = abs(self.realStop - self.realStart) - if self.rangeFactor <= 2 or currRangeReal == 0: + if currRangeReal == 0: self.moveStop(self.realStop) self.moveStart(self.realStart) # Ill-formed snap range- move the sliders anyway, @@ -430,15 +435,15 @@ class ScanProxy(QtCore.QObject): # This will force the sliders to have positions on the axis # which reflect the start/stop values currently set. else: - self.zoomToFit() + 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 fitToView(self): - lowRange = 1.0/self.rangeFactor - highRange = (self.rangeFactor - 1)/self.rangeFactor + def snapRange(self): + lowRange = self.zoomMargin + highRange = 1 - self.zoomMargin newStart = self.pixelToReal(lowRange * self.slider.effectiveWidth()) newStop = self.pixelToReal(highRange * self.slider.effectiveWidth()) sliderRange = self.slider.maximum() - self.slider.minimum() @@ -463,21 +468,21 @@ class ScanProxy(QtCore.QObject): newWidth = ev.size().width() - self.slider.handleWidth() # assert refRight > oldLeft newScale = newWidth/(refRight - oldLeft) - self.realToPixelTransform = -oldLeft, newScale + self.realToPixelTransform = oldLeft, newScale else: # TODO: self.axis.width() is invalid during object # 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 + 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 zoomToFit based on the desired real values. + # 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.zoomToFitInit() + self.viewRangeInit() self.invalidOldSizeExpected = False # assert self.pixelToReal(0) == oldLeft, \ # "{}, {}".format(self.pixelToReal(0), oldLeft) @@ -493,13 +498,12 @@ class ScanWidget(QtWidgets.QWidget): sigStopMoved = QtCore.pyqtSignal(float) sigNumChanged = QtCore.pyqtSignal(int) - def __init__(self, zoomFactor=1.05, rangeFactor=6, dynamicRange=1e8): + def __init__(self, zoomFactor=1.05, zoomMargin=.1, dynamicRange=1e8): QtWidgets.QWidget.__init__(self) self.slider = slider = ScanSlider() self.axis = axis = ScanAxis(zoomFactor) - self.proxy = ScanProxy(slider, axis, rangeFactor, dynamicRange) + self.proxy = ScanProxy(slider, axis, zoomMargin, dynamicRange) axis.proxy = self.proxy - axis.slider = slider slider.setMaximum(1023) # Layout. @@ -524,10 +528,10 @@ class ScanWidget(QtWidgets.QWidget): slider.installEventFilter(axis) # Context menu entries - self.zoomToFitAct = QtWidgets.QAction("&View Range", self) - self.fitToViewAct = QtWidgets.QAction("&Snap Range", self) - self.zoomToFitAct.triggered.connect(self.zoomToFit) - self.fitToViewAct.triggered.connect(self.fitToView) + self.viewRangeAct = QtWidgets.QAction("&View Range", self) + self.snapRangeAct = QtWidgets.QAction("&Snap Range", self) + self.viewRangeAct.triggered.connect(self.viewRange) + self.snapRangeAct.triggered.connect(self.snapRange) # Spinbox and button slots. Any time the spinboxes change, ScanWidget # mirrors it and passes the information to the proxy. @@ -540,14 +544,14 @@ class ScanWidget(QtWidgets.QWidget): def setNumPoints(self, val): self.proxy.setNumPoints(val) - def zoomToFit(self): - self.proxy.zoomToFit() + def viewRange(self): + self.proxy.viewRange() - def fitToView(self): - self.proxy.fitToView() + def snapRange(self): + self.proxy.snapRange() def contextMenuEvent(self, ev): menu = QtWidgets.QMenu(self) - menu.addAction(self.zoomToFitAct) - menu.addAction(self.fitToViewAct) + menu.addAction(self.viewRangeAct) + menu.addAction(self.snapRangeAct) menu.exec(ev.globalPos()) From 22b072699f64cc58a2424a659d50138213c4da88 Mon Sep 17 00:00:00 2001 From: Robert Jordens Date: Fri, 11 Mar 2016 18:30:27 +0100 Subject: [PATCH 8/8] gui: fix scanwidget usage --- artiq/gui/entries.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/artiq/gui/entries.py b/artiq/gui/entries.py index 79064e29b..161f3f342 100644 --- a/artiq/gui/entries.py +++ b/artiq/gui/entries.py @@ -186,7 +186,7 @@ class _RangeScan(LayoutWidget): scanner.setStart(value) def update_max(value): - state["min"] = value*scale + state["max"] = value*scale scanner.setStop(value) def update_npoints(value):