Merge branch 'scanwidget' (closes #128)

* scanwidget:
  gui: fix scanwidget usage
  scanwidget: apply changes as of 10439cb
  scanwidget: apply changes as of 98f0a56
  missing parts of 59ac567
  scanwidget: disable unmodified wheel on axis and slider
  scanwidget: wire up signals better, set values late, take scanwidget from 7aa6397
  gui: use scanwidget
  scanwidget: add from current git
This commit is contained in:
Robert Jördens 2016-03-11 18:31:25 +01:00
commit d34d83f35c
4 changed files with 804 additions and 18 deletions

View File

@ -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):

557
artiq/gui/scanwidget.py Normal file
View File

@ -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())

View File

@ -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

136
artiq/gui/ticker.py Normal file
View File

@ -0,0 +1,136 @@
# Robert Jordens <rj@m-labs.hk>, 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