-
Notifications
You must be signed in to change notification settings - Fork 201
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
Showing
4 changed files
with
804 additions
and
18 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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()) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |