forked from M-Labs/artiq
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:
commit
d34d83f35c
|
@ -4,6 +4,8 @@ from collections import OrderedDict
|
||||||
from PyQt5 import QtCore, QtGui, QtWidgets
|
from PyQt5 import QtCore, QtGui, QtWidgets
|
||||||
|
|
||||||
from artiq.gui.tools import LayoutWidget, disable_scroll_wheel
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
@ -136,6 +138,7 @@ class _RangeScan(LayoutWidget):
|
||||||
LayoutWidget.__init__(self)
|
LayoutWidget.__init__(self)
|
||||||
|
|
||||||
scale = procdesc["scale"]
|
scale = procdesc["scale"]
|
||||||
|
|
||||||
def apply_properties(spinbox):
|
def apply_properties(spinbox):
|
||||||
spinbox.setDecimals(procdesc["ndecimals"])
|
spinbox.setDecimals(procdesc["ndecimals"])
|
||||||
if procdesc["global_min"] is not None:
|
if procdesc["global_min"] is not None:
|
||||||
|
@ -151,37 +154,56 @@ class _RangeScan(LayoutWidget):
|
||||||
if procdesc["unit"]:
|
if procdesc["unit"]:
|
||||||
spinbox.setSuffix(" " + procdesc["unit"])
|
spinbox.setSuffix(" " + procdesc["unit"])
|
||||||
|
|
||||||
self.addWidget(QtWidgets.QLabel("Min:"), 0, 0)
|
self.scanner = scanner = ScanWidget()
|
||||||
self.min = QtWidgets.QDoubleSpinBox()
|
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)
|
disable_scroll_wheel(self.min)
|
||||||
apply_properties(self.min)
|
|
||||||
self.addWidget(self.min, 0, 1)
|
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 = QtWidgets.QSpinBox()
|
||||||
|
self.npoints.setMinimum(1)
|
||||||
disable_scroll_wheel(self.npoints)
|
disable_scroll_wheel(self.npoints)
|
||||||
self.npoints.setMinimum(2)
|
self.addWidget(self.npoints, 1, 1)
|
||||||
self.npoints.setValue(10)
|
|
||||||
self.addWidget(self.npoints, 2, 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):
|
def update_min(value):
|
||||||
state["min"] = value*scale
|
state["min"] = value*scale
|
||||||
|
scanner.setStart(value)
|
||||||
|
|
||||||
def update_max(value):
|
def update_max(value):
|
||||||
state["min"] = value*scale
|
state["max"] = value*scale
|
||||||
|
scanner.setStop(value)
|
||||||
|
|
||||||
def update_npoints(value):
|
def update_npoints(value):
|
||||||
state["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.min.valueChanged.connect(update_min)
|
||||||
self.max.valueChanged.connect(update_max)
|
|
||||||
self.npoints.valueChanged.connect(update_npoints)
|
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):
|
class _ExplicitScan(LayoutWidget):
|
||||||
|
|
|
@ -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())
|
|
@ -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