forked from M-Labs/artiq
scanwidget: add from current git
This commit is contained in:
parent
51e831cec5
commit
3ed8288601
|
@ -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()
|
|
@ -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
|
|
@ -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
|
Loading…
Reference in New Issue