Skip to content

Commit d34d83f

Browse files
committedMar 11, 2016
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
2 parents 01e919d + 22b0726 commit d34d83f

File tree

4 files changed

+804
-18
lines changed

4 files changed

+804
-18
lines changed
 

Diff for: ‎artiq/gui/entries.py

+40-18
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from PyQt5 import QtCore, QtGui, QtWidgets
55

66
from artiq.gui.tools import LayoutWidget, disable_scroll_wheel
7+
from artiq.gui.scanwidget import ScanWidget
8+
from artiq.gui.scientific_spinbox import ScientificSpinBox
79

810

911
logger = logging.getLogger(__name__)
@@ -136,6 +138,7 @@ def __init__(self, procdesc, state):
136138
LayoutWidget.__init__(self)
137139

138140
scale = procdesc["scale"]
141+
139142
def apply_properties(spinbox):
140143
spinbox.setDecimals(procdesc["ndecimals"])
141144
if procdesc["global_min"] is not None:
@@ -151,37 +154,56 @@ def apply_properties(spinbox):
151154
if procdesc["unit"]:
152155
spinbox.setSuffix(" " + procdesc["unit"])
153156

154-
self.addWidget(QtWidgets.QLabel("Min:"), 0, 0)
155-
self.min = QtWidgets.QDoubleSpinBox()
157+
self.scanner = scanner = ScanWidget()
158+
scanner.setMinimumSize(150, 0)
159+
scanner.setSizePolicy(QtWidgets.QSizePolicy(
160+
QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed))
161+
disable_scroll_wheel(scanner.axis)
162+
disable_scroll_wheel(scanner.slider)
163+
self.addWidget(scanner, 0, 0, -1, 1)
164+
165+
self.min = ScientificSpinBox()
166+
self.min.setStyleSheet("QDoubleSpinBox {color:blue}")
167+
self.min.setMinimumSize(110, 0)
168+
self.min.setSizePolicy(QtWidgets.QSizePolicy(
169+
QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed))
156170
disable_scroll_wheel(self.min)
157-
apply_properties(self.min)
158171
self.addWidget(self.min, 0, 1)
159172

160-
self.addWidget(QtWidgets.QLabel("Max:"), 1, 0)
161-
self.max = QtWidgets.QDoubleSpinBox()
162-
disable_scroll_wheel(self.max)
163-
apply_properties(self.max)
164-
self.addWidget(self.max, 1, 1)
165-
166-
self.addWidget(QtWidgets.QLabel("#Points:"), 2, 0)
167173
self.npoints = QtWidgets.QSpinBox()
174+
self.npoints.setMinimum(1)
168175
disable_scroll_wheel(self.npoints)
169-
self.npoints.setMinimum(2)
170-
self.npoints.setValue(10)
171-
self.addWidget(self.npoints, 2, 1)
176+
self.addWidget(self.npoints, 1, 1)
177+
178+
self.max = ScientificSpinBox()
179+
self.max.setStyleSheet("QDoubleSpinBox {color:red}")
180+
self.max.setMinimumSize(110, 0)
181+
disable_scroll_wheel(self.max)
182+
self.addWidget(self.max, 2, 1)
172183

173-
self.min.setValue(state["min"]/scale)
174-
self.max.setValue(state["max"]/scale)
175-
self.npoints.setValue(state["npoints"])
176184
def update_min(value):
177185
state["min"] = value*scale
186+
scanner.setStart(value)
187+
178188
def update_max(value):
179-
state["min"] = value*scale
189+
state["max"] = value*scale
190+
scanner.setStop(value)
191+
180192
def update_npoints(value):
181193
state["npoints"] = value
194+
scanner.setNumPoints(value)
195+
196+
scanner.sigStartMoved.connect(self.min.setValue)
197+
scanner.sigNumChanged.connect(self.npoints.setValue)
198+
scanner.sigStopMoved.connect(self.max.setValue)
182199
self.min.valueChanged.connect(update_min)
183-
self.max.valueChanged.connect(update_max)
184200
self.npoints.valueChanged.connect(update_npoints)
201+
self.max.valueChanged.connect(update_max)
202+
self.min.setValue(state["min"]/scale)
203+
self.npoints.setValue(state["npoints"])
204+
self.max.setValue(state["max"]/scale)
205+
apply_properties(self.min)
206+
apply_properties(self.max)
185207

186208

187209
class _ExplicitScan(LayoutWidget):

Diff for: ‎artiq/gui/scanwidget.py

+557
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,557 @@
1+
import logging
2+
3+
from PyQt5 import QtGui, QtCore, QtWidgets
4+
from numpy import linspace
5+
6+
from .ticker import Ticker
7+
8+
9+
logger = logging.getLogger(__name__)
10+
11+
12+
class ScanAxis(QtWidgets.QWidget):
13+
sigZoom = QtCore.pyqtSignal(float, int)
14+
sigPoints = QtCore.pyqtSignal(int)
15+
16+
def __init__(self, zoomFactor):
17+
QtWidgets.QWidget.__init__(self)
18+
self.proxy = None
19+
self.sizePolicy().setControlType(QtWidgets.QSizePolicy.ButtonBox)
20+
self.ticker = Ticker()
21+
self.zoomFactor = zoomFactor
22+
23+
def paintEvent(self, ev):
24+
painter = QtGui.QPainter(self)
25+
font = painter.font()
26+
avgCharWidth = QtGui.QFontMetrics(font).averageCharWidth()
27+
painter.setRenderHint(QtGui.QPainter.Antialiasing)
28+
# The center of the slider handles should reflect what's displayed
29+
# on the spinboxes.
30+
painter.translate(self.proxy.slider.handleWidth()/2, self.height() - 5)
31+
painter.drawLine(0, 0, self.width(), 0)
32+
realLeft = self.proxy.pixelToReal(0)
33+
realRight = self.proxy.pixelToReal(self.width())
34+
ticks, prefix, labels = self.ticker(realLeft, realRight)
35+
painter.drawText(0, -25, prefix)
36+
37+
pen = QtGui.QPen()
38+
pen.setWidth(2)
39+
painter.setPen(pen)
40+
41+
for t, l in zip(ticks, labels):
42+
t = self.proxy.realToPixel(t)
43+
painter.drawLine(t, 0, t, -5)
44+
painter.drawText(t - len(l)/2*avgCharWidth, -10, l)
45+
46+
sliderStartPixel = self.proxy.realToPixel(self.proxy.realStart)
47+
sliderStopPixel = self.proxy.realToPixel(self.proxy.realStop)
48+
pixels = linspace(sliderStartPixel, sliderStopPixel,
49+
self.proxy.numPoints)
50+
for p in pixels:
51+
p_int = int(p)
52+
painter.drawLine(p_int, 0, p_int, 5)
53+
ev.accept()
54+
55+
def wheelEvent(self, ev):
56+
y = ev.angleDelta().y()
57+
if y:
58+
if ev.modifiers() & QtCore.Qt.ShiftModifier:
59+
# If shift+scroll, modify number of points.
60+
# TODO: This is not perfect. For high-resolution touchpads you
61+
# get many small events with y < 120 which should accumulate.
62+
# That would also match the wheel behavior of an integer
63+
# spinbox.
64+
z = int(y / 120.)
65+
self.sigPoints.emit(z)
66+
else:
67+
z = self.zoomFactor**(y / 120.)
68+
# Remove the slider-handle shift correction, b/c none of the
69+
# other widgets know about it. If we have the mouse directly
70+
# over a tick during a zoom, it should appear as if we are
71+
# doing zoom relative to the ticks which live in axis
72+
# pixel-space, not slider pixel-space.
73+
self.sigZoom.emit(
74+
z, ev.x() - self.proxy.slider.handleWidth()/2)
75+
self.update()
76+
ev.accept()
77+
78+
def eventFilter(self, obj, ev):
79+
if obj is not self.proxy.slider:
80+
return False
81+
if ev.type() != QtCore.QEvent.Wheel:
82+
return False
83+
self.wheelEvent(ev)
84+
return True
85+
86+
87+
# Basic ideas from https://gist.github.com/Riateche/27e36977f7d5ea72cf4f
88+
class ScanSlider(QtWidgets.QSlider):
89+
sigStartMoved = QtCore.pyqtSignal(int)
90+
sigStopMoved = QtCore.pyqtSignal(int)
91+
92+
def __init__(self):
93+
QtWidgets.QSlider.__init__(self, QtCore.Qt.Horizontal)
94+
self.startPos = 0 # Pos and Val can differ in event handling.
95+
# perhaps prevPos and currPos is more accurate.
96+
self.stopPos = 99
97+
self.startVal = 0 # lower
98+
self.stopVal = 99 # upper
99+
self.offset = 0
100+
self.position = 0
101+
self.upperPressed = QtWidgets.QStyle.SC_None
102+
self.lowerPressed = QtWidgets.QStyle.SC_None
103+
self.firstMovement = False # State var for handling slider overlap.
104+
self.blockTracking = False
105+
106+
# We need fake sliders to keep around so that we can dynamically
107+
# set the stylesheets for drawing each slider later. See paintEvent.
108+
self.dummyStartSlider = QtWidgets.QSlider()
109+
self.dummyStopSlider = QtWidgets.QSlider()
110+
self.dummyStartSlider.setStyleSheet(
111+
"QSlider::handle {background:blue}")
112+
self.dummyStopSlider.setStyleSheet(
113+
"QSlider::handle {background:red}")
114+
115+
# We basically superimpose two QSliders on top of each other, discarding
116+
# the state that remains constant between the two when drawing.
117+
# Everything except the handles remain constant.
118+
def initHandleStyleOption(self, opt, handle):
119+
self.initStyleOption(opt)
120+
if handle == "start":
121+
opt.sliderPosition = self.startPos
122+
opt.sliderValue = self.startVal
123+
elif handle == "stop":
124+
opt.sliderPosition = self.stopPos
125+
opt.sliderValue = self.stopVal
126+
127+
# We get the range of each slider separately.
128+
def pixelPosToRangeValue(self, pos):
129+
opt = QtWidgets.QStyleOptionSlider()
130+
self.initStyleOption(opt)
131+
gr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt,
132+
QtWidgets.QStyle.SC_SliderGroove,
133+
self)
134+
rangeVal = QtWidgets.QStyle.sliderValueFromPosition(
135+
self.minimum(), self.maximum(), pos - gr.x(),
136+
self.effectiveWidth(), opt.upsideDown)
137+
return rangeVal
138+
139+
def rangeValueToPixelPos(self, val):
140+
opt = QtWidgets.QStyleOptionSlider()
141+
self.initStyleOption(opt)
142+
pixel = QtWidgets.QStyle.sliderPositionFromValue(
143+
self.minimum(), self.maximum(), val, self.effectiveWidth(),
144+
opt.upsideDown)
145+
return pixel
146+
147+
# When calculating conversions to/from pixel space, not all of the slider's
148+
# width is actually usable, because the slider handle has a nonzero width.
149+
# We use this function as a helper when the axis needs slider information.
150+
def handleWidth(self):
151+
opt = QtWidgets.QStyleOptionSlider()
152+
self.initStyleOption(opt)
153+
sr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt,
154+
QtWidgets.QStyle.SC_SliderHandle,
155+
self)
156+
return sr.width()
157+
158+
def effectiveWidth(self):
159+
opt = QtWidgets.QStyleOptionSlider()
160+
self.initStyleOption(opt)
161+
gr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt,
162+
QtWidgets.QStyle.SC_SliderGroove,
163+
self)
164+
return gr.width() - self.handleWidth()
165+
166+
def handleMousePress(self, pos, control, val, handle):
167+
opt = QtWidgets.QStyleOptionSlider()
168+
self.initHandleStyleOption(opt, handle)
169+
startAtEdges = (handle == "start" and
170+
(self.startVal == self.minimum() or
171+
self.startVal == self.maximum()))
172+
stopAtEdges = (handle == "stop" and
173+
(self.stopVal == self.minimum() or
174+
self.stopVal == self.maximum()))
175+
176+
# If chosen slider at edge, treat it as non-interactive.
177+
if startAtEdges or stopAtEdges:
178+
return QtWidgets.QStyle.SC_None
179+
180+
oldControl = control
181+
control = self.style().hitTestComplexControl(
182+
QtWidgets.QStyle.CC_Slider, opt, pos, self)
183+
sr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt,
184+
QtWidgets.QStyle.SC_SliderHandle,
185+
self)
186+
if control == QtWidgets.QStyle.SC_SliderHandle:
187+
# no pick()- slider orientation static
188+
self.offset = pos.x() - sr.topLeft().x()
189+
self.setSliderDown(True)
190+
# emit
191+
192+
# Needed?
193+
if control != oldControl:
194+
self.update(sr)
195+
return control
196+
197+
def drawHandle(self, painter, handle):
198+
opt = QtWidgets.QStyleOptionSlider()
199+
self.initStyleOption(opt)
200+
self.initHandleStyleOption(opt, handle)
201+
opt.subControls = QtWidgets.QStyle.SC_SliderHandle
202+
painter.drawComplexControl(QtWidgets.QStyle.CC_Slider, opt)
203+
204+
# def triggerAction(self, action, slider):
205+
# if action == QtWidgets.QAbstractSlider.SliderSingleStepAdd:
206+
# if
207+
208+
def setSpan(self, low, high):
209+
# TODO: Is this necessary? QStyle::sliderPositionFromValue appears
210+
# to clamp already.
211+
low = min(max(self.minimum(), low), self.maximum())
212+
high = min(max(self.minimum(), high), self.maximum())
213+
214+
if low != self.startVal or high != self.stopVal:
215+
if low != self.startVal:
216+
self.startVal = low
217+
self.startPos = low
218+
if high != self.stopVal:
219+
self.stopVal = high
220+
self.stopPos = high
221+
self.update()
222+
223+
def setStartPosition(self, val):
224+
if val != self.startPos:
225+
self.startPos = val
226+
if not self.hasTracking():
227+
self.update()
228+
if self.isSliderDown():
229+
self.sigStartMoved.emit(self.startPos)
230+
if self.hasTracking() and not self.blockTracking:
231+
self.setSpan(self.startPos, self.stopVal)
232+
233+
def setStopPosition(self, val):
234+
if val != self.stopPos:
235+
self.stopPos = val
236+
if not self.hasTracking():
237+
self.update()
238+
if self.isSliderDown():
239+
self.sigStopMoved.emit(self.stopPos)
240+
if self.hasTracking() and not self.blockTracking:
241+
self.setSpan(self.startVal, self.stopPos)
242+
243+
def mousePressEvent(self, ev):
244+
if self.minimum() == self.maximum() or (ev.buttons() ^ ev.button()):
245+
ev.ignore()
246+
return
247+
248+
# Prefer stopVal in the default case.
249+
self.upperPressed = self.handleMousePress(
250+
ev.pos(), self.upperPressed, self.stopVal, "stop")
251+
if self.upperPressed != QtWidgets.QStyle.SC_SliderHandle:
252+
self.lowerPressed = self.handleMousePress(
253+
ev.pos(), self.upperPressed, self.startVal, "start")
254+
255+
# State that is needed to handle the case where two sliders are equal.
256+
self.firstMovement = True
257+
ev.accept()
258+
259+
def mouseMoveEvent(self, ev):
260+
if (self.lowerPressed != QtWidgets.QStyle.SC_SliderHandle and
261+
self.upperPressed != QtWidgets.QStyle.SC_SliderHandle):
262+
ev.ignore()
263+
return
264+
265+
opt = QtWidgets.QStyleOptionSlider()
266+
self.initStyleOption(opt)
267+
268+
# This code seems to be needed so that returning the slider to the
269+
# previous position is honored if a drag distance is exceeded.
270+
m = self.style().pixelMetric(QtWidgets.QStyle.PM_MaximumDragDistance,
271+
opt, self)
272+
newPos = self.pixelPosToRangeValue(ev.pos().x() - self.offset)
273+
274+
if m >= 0:
275+
r = self.rect().adjusted(-m, -m, m, m)
276+
if not r.contains(ev.pos()):
277+
newPos = self.position
278+
279+
if self.firstMovement:
280+
if self.startPos == self.stopPos:
281+
# StopSlider is preferred, except in the case where
282+
# start == max possible value the slider can take.
283+
if self.startPos == self.maximum():
284+
self.lowerPressed = QtWidgets.QStyle.SC_SliderHandle
285+
self.upperPressed = QtWidgets.QStyle.SC_None
286+
self.firstMovement = False
287+
288+
if self.lowerPressed == QtWidgets.QStyle.SC_SliderHandle:
289+
self.setStartPosition(newPos)
290+
291+
if self.upperPressed == QtWidgets.QStyle.SC_SliderHandle:
292+
self.setStopPosition(newPos)
293+
294+
ev.accept()
295+
296+
def mouseReleaseEvent(self, ev):
297+
QtWidgets.QSlider.mouseReleaseEvent(self, ev)
298+
self.setSliderDown(False) # AbstractSlider needs this
299+
self.lowerPressed = QtWidgets.QStyle.SC_None
300+
self.upperPressed = QtWidgets.QStyle.SC_None
301+
302+
def paintEvent(self, ev):
303+
# Use QStylePainters to make redrawing as painless as possible.
304+
# Paint on the custom widget, using the attributes of the fake
305+
# slider references we keep around. setStyleSheet within paintEvent
306+
# leads to heavy performance penalties (and recursion?).
307+
# QPalettes would be nicer to use, since palette entries can be set
308+
# individually for each slider handle, but Windows 7 does not
309+
# use them. This seems to be the only way to override the colors
310+
# regardless of platform.
311+
startPainter = QtWidgets.QStylePainter(self, self.dummyStartSlider)
312+
stopPainter = QtWidgets.QStylePainter(self, self.dummyStopSlider)
313+
314+
# Handles
315+
# Qt will snap sliders to 0 or maximum() if given a desired pixel
316+
# location outside the mapped range. So we manually just don't draw
317+
# the handles if they are at 0 or max.
318+
if self.startVal > 0 and self.startVal < self.maximum():
319+
self.drawHandle(startPainter, "start")
320+
if self.stopVal > 0 and self.stopVal < self.maximum():
321+
self.drawHandle(stopPainter, "stop")
322+
323+
324+
# real (Sliders) => pixel (one pixel movement of sliders would increment by X)
325+
# => range (minimum granularity that sliders understand).
326+
class ScanProxy(QtCore.QObject):
327+
sigStartMoved = QtCore.pyqtSignal(float)
328+
sigStopMoved = QtCore.pyqtSignal(float)
329+
sigNumPoints = QtCore.pyqtSignal(int)
330+
331+
def __init__(self, slider, axis, zoomMargin, dynamicRange):
332+
QtCore.QObject.__init__(self)
333+
self.axis = axis
334+
self.slider = slider
335+
self.realStart = 0
336+
self.realStop = 0
337+
self.numPoints = 10
338+
self.zoomMargin = zoomMargin
339+
self.dynamicRange = dynamicRange
340+
341+
# Transform that maps the spinboxes to a pixel position on the
342+
# axis. 0 to axis.width() exclusive indicate positions which will be
343+
# displayed on the axis.
344+
# Because the axis's width will change when placed within a layout,
345+
# the realToPixelTransform will initially be invalid. It will be set
346+
# properly during the first resizeEvent, with the below transform.
347+
self.realToPixelTransform = -self.axis.width()/2, 1.
348+
self.invalidOldSizeExpected = True
349+
350+
# pixel vals for sliders: 0 to slider_width - 1
351+
def realToPixel(self, val):
352+
a, b = self.realToPixelTransform
353+
rawVal = b*(val - a)
354+
# Clamp pixel values to 32 bits, b/c Qt will otherwise wrap values.
355+
rawVal = min(max(-(1 << 31), rawVal), (1 << 31) - 1)
356+
return rawVal
357+
358+
# Get a point from pixel units to what the sliders display.
359+
def pixelToReal(self, val):
360+
a, b = self.realToPixelTransform
361+
return val/b + a
362+
363+
def rangeToReal(self, val):
364+
pixelVal = self.slider.rangeValueToPixelPos(val)
365+
return self.pixelToReal(pixelVal)
366+
367+
def realToRange(self, val):
368+
pixelVal = self.realToPixel(val)
369+
return self.slider.pixelPosToRangeValue(pixelVal)
370+
371+
def moveStop(self, val):
372+
sliderX = self.realToRange(val)
373+
self.slider.setStopPosition(sliderX)
374+
self.realStop = val
375+
self.axis.update() # Number of points ticks changed positions.
376+
377+
def moveStart(self, val):
378+
sliderX = self.realToRange(val)
379+
self.slider.setStartPosition(sliderX)
380+
self.realStart = val
381+
self.axis.update()
382+
383+
def handleStopMoved(self, rangeVal):
384+
self.sigStopMoved.emit(self.rangeToReal(rangeVal))
385+
386+
def handleStartMoved(self, rangeVal):
387+
self.sigStartMoved.emit(self.rangeToReal(rangeVal))
388+
389+
def handleNumPoints(self, inc):
390+
self.sigNumPoints.emit(self.numPoints + inc)
391+
392+
def setNumPoints(self, val):
393+
self.numPoints = val
394+
self.axis.update()
395+
396+
def handleZoom(self, zoomFactor, mouseXPos):
397+
newScale = self.realToPixelTransform[1] * zoomFactor
398+
refReal = self.pixelToReal(mouseXPos)
399+
newLeft = refReal - mouseXPos/newScale
400+
newZero = newLeft*newScale + self.slider.effectiveWidth()/2
401+
if zoomFactor > 1 and abs(newZero) > self.dynamicRange:
402+
return
403+
self.realToPixelTransform = newLeft, newScale
404+
self.moveStop(self.realStop)
405+
self.moveStart(self.realStart)
406+
407+
def viewRange(self):
408+
newScale = self.slider.effectiveWidth()/abs(
409+
self.realStop - self.realStart)
410+
newScale *= 1 - 2*self.zoomMargin
411+
newCenter = (self.realStop + self.realStart)/2
412+
if newCenter:
413+
newScale = min(newScale, self.dynamicRange/abs(newCenter))
414+
newLeft = newCenter - self.slider.effectiveWidth()/2/newScale
415+
self.realToPixelTransform = newLeft, newScale
416+
self.moveStop(self.realStop)
417+
self.moveStart(self.realStart)
418+
self.axis.update() # Axis normally takes care to update itself during
419+
# zoom. In this code path however, the zoom didn't arrive via the axis
420+
# widget, so we need to notify manually.
421+
422+
# This function is called if the axis width, slider width, and slider
423+
# positions are in an inconsistent state, to initialize the widget.
424+
# This function handles handles the slider positions. Slider and axis
425+
# handle its own width changes; proxy watches for axis width resizeEvent to
426+
# alter mapping from real to pixel space.
427+
def viewRangeInit(self):
428+
currRangeReal = abs(self.realStop - self.realStart)
429+
if currRangeReal == 0:
430+
self.moveStop(self.realStop)
431+
self.moveStart(self.realStart)
432+
# Ill-formed snap range- move the sliders anyway,
433+
# because we arrived here during widget
434+
# initialization, where the slider positions are likely invalid.
435+
# This will force the sliders to have positions on the axis
436+
# which reflect the start/stop values currently set.
437+
else:
438+
self.viewRange()
439+
# Notify spinboxes manually, since slider wasn't clicked and will
440+
# therefore not emit signals.
441+
self.sigStopMoved.emit(self.realStop)
442+
self.sigStartMoved.emit(self.realStart)
443+
444+
def snapRange(self):
445+
lowRange = self.zoomMargin
446+
highRange = 1 - self.zoomMargin
447+
newStart = self.pixelToReal(lowRange * self.slider.effectiveWidth())
448+
newStop = self.pixelToReal(highRange * self.slider.effectiveWidth())
449+
sliderRange = self.slider.maximum() - self.slider.minimum()
450+
# Signals won't fire unless slider was actually grabbed, so
451+
# manually update so the spinboxes know that knew values were set.
452+
# self.realStop/Start and the sliders themselves will be updated as a
453+
# consequence of ValueChanged signal in spinboxes. The slider widget
454+
# has guards against recursive signals in setSpan().
455+
if sliderRange > 0:
456+
self.sigStopMoved.emit(newStop)
457+
self.sigStartMoved.emit(newStart)
458+
459+
def eventFilter(self, obj, ev):
460+
if obj != self.axis:
461+
return False
462+
if ev.type() != QtCore.QEvent.Resize:
463+
return False
464+
if ev.oldSize().isValid():
465+
oldLeft = self.pixelToReal(0)
466+
refWidth = ev.oldSize().width() - self.slider.handleWidth()
467+
refRight = self.pixelToReal(refWidth)
468+
newWidth = ev.size().width() - self.slider.handleWidth()
469+
# assert refRight > oldLeft
470+
newScale = newWidth/(refRight - oldLeft)
471+
self.realToPixelTransform = oldLeft, newScale
472+
else:
473+
# TODO: self.axis.width() is invalid during object
474+
# construction. The width will change when placed in a
475+
# layout WITHOUT a resizeEvent. Why?
476+
oldLeft = -ev.size().width()/2
477+
newScale = 1.0
478+
self.realToPixelTransform = oldLeft, newScale
479+
# We need to reinitialize the pixel transform b/c the old width
480+
# of the axis is no longer valid. When we have a valid transform,
481+
# we can then viewRange based on the desired real values.
482+
# The slider handle values are invalid before this point as well;
483+
# we set them to the correct value here, regardless of whether
484+
# the slider has already resized itsef or not.
485+
self.viewRangeInit()
486+
self.invalidOldSizeExpected = False
487+
# assert self.pixelToReal(0) == oldLeft, \
488+
# "{}, {}".format(self.pixelToReal(0), oldLeft)
489+
# Slider will update independently, making sure that the old
490+
# slider positions are preserved. Because of this, we can be
491+
# confident that the new slider position will still map to the
492+
# same positions in the new axis-space.
493+
return False
494+
495+
496+
class ScanWidget(QtWidgets.QWidget):
497+
sigStartMoved = QtCore.pyqtSignal(float)
498+
sigStopMoved = QtCore.pyqtSignal(float)
499+
sigNumChanged = QtCore.pyqtSignal(int)
500+
501+
def __init__(self, zoomFactor=1.05, zoomMargin=.1, dynamicRange=1e8):
502+
QtWidgets.QWidget.__init__(self)
503+
self.slider = slider = ScanSlider()
504+
self.axis = axis = ScanAxis(zoomFactor)
505+
self.proxy = ScanProxy(slider, axis, zoomMargin, dynamicRange)
506+
axis.proxy = self.proxy
507+
slider.setMaximum(1023)
508+
509+
# Layout.
510+
layout = QtWidgets.QGridLayout()
511+
# Default size will cause axis to disappear otherwise.
512+
layout.setRowMinimumHeight(0, 40)
513+
layout.addWidget(axis, 0, 0, 1, -1)
514+
layout.addWidget(slider, 1, 0, 1, -1)
515+
self.setLayout(layout)
516+
517+
# Connect signals (minus context menu)
518+
slider.sigStopMoved.connect(self.proxy.handleStopMoved)
519+
slider.sigStartMoved.connect(self.proxy.handleStartMoved)
520+
self.proxy.sigStopMoved.connect(self.sigStopMoved)
521+
self.proxy.sigStartMoved.connect(self.sigStartMoved)
522+
self.proxy.sigNumPoints.connect(self.sigNumChanged)
523+
axis.sigZoom.connect(self.proxy.handleZoom)
524+
axis.sigPoints.connect(self.proxy.handleNumPoints)
525+
526+
# Connect event observers.
527+
axis.installEventFilter(self.proxy)
528+
slider.installEventFilter(axis)
529+
530+
# Context menu entries
531+
self.viewRangeAct = QtWidgets.QAction("&View Range", self)
532+
self.snapRangeAct = QtWidgets.QAction("&Snap Range", self)
533+
self.viewRangeAct.triggered.connect(self.viewRange)
534+
self.snapRangeAct.triggered.connect(self.snapRange)
535+
536+
# Spinbox and button slots. Any time the spinboxes change, ScanWidget
537+
# mirrors it and passes the information to the proxy.
538+
def setStop(self, val):
539+
self.proxy.moveStop(val)
540+
541+
def setStart(self, val):
542+
self.proxy.moveStart(val)
543+
544+
def setNumPoints(self, val):
545+
self.proxy.setNumPoints(val)
546+
547+
def viewRange(self):
548+
self.proxy.viewRange()
549+
550+
def snapRange(self):
551+
self.proxy.snapRange()
552+
553+
def contextMenuEvent(self, ev):
554+
menu = QtWidgets.QMenu(self)
555+
menu.addAction(self.viewRangeAct)
556+
menu.addAction(self.snapRangeAct)
557+
menu.exec(ev.globalPos())

Diff for: ‎artiq/gui/scientific_spinbox.py

+71
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import re
2+
from PyQt5 import QtGui, QtWidgets
3+
4+
# after
5+
# http://jdreaver.com/posts/2014-07-28-scientific-notation-spin-box-pyside.html
6+
7+
8+
_inf = float("inf")
9+
# Regular expression to find floats. Match groups are the whole string, the
10+
# whole coefficient, the decimal part of the coefficient, and the exponent
11+
# part.
12+
_float_re = re.compile(r"(([+-]?\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?)")
13+
14+
15+
def valid_float_string(string):
16+
match = _float_re.search(string)
17+
if match:
18+
return match.groups()[0] == string
19+
return False
20+
21+
22+
class FloatValidator(QtGui.QValidator):
23+
def validate(self, string, position):
24+
if valid_float_string(string):
25+
return self.Acceptable, string, position
26+
if string == "" or string[position-1] in "eE.-+":
27+
return self.Intermediate, string, position
28+
return self.Invalid, string, position
29+
30+
def fixup(self, text):
31+
match = _float_re.search(text)
32+
if match:
33+
return match.groups()[0]
34+
return ""
35+
36+
37+
class ScientificSpinBox(QtWidgets.QDoubleSpinBox):
38+
def __init__(self, *args, **kwargs):
39+
super().__init__(*args, **kwargs)
40+
self.setMinimum(-_inf)
41+
self.setMaximum(_inf)
42+
self.validator = FloatValidator()
43+
self.setDecimals(20)
44+
45+
def validate(self, text, position):
46+
return self.validator.validate(text, position)
47+
48+
def fixup(self, text):
49+
return self.validator.fixup(text)
50+
51+
def valueFromText(self, text):
52+
return float(text)
53+
54+
def textFromValue(self, value):
55+
return format_float(value)
56+
57+
def stepBy(self, steps):
58+
text = self.cleanText()
59+
groups = _float_re.search(text).groups()
60+
decimal = float(groups[1])
61+
decimal += steps
62+
new_string = "{:g}".format(decimal) + (groups[3] if groups[3] else "")
63+
self.lineEdit().setText(new_string)
64+
65+
66+
def format_float(value):
67+
"""Modified form of the 'g' format specifier."""
68+
string = "{:g}".format(value)
69+
string = string.replace("e+", "e")
70+
string = re.sub("e(-?)0*(\d+)", r"e\1\2", string)
71+
return string

Diff for: ‎artiq/gui/ticker.py

+136
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# Robert Jordens <rj@m-labs.hk>, 2016
2+
3+
import numpy as np
4+
5+
6+
class Ticker:
7+
# TODO: if this turns out to be computationally expensive, then refactor
8+
# such that the log()s and intermediate values are reused. But
9+
# probably the string formatting itself is the limiting factor here.
10+
def __init__(self, min_ticks=3, precision=3, steps=(5, 2, 1, .5)):
11+
"""
12+
min_ticks: minimum number of ticks to generate
13+
The maximum number of ticks is
14+
max(consecutive ratios in steps)*min_ticks
15+
thus 5/2*min_ticks for default steps.
16+
precision: maximum number of significant digits in labels
17+
Also extract common offset and magnitude from ticks
18+
if dynamic range exceeds precision number of digits
19+
(small range on top of large offset).
20+
steps: tick increments at a given magnitude
21+
The .5 catches rounding errors where the calculation
22+
of step_magnitude falls into the wrong exponent bin.
23+
"""
24+
self.min_ticks = min_ticks
25+
self.precision = precision
26+
self.steps = steps
27+
28+
def step(self, i):
29+
"""
30+
Return recommended step value for interval size `i`.
31+
"""
32+
if not i:
33+
raise ValueError("Need a finite interval")
34+
step = i/self.min_ticks # rational step size for min_ticks
35+
step_magnitude = 10**np.floor(np.log10(step))
36+
# underlying magnitude for steps
37+
for m in self.steps:
38+
good_step = m*step_magnitude
39+
if good_step <= step:
40+
return good_step
41+
42+
def ticks(self, a, b):
43+
"""
44+
Return recommended tick values for interval `[a, b[`.
45+
"""
46+
step = self.step(b - a)
47+
a0 = np.ceil(a/step)*step
48+
ticks = np.arange(a0, b, step)
49+
return ticks
50+
51+
def offset(self, a, step):
52+
"""
53+
Find offset if dynamic range of the interval is large
54+
(small range on large offset).
55+
56+
If offset is finite, show `offset + value`.
57+
"""
58+
if a == 0.:
59+
return 0.
60+
la = np.floor(np.log10(abs(a)))
61+
lr = np.floor(np.log10(step))
62+
if la - lr < self.precision:
63+
return 0.
64+
magnitude = 10**(lr - 1 + self.precision)
65+
offset = np.floor(a/magnitude)*magnitude
66+
return offset
67+
68+
def magnitude(self, a, b, step):
69+
"""
70+
Determine the scaling magnitude.
71+
72+
If magnitude differs from unity, show `magnitude * value`.
73+
This depends on proper offsetting by `offset()`.
74+
"""
75+
v = np.floor(np.log10(max(abs(a), abs(b))))
76+
w = np.floor(np.log10(step))
77+
if v < self.precision and w > -self.precision:
78+
return 1.
79+
return 10**v
80+
81+
def fix_minus(self, s):
82+
return s.replace("-", "−") # unicode minus
83+
84+
def format(self, step):
85+
"""
86+
Determine format string to represent step sufficiently accurate.
87+
"""
88+
dynamic = -int(np.floor(np.log10(step)))
89+
dynamic = min(max(0, dynamic), self.precision)
90+
return "{{:1.{:d}f}}".format(dynamic)
91+
92+
def compact_exponential(self, v):
93+
"""
94+
Format `v` in in compact exponential, stripping redundant elements
95+
(pluses, leading and trailing zeros and decimal point, trailing `e`).
96+
"""
97+
# this is after the matplotlib ScalarFormatter
98+
# without any i18n
99+
significand, exponent = "{:1.10e}".format(v).split("e")
100+
significand = significand.rstrip("0").rstrip(".")
101+
exponent_sign = exponent[0].replace("+", "")
102+
exponent = exponent[1:].lstrip("0")
103+
s = "{:s}e{:s}{:s}".format(significand, exponent_sign,
104+
exponent).rstrip("e")
105+
return self.fix_minus(s)
106+
107+
def prefix(self, offset, magnitude):
108+
"""
109+
Stringify `offset` and `magnitude`.
110+
111+
Expects the string to be shown top/left of the value it refers to.
112+
"""
113+
prefix = ""
114+
if offset != 0.:
115+
prefix += self.compact_exponential(offset) + " + "
116+
if magnitude != 1.:
117+
prefix += self.compact_exponential(magnitude) + " × "
118+
return prefix
119+
120+
def __call__(self, a, b):
121+
"""
122+
Determine ticks, prefix and labels given the interval
123+
`[a, b[`.
124+
125+
Return tick values, prefix string to be show to the left or
126+
above the labels, and tick labels.
127+
"""
128+
ticks = self.ticks(a, b)
129+
offset = self.offset(a, ticks[1] - ticks[0])
130+
t = ticks - offset
131+
magnitude = self.magnitude(t[0], t[-1], t[1] - t[0])
132+
t /= magnitude
133+
prefix = self.prefix(offset, magnitude)
134+
format = self.format(t[1] - t[0])
135+
labels = [self.fix_minus(format.format(t)) for t in t]
136+
return ticks, prefix, labels

0 commit comments

Comments
 (0)
Please sign in to comment.