diff --git a/artiq/gui/entries.py b/artiq/gui/entries.py index 92f30959f..161f3f342 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,56 @@ 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.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() + self.min.setStyleSheet("QDoubleSpinBox {color:blue}") + self.min.setMinimumSize(110, 0) + self.min.setSizePolicy(QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed)) 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() - disable_scroll_wheel(self.max) - apply_properties(self.max) - self.addWidget(self.max, 1, 1) - - self.addWidget(QtWidgets.QLabel("#Points:"), 2, 0) self.npoints = QtWidgets.QSpinBox() + self.npoints.setMinimum(1) disable_scroll_wheel(self.npoints) - self.npoints.setMinimum(2) - self.npoints.setValue(10) - self.addWidget(self.npoints, 2, 1) + self.addWidget(self.npoints, 1, 1) + + self.max = ScientificSpinBox() + self.max.setStyleSheet("QDoubleSpinBox {color:red}") + self.max.setMinimumSize(110, 0) + disable_scroll_wheel(self.max) + self.addWidget(self.max, 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 + scanner.setStart(value) + def update_max(value): - state["min"] = value*scale + state["max"] = value*scale + scanner.setStop(value) + def update_npoints(value): state["npoints"] = value + scanner.setNumPoints(value) + + 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.max.valueChanged.connect(update_max) 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) + apply_properties(self.min) + apply_properties(self.max) class _ExplicitScan(LayoutWidget): diff --git a/artiq/gui/scanwidget.py b/artiq/gui/scanwidget.py new file mode 100644 index 000000000..ba6e5f02d --- /dev/null +++ b/artiq/gui/scanwidget.py @@ -0,0 +1,557 @@ +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) + + 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) + 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) + painter.drawLine(t, 0, t, -5) + painter.drawText(t - len(l)/2*avgCharWidth, -10, 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() + + def wheelEvent(self, ev): + y = ev.angleDelta().y() + 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: + 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 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) + + def __init__(self): + QtWidgets.QSlider.__init__(self, QtCore.Qt.Horizontal) + self.startPos = 0 # Pos and Val can differ in event handling. + # perhaps prevPos and currPos is more accurate. + self.stopPos = 99 + self.startVal = 0 # lower + self.stopVal = 99 # upper + self.offset = 0 + self.position = 0 + self.upperPressed = QtWidgets.QStyle.SC_None + self.lowerPressed = QtWidgets.QStyle.SC_None + self.firstMovement = False # State var for handling slider overlap. + self.blockTracking = False + + # We need fake sliders to keep around so that we can dynamically + # set the stylesheets for drawing each slider later. See paintEvent. + self.dummyStartSlider = QtWidgets.QSlider() + self.dummyStopSlider = QtWidgets.QSlider() + 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 == "start": + opt.sliderPosition = self.startPos + opt.sliderValue = self.startVal + elif handle == "stop": + opt.sliderPosition = self.stopPos + opt.sliderValue = self.stopVal + + # We get the range of each slider separately. + def pixelPosToRangeValue(self, pos): + 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 + + 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 + + # 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) + return gr.width() - self.handleWidth() + + def handleMousePress(self, pos, control, val, handle): + opt = QtWidgets.QStyleOptionSlider() + self.initHandleStyleOption(opt, handle) + startAtEdges = (handle == "start" and + (self.startVal == self.minimum() or + self.startVal == self.maximum())) + stopAtEdges = (handle == "stop" and + (self.stopVal == self.minimum() or + self.stopVal == self.maximum())) + + # If chosen slider at edge, treat it as non-interactive. + if startAtEdges or stopAtEdges: + return QtWidgets.QStyle.SC_None + + oldControl = control + control = self.style().hitTestComplexControl( + QtWidgets.QStyle.CC_Slider, opt, pos, self) + sr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt, + QtWidgets.QStyle.SC_SliderHandle, + self) + if control == QtWidgets.QStyle.SC_SliderHandle: + # no pick()- slider orientation static + self.offset = pos.x() - sr.topLeft().x() + self.setSliderDown(True) + # emit + + # Needed? + if control != oldControl: + self.update(sr) + return control + + def drawHandle(self, painter, handle): + opt = QtWidgets.QStyleOptionSlider() + 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 setSpan(self, low, high): + # TODO: Is this necessary? QStyle::sliderPositionFromValue appears + # to clamp already. + 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: + self.startVal = low + self.startPos = low + if high != self.stopVal: + self.stopVal = high + self.stopPos = high + self.update() + + def setStartPosition(self, val): + if val != self.startPos: + self.startPos = val + if not self.hasTracking(): + self.update() + if self.isSliderDown(): + self.sigStartMoved.emit(self.startPos) + if self.hasTracking() and not self.blockTracking: + self.setSpan(self.startPos, self.stopVal) + + def setStopPosition(self, val): + if val != self.stopPos: + self.stopPos = val + if not self.hasTracking(): + self.update() + if self.isSliderDown(): + self.sigStopMoved.emit(self.stopPos) + if self.hasTracking() and not self.blockTracking: + self.setSpan(self.startVal, self.stopPos) + + def mousePressEvent(self, ev): + if self.minimum() == self.maximum() or (ev.buttons() ^ ev.button()): + ev.ignore() + return + + # Prefer stopVal in the default case. + self.upperPressed = self.handleMousePress( + ev.pos(), self.upperPressed, self.stopVal, "stop") + if self.upperPressed != QtWidgets.QStyle.SC_SliderHandle: + self.lowerPressed = self.handleMousePress( + ev.pos(), self.upperPressed, self.startVal, "start") + + # 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.startPos == self.stopPos: + # StopSlider is preferred, except in the case where + # start == max possible value the slider can take. + if self.startPos == self.maximum(): + self.lowerPressed = QtWidgets.QStyle.SC_SliderHandle + self.upperPressed = QtWidgets.QStyle.SC_None + self.firstMovement = False + + if self.lowerPressed == QtWidgets.QStyle.SC_SliderHandle: + self.setStartPosition(newPos) + + if self.upperPressed == QtWidgets.QStyle.SC_SliderHandle: + self.setStopPosition(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. + # Paint on the custom widget, using the attributes of the fake + # slider references we keep around. setStyleSheet within paintEvent + # leads to heavy performance penalties (and recursion?). + # QPalettes would be nicer to use, since palette entries can be set + # individually for each slider handle, but Windows 7 does not + # use them. This seems to be the only way to override the colors + # regardless of platform. + startPainter = QtWidgets.QStylePainter(self, self.dummyStartSlider) + stopPainter = QtWidgets.QStylePainter(self, self.dummyStopSlider) + + # 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, "start") + if self.stopVal > 0 and self.stopVal < self.maximum(): + self.drawHandle(stopPainter, "stop") + + +# real (Sliders) => pixel (one pixel movement of sliders would increment by X) +# => range (minimum granularity that sliders understand). +class ScanProxy(QtCore.QObject): + sigStartMoved = QtCore.pyqtSignal(float) + sigStopMoved = QtCore.pyqtSignal(float) + sigNumPoints = QtCore.pyqtSignal(int) + + 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.zoomMargin = zoomMargin + 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 + # displayed on the axis. + # Because the axis's width will change when placed within a layout, + # the realToPixelTransform will initially be invalid. It will be set + # properly during the first resizeEvent, with the below transform. + self.realToPixelTransform = -self.axis.width()/2, 1. + self.invalidOldSizeExpected = True + + # 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 + + # Get a point from pixel units to what the sliders display. + 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 moveStop(self, val): + sliderX = self.realToRange(val) + self.slider.setStopPosition(sliderX) + self.realStop = val + self.axis.update() # Number of points ticks changed positions. + + def moveStart(self, val): + sliderX = self.realToRange(val) + self.slider.setStartPosition(sliderX) + self.realStart = val + self.axis.update() + + def handleStopMoved(self, rangeVal): + self.sigStopMoved.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[1] * zoomFactor + refReal = self.pixelToReal(mouseXPos) + newLeft = refReal - mouseXPos/newScale + newZero = newLeft*newScale + self.slider.effectiveWidth()/2 + if zoomFactor > 1 and abs(newZero) > self.dynamicRange: + return + self.realToPixelTransform = newLeft, newScale + self.moveStop(self.realStop) + self.moveStart(self.realStart) + + 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) + self.axis.update() # Axis normally takes care to update itself during + # zoom. In this code path however, the zoom didn't arrive via the axis + # widget, so we need to notify manually. + + # This function is called if the axis width, slider width, and slider + # positions are in an inconsistent state, to initialize the widget. + # This function handles handles the slider positions. Slider and axis + # handle its own width changes; proxy watches for axis width resizeEvent to + # alter mapping from real to pixel space. + def viewRangeInit(self): + currRangeReal = abs(self.realStop - self.realStart) + if currRangeReal == 0: + self.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.viewRange() + # Notify spinboxes manually, since slider wasn't clicked and will + # therefore not emit signals. + self.sigStopMoved.emit(self.realStop) + self.sigStartMoved.emit(self.realStart) + + def snapRange(self): + 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() + # Signals won't fire unless slider was actually grabbed, so + # manually update so the spinboxes know that knew values were set. + # 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 + 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 + 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 viewRange based on the desired real values. + # The slider handle values are invalid before this point as well; + # we set them to the correct value here, regardless of whether + # the slider has already resized itsef or not. + self.viewRangeInit() + self.invalidOldSizeExpected = False + # 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 + + +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=1e8): + QtWidgets.QWidget.__init__(self) + self.slider = slider = ScanSlider() + self.axis = axis = ScanAxis(zoomFactor) + self.proxy = ScanProxy(slider, axis, zoomMargin, dynamicRange) + axis.proxy = self.proxy + slider.setMaximum(1023) + + # 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) + self.setLayout(layout) + + # Connect signals (minus context menu) + 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) + + # Connect event observers. + axis.installEventFilter(self.proxy) + slider.installEventFilter(axis) + + # Context menu entries + 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. + def setStop(self, val): + self.proxy.moveStop(val) + + def setStart(self, val): + self.proxy.moveStart(val) + + def setNumPoints(self, val): + self.proxy.setNumPoints(val) + + def viewRange(self): + self.proxy.viewRange() + + def snapRange(self): + self.proxy.snapRange() + + def contextMenuEvent(self, ev): + menu = QtWidgets.QMenu(self) + menu.addAction(self.viewRangeAct) + menu.addAction(self.snapRangeAct) + menu.exec(ev.globalPos()) 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