|
| 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()) |
0 commit comments