def _create_qApp(): """ Only one qApp can exist at a time, so check before creating one. """ global qApp if qApp is None: app = QtWidgets.QApplication.instance() if app is None: # check for DISPLAY env variable on X11 build of Qt if is_pyqt5(): try: from PyQt5 import QtX11Extras is_x11_build = True except ImportError: is_x11_build = False else: is_x11_build = hasattr(QtGui, "QX11Info") if is_x11_build: display = os.environ.get('DISPLAY') if display is None or not re.search(r':\d', display): raise RuntimeError('Invalid DISPLAY variable') qApp = QtWidgets.QApplication([b"matplotlib"]) qApp.lastWindowClosed.connect(qApp.quit) else: qApp = app if is_pyqt5(): try: qApp.setAttribute(QtCore.Qt.AA_UseHighDpiPixmaps) qApp.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling) except AttributeError: pass
def setup_label_actions(self): key_label_mapping = { 'b': Labels.BFILL, 'n': Labels.FFILL, 'd': Labels.DISCARD, 'z': Labels.ZERO, 'j': Labels.GOOD, 'c': Labels.COMMENT, 'w': Labels.LINEAR_FILL, } assert(len(key_label_mapping) == len(LABEL_COLOR_MAP)) # Have to be careful so that the lambda get redefined each loop: # http://martinfitzpatrick.name/article/transmit-extra-data-with-signals-in-pyqt/ for key, label in key_label_mapping.items(): logging.debug('Adding action for key -> label %s:%s', key, label) if is_pyqt5(): # NOTE: The reason why the callback gets an extra arg (False) # in pyqt5 is not clear. Could perhaps be found in docs. setter_callback = lambda _, lb=label: self.set_marking_label(lb) else: setter_callback = lambda lb=label: self.set_marking_label(lb) self.actions['label_'+label] = create_action( '&'+label, parent=self, shortcut='Ctrl+'+key, connect=setter_callback, add_to=self.label_menu, )
def __init__(self, data=None, call_exec=False, loglevel=logging.INFO, interactive=True): """ :param data: Series | Dataframe | [Series] | {str: Series} | None :param call_exec: bool Block by calling app.exec_() and exit on close. Use call_exec=False if running interactively from python prompt. :param loglevel: loglevel the app will log at :param interactive: bool If not run in an interactive prompt, set this to False. Used for configuring inputhook under ipython. """ global QtGui logging.basicConfig( level=loglevel, format="%(asctime)s %(levelname)-8s [%(name)s] : %(message)s" ) logger.debug('Initializing Inspector ...') logger.debug('Using pyqt5: %s', is_pyqt5()) # Make sure that we use any pre-existing QApplication instance if interactive: shell = get_ipython_if_any() if shell: if not shell._inputhook or shell._inputhook.__module__.endswith('.qt'): shell.enable_gui('qt') logger.info("Enabled 'qt' gui in current ipython shell") app = QtWidgets.QApplication.instance() self.app = app or QtWidgets.QApplication(sys.argv) QtGui.qApp = self.app self.model = Model() self.view = View(self.model, data=data, interactive=interactive) if call_exec: sys.exit(self.app.exec_())
def _icon(self, name): if is_pyqt5(): name = name.replace('.png', '_large.png') pm = QtGui.QPixmap(os.path.join(self.basedir, name)) if hasattr(pm, 'setDevicePixelRatio'): pm.setDevicePixelRatio(self.canvas._dpi_ratio) return QtGui.QIcon(pm)
def sizeHint(self): size = super().sizeHint() if is_pyqt5() and self.canvas._dpi_ratio > 1: # For some reason, self.setMinimumHeight doesn't seem to carry over # to the actual sizeHint, so override it instead in order to make # the aesthetic adjustments noted above. size.setHeight(max(48, size.height())) return size
def _icon(self, name, color=None): if is_pyqt5(): name = name.replace('.png', '_large.png') pm = QtGui.QPixmap(os.path.join(self.basedir, name)) if hasattr(pm, 'setDevicePixelRatio'): pm.setDevicePixelRatio(self.canvas._dpi_ratio) if color is not None: mask = pm.createMaskFromColor(QtGui.QColor('black'), QtCore.Qt.MaskOutColor) pm.fill(color) pm.setMask(mask) return QtGui.QIcon(pm)
def _icon(self, name): if qt_compat.is_pyqt5(): name = name.replace('.png', '_large.png') # TODO # ### TO BE MODIFIED ### icon_path = os.path.join(app_info.app_media_path, name) # logger.debug("nav toolbar icon: %s" % icon_path) pm = QtGui.QPixmap(icon_path) # ################ if hasattr(pm, 'setDevicePixelRatio'): pm.setDevicePixelRatio(self.canvas._dpi_ratio) return QtGui.QIcon(pm)
def _init_toolbar(self): # ! Choose icon theme if self.darkMode == True: self.basedir = os.path.join(self.main_dir, 'data', 'resources', 'images_dark', 'matplotlib-dark-images') else: self.basedir = os.path.join(matplotlib.rcParams['datapath'], 'images') for text, tooltip_text, image_file, callback in self.toolitems: if text is None: self.addSeparator() else: a = self.addAction(self._icon(image_file + '.png'), text, getattr(self, callback)) self._actions[callback] = a if callback in ['zoom', 'pan']: a.setCheckable(True) if tooltip_text is not None: a.setToolTip(tooltip_text) if text == 'Subplots': a = self.addAction(self._icon("qt4_editor_options.png"), 'Customize', self.edit_parameters) a.setToolTip('Edit axis, curve and image parameters') # Add the x,y location widget at the right side of the toolbar # The stretch factor is 1 which means any resizing of the toolbar # will resize this label instead of the buttons. if self.coordinates: self.locLabel = QtWidgets.QLabel("", self) self.locLabel.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignTop) self.locLabel.setSizePolicy( QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Ignored)) labelAction = self.addWidget(self.locLabel) labelAction.setVisible(True) # Esthetic adjustments - we need to set these explicitly in PyQt5 # otherwise the layout looks different - but we don't want to set it if # not using HiDPI icons otherwise they look worse than before. if is_pyqt5() and self.canvas._dpi_ratio > 1: self.setIconSize(QtCore.QSize(24, 24)) self.layout().setSpacing(12)
def _init_toolbar(self): self.basedir = resource_filename('VisualPIC.Icons.mpl', '') for text, tooltip_text, image_file, callback in self.toolitems: if text is None: self.addSeparator() else: a = self.addAction(self._icon(image_file + '.svg'), text, getattr(self, callback)) self._actions[callback] = a if callback in ['zoom', 'pan']: a.setCheckable(True) if tooltip_text is not None: a.setToolTip(tooltip_text) if text == 'Subplots': a = self.addAction(self._icon("qt4_editor_options.svg"), 'Customize', self.edit_parameters) a.setToolTip('Edit axis, curve and image parameters') self.buttons = {} # Add the x,y location widget at the right side of the toolbar # The stretch factor is 1 which means any resizing of the toolbar # will resize this label instead of the buttons. if self.coordinates: self.locLabel = QtWidgets.QLabel("", self) self.locLabel.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignTop) self.locLabel.setSizePolicy( QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Ignored)) labelAction = self.addWidget(self.locLabel) labelAction.setVisible(True) # reference holder for subplots_adjust window self.adj_window = None # Esthetic adjustments - we need to set these explicitly in PyQt5 # otherwise the layout looks different - but we don't want to set it if # not using HiDPI icons otherwise they look worse than before. if is_pyqt5(): self.setIconSize(QtCore.QSize(24, 24)) self.layout().setSpacing(12)
Embedding in Qt =============== Simple Qt application embedding Matplotlib canvases. This program will work equally well using Qt4 and Qt5. Either version of Qt can be selected (for example) by setting the ``MPLBACKEND`` environment variable to "Qt4Agg" or "Qt5Agg", or by first importing the desired version of PyQt. """ import sys import time import numpy as np from matplotlib.backends.qt_compat import QtCore, QtWidgets, is_pyqt5 if is_pyqt5(): from matplotlib.backends.backend_qt5agg import (FigureCanvas, NavigationToolbar2QT as NavigationToolbar) else: from matplotlib.backends.backend_qt4agg import (FigureCanvas, NavigationToolbar2QT as NavigationToolbar) from matplotlib.figure import Figure class ApplicationWindow(QtWidgets.QMainWindow): def __init__(self): super().__init__() self._main = QtWidgets.QWidget() self.setCentralWidget(self._main)
import math import PyQt5.QtWidgets as qt import copy from datetime import datetime import matplotlib.backends.qt_compat as qtplt import matplotlib.figure as fig if qtplt.is_pyqt5(): import matplotlib.backends.backend_qt5agg as pyqtplt else: import matplotlib.backends.backend_qt4agg as pyqtplt class TransistorMeasureScreenWidget(qt.QWidget): def __init__(self, measureData, measureSeriesForMeasureData, uceMax, ubeMax, uceTick, uceTickLabel, ubeTick, ubeTickLabel, screenGeometry): super().__init__() self.stopped = False self.axes = None self.lines = [] self.fig = None self.timer = None self.canvas = None
Embedding in Qt =============== Simple Qt application embedding Matplotlib canvases. This program will work equally well using Qt4 and Qt5. Either version of Qt can be selected (for example) by setting the ``MPLBACKEND`` environment variable to "Qt4Agg" or "Qt5Agg", or by first importing the desired version of PyQt. """ import sys import time import numpy as np from matplotlib.backends.qt_compat import QtCore, QtWidgets, is_pyqt5 if is_pyqt5(): from matplotlib.backends.backend_qt5agg import ( FigureCanvas, NavigationToolbar2QT as NavigationToolbar) else: from matplotlib.backends.backend_qt4agg import ( FigureCanvas, NavigationToolbar2QT as NavigationToolbar) from matplotlib.figure import Figure class ApplicationWindow(QtWidgets.QMainWindow): def __init__(self): super(ApplicationWindow, self).__init__() self._main = QtWidgets.QWidget() self.setCentralWidget(self._main) layout = QtWidgets.QVBoxLayout(self._main)
def _icon_extension(self): if is_pyqt5(): return '_large.png' return '.png'
class NavigationToolbar2QT(NavigationToolbar2, QtWidgets.QToolBar): message = QtCore.Signal(str) def __init__(self, canvas, parent, coordinates=True): """ coordinates: should we show the coordinates on the right? """ self.canvas = canvas self.parent = parent self.coordinates = coordinates self._actions = {} """A mapping of toolitem method names to their QActions""" QtWidgets.QToolBar.__init__(self, parent) NavigationToolbar2.__init__(self, canvas) def _icon(self, name): if is_pyqt5(): name = name.replace('.png', '_large.png') pm = QtGui.QPixmap(os.path.join(self.basedir, name)) if hasattr(pm, 'setDevicePixelRatio'): pm.setDevicePixelRatio(self.canvas._dpi_ratio) return QtGui.QIcon(pm) def _init_toolbar(self): self.basedir = os.path.join(matplotlib.rcParams['datapath'], 'images') for text, tooltip_text, image_file, callback in self.toolitems: if text is None: self.addSeparator() else: a = self.addAction(self._icon(image_file + '.png'), text, getattr(self, callback)) self._actions[callback] = a if callback in ['zoom', 'pan']: a.setCheckable(True) if tooltip_text is not None: a.setToolTip(tooltip_text) if text == 'Subplots': a = self.addAction(self._icon("qt4_editor_options.png"), 'Customize', self.edit_parameters) a.setToolTip('Edit axis, curve and image parameters') self.buttons = {} # Add the x,y location widget at the right side of the toolbar # The stretch factor is 1 which means any resizing of the toolbar # will resize this label instead of the buttons. if self.coordinates: self.locLabel = QtWidgets.QLabel("", self) self.locLabel.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignTop) self.locLabel.setSizePolicy( QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Ignored)) labelAction = self.addWidget(self.locLabel) labelAction.setVisible(True) # reference holder for subplots_adjust window self.adj_window = None # Esthetic adjustments - we need to set these explicitly in PyQt5 # otherwise the layout looks different - but we don't want to set it if # not using HiDPI icons otherwise they look worse than before. if is_pyqt5(): self.setIconSize(QtCore.QSize(24, 24)) self.layout().setSpacing(12) if is_pyqt5(): # For some reason, self.setMinimumHeight doesn't seem to carry over to # the actual sizeHint, so override it instead in order to make the # aesthetic adjustments noted above. def sizeHint(self): size = super(NavigationToolbar2QT, self).sizeHint() size.setHeight(max(48, size.height())) return size def edit_parameters(self): allaxes = self.canvas.figure.get_axes() if not allaxes: QtWidgets.QMessageBox.warning(self.parent, "Error", "There are no axes to edit.") return elif len(allaxes) == 1: axes, = allaxes else: titles = [] for axes in allaxes: name = (axes.get_title() or " - ".join( filter(None, [axes.get_xlabel(), axes.get_ylabel()])) or "<anonymous {} (id: {:#x})>".format( type(axes).__name__, id(axes))) titles.append(name) item, ok = QtWidgets.QInputDialog.getItem(self.parent, 'Customize', 'Select axes:', titles, 0, False) if ok: axes = allaxes[titles.index(six.text_type(item))] else: return figureoptions.figure_edit(axes, self) def _update_buttons_checked(self): # sync button checkstates to match active mode self._actions['pan'].setChecked(self._active == 'PAN') self._actions['zoom'].setChecked(self._active == 'ZOOM') def pan(self, *args): super(NavigationToolbar2QT, self).pan(*args) self._update_buttons_checked() def zoom(self, *args): super(NavigationToolbar2QT, self).zoom(*args) self._update_buttons_checked() def set_message(self, s): self.message.emit(s) if self.coordinates: self.locLabel.setText(s) def set_cursor(self, cursor): self.canvas.setCursor(cursord[cursor]) def draw_rubberband(self, event, x0, y0, x1, y1): height = self.canvas.figure.bbox.height y1 = height - y1 y0 = height - y0 rect = [int(val) for val in (x0, y0, x1 - x0, y1 - y0)] self.canvas.drawRectangle(rect) def remove_rubberband(self): self.canvas.drawRectangle(None) def configure_subplots(self): image = os.path.join(matplotlib.rcParams['datapath'], 'images', 'matplotlib.png') dia = SubplotToolQt(self.canvas.figure, self.parent) dia.setWindowIcon(QtGui.QIcon(image)) dia.exec_() def save_figure(self, *args): filetypes = self.canvas.get_supported_filetypes_grouped() sorted_filetypes = sorted(six.iteritems(filetypes)) default_filetype = self.canvas.get_default_filetype() startpath = os.path.expanduser( matplotlib.rcParams['savefig.directory']) start = os.path.join(startpath, self.canvas.get_default_filename()) filters = [] selectedFilter = None for name, exts in sorted_filetypes: exts_list = " ".join(['*.%s' % ext for ext in exts]) filter = '%s (%s)' % (name, exts_list) if default_filetype in exts: selectedFilter = filter filters.append(filter) filters = ';;'.join(filters) fname, filter = _getSaveFileName(self.parent, "Choose a filename to save to", start, filters, selectedFilter) if fname: # Save dir for next time, unless empty str (i.e., use cwd). if startpath != "": matplotlib.rcParams['savefig.directory'] = (os.path.dirname( six.text_type(fname))) try: self.canvas.figure.savefig(six.text_type(fname)) except Exception as e: QtWidgets.QMessageBox.critical(self, "Error saving file", six.text_type(e), QtWidgets.QMessageBox.Ok, QtWidgets.QMessageBox.NoButton)
class FigureCanvasQT(QtWidgets.QWidget, FigureCanvasBase): # map Qt button codes to MouseEvent's ones: buttond = { QtCore.Qt.LeftButton: 1, QtCore.Qt.MidButton: 2, QtCore.Qt.RightButton: 3, # QtCore.Qt.XButton1: None, # QtCore.Qt.XButton2: None, } @_allow_super_init def __init__(self, figure): _create_qApp() super(FigureCanvasQT, self).__init__(figure=figure) self.figure = figure # We don't want to scale up the figure DPI more than once. # Note, we don't handle a signal for changing DPI yet. figure._original_dpi = figure.dpi self._update_figure_dpi() # In cases with mixed resolution displays, we need to be careful if the # dpi_ratio changes - in this case we need to resize the canvas # accordingly. We could watch for screenChanged events from Qt, but # the issue is that we can't guarantee this will be emitted *before* # the first paintEvent for the canvas, so instead we keep track of the # dpi_ratio value here and in paintEvent we resize the canvas if # needed. self._dpi_ratio_prev = None self._draw_pending = False self._is_drawing = False self._draw_rect_callback = lambda painter: None self.setAttribute(QtCore.Qt.WA_OpaquePaintEvent) self.setMouseTracking(True) self.resize(*self.get_width_height()) # Key auto-repeat enabled by default self._keyautorepeat = True palette = QtGui.QPalette(QtCore.Qt.white) self.setPalette(palette) def _update_figure_dpi(self): dpi = self._dpi_ratio * self.figure._original_dpi self.figure._set_dpi(dpi, forward=False) @property def _dpi_ratio(self): # Not available on Qt4 or some older Qt5. try: # self.devicePixelRatio() returns 0 in rare cases return self.devicePixelRatio() or 1 except AttributeError: return 1 def _update_dpi(self): # As described in __init__ above, we need to be careful in cases with # mixed resolution displays if dpi_ratio is changing between painting # events. # Return whether we triggered a resizeEvent (and thus a paintEvent) # from within this function. if self._dpi_ratio != self._dpi_ratio_prev: # We need to update the figure DPI. self._update_figure_dpi() self._dpi_ratio_prev = self._dpi_ratio # The easiest way to resize the canvas is to emit a resizeEvent # since we implement all the logic for resizing the canvas for # that event. event = QtGui.QResizeEvent(self.size(), self.size()) self.resizeEvent(event) # resizeEvent triggers a paintEvent itself, so we exit this one # (after making sure that the event is immediately handled). return True return False def get_width_height(self): w, h = FigureCanvasBase.get_width_height(self) return int(w / self._dpi_ratio), int(h / self._dpi_ratio) def enterEvent(self, event): FigureCanvasBase.enter_notify_event(self, guiEvent=event) def leaveEvent(self, event): QtWidgets.QApplication.restoreOverrideCursor() FigureCanvasBase.leave_notify_event(self, guiEvent=event) def mouseEventCoords(self, pos): """Calculate mouse coordinates in physical pixels Qt5 use logical pixels, but the figure is scaled to physical pixels for rendering. Transform to physical pixels so that all of the down-stream transforms work as expected. Also, the origin is different and needs to be corrected. """ dpi_ratio = self._dpi_ratio x = pos.x() # flip y so y=0 is bottom of canvas y = self.figure.bbox.height / dpi_ratio - pos.y() return x * dpi_ratio, y * dpi_ratio def mousePressEvent(self, event): x, y = self.mouseEventCoords(event.pos()) button = self.buttond.get(event.button()) if button is not None: FigureCanvasBase.button_press_event(self, x, y, button, guiEvent=event) def mouseDoubleClickEvent(self, event): x, y = self.mouseEventCoords(event.pos()) button = self.buttond.get(event.button()) if button is not None: FigureCanvasBase.button_press_event(self, x, y, button, dblclick=True, guiEvent=event) def mouseMoveEvent(self, event): x, y = self.mouseEventCoords(event) FigureCanvasBase.motion_notify_event(self, x, y, guiEvent=event) def mouseReleaseEvent(self, event): x, y = self.mouseEventCoords(event) button = self.buttond.get(event.button()) if button is not None: FigureCanvasBase.button_release_event(self, x, y, button, guiEvent=event) if is_pyqt5(): def wheelEvent(self, event): x, y = self.mouseEventCoords(event) # from QWheelEvent::delta doc if event.pixelDelta().x() == 0 and event.pixelDelta().y() == 0: steps = event.angleDelta().y() / 120 else: steps = event.pixelDelta().y() if steps: FigureCanvasBase.scroll_event(self, x, y, steps, guiEvent=event) else: def wheelEvent(self, event): x = event.x() # flipy so y=0 is bottom of canvas y = self.figure.bbox.height - event.y() # from QWheelEvent::delta doc steps = event.delta() / 120 if event.orientation() == QtCore.Qt.Vertical: FigureCanvasBase.scroll_event(self, x, y, steps, guiEvent=event) def keyPressEvent(self, event): key = self._get_key(event) if key is not None: FigureCanvasBase.key_press_event(self, key, guiEvent=event) def keyReleaseEvent(self, event): key = self._get_key(event) if key is not None: FigureCanvasBase.key_release_event(self, key, guiEvent=event) @property def keyAutoRepeat(self): """ If True, enable auto-repeat for key events. """ return self._keyautorepeat @keyAutoRepeat.setter def keyAutoRepeat(self, val): self._keyautorepeat = bool(val) def resizeEvent(self, event): # _dpi_ratio_prev will be set the first time the canvas is painted, and # the rendered buffer is useless before anyways. if self._dpi_ratio_prev is None: return w = event.size().width() * self._dpi_ratio h = event.size().height() * self._dpi_ratio dpival = self.figure.dpi winch = w / dpival hinch = h / dpival self.figure.set_size_inches(winch, hinch, forward=False) # pass back into Qt to let it finish QtWidgets.QWidget.resizeEvent(self, event) # emit our resize events FigureCanvasBase.resize_event(self) def sizeHint(self): w, h = self.get_width_height() return QtCore.QSize(w, h) def minumumSizeHint(self): return QtCore.QSize(10, 10) def _get_key(self, event): if not self._keyautorepeat and event.isAutoRepeat(): return None event_key = event.key() event_mods = int(event.modifiers()) # actually a bitmask # get names of the pressed modifier keys # bit twiddling to pick out modifier keys from event_mods bitmask, # if event_key is a MODIFIER, it should not be duplicated in mods mods = [ name for name, mod_key, qt_key in MODIFIER_KEYS if event_key != qt_key and (event_mods & mod_key) == mod_key ] try: # for certain keys (enter, left, backspace, etc) use a word for the # key, rather than unicode key = SPECIAL_KEYS[event_key] except KeyError: # unicode defines code points up to 0x0010ffff # QT will use Key_Codes larger than that for keyboard keys that are # are not unicode characters (like multimedia keys) # skip these # if you really want them, you should add them to SPECIAL_KEYS MAX_UNICODE = 0x10ffff if event_key > MAX_UNICODE: return None key = unichr(event_key) # qt delivers capitalized letters. fix capitalization # note that capslock is ignored if 'shift' in mods: mods.remove('shift') else: key = key.lower() mods.reverse() return '+'.join(mods + [key]) def new_timer(self, *args, **kwargs): """ Creates a new backend-specific subclass of :class:`backend_bases.Timer`. This is useful for getting periodic events through the backend's native event loop. Implemented only for backends with GUIs. Other Parameters ---------------- interval : scalar Timer interval in milliseconds callbacks : list Sequence of (func, args, kwargs) where ``func(*args, **kwargs)`` will be executed by the timer every *interval*. """ return TimerQT(*args, **kwargs) def flush_events(self): qApp.processEvents() def start_event_loop(self, timeout=0): if hasattr(self, "_event_loop") and self._event_loop.isRunning(): raise RuntimeError("Event loop already running") self._event_loop = event_loop = QtCore.QEventLoop() if timeout: timer = QtCore.QTimer.singleShot(timeout * 1000, event_loop.quit) event_loop.exec_() def stop_event_loop(self, event=None): if hasattr(self, "_event_loop"): self._event_loop.quit() def draw(self): """Render the figure, and queue a request for a Qt draw. """ # The renderer draw is done here; delaying causes problems with code # that uses the result of the draw() to update plot elements. if self._is_drawing: return self._is_drawing = True try: super(FigureCanvasQT, self).draw() finally: self._is_drawing = False self.update() def draw_idle(self): """Queue redraw of the Agg buffer and request Qt paintEvent. """ # The Agg draw needs to be handled by the same thread matplotlib # modifies the scene graph from. Post Agg draw request to the # current event loop in order to ensure thread affinity and to # accumulate multiple draw requests from event handling. # TODO: queued signal connection might be safer than singleShot if not (self._draw_pending or self._is_drawing): self._draw_pending = True QtCore.QTimer.singleShot(0, self._draw_idle) def _draw_idle(self): if self.height() < 0 or self.width() < 0: self._draw_pending = False if not self._draw_pending: return try: self.draw() except Exception: # Uncaught exceptions are fatal for PyQt5, so catch them instead. traceback.print_exc() finally: self._draw_pending = False def drawRectangle(self, rect): # Draw the zoom rectangle to the QPainter. _draw_rect_callback needs # to be called at the end of paintEvent. if rect is not None: def _draw_rect_callback(painter): pen = QtGui.QPen(QtCore.Qt.black, 1 / self._dpi_ratio, QtCore.Qt.DotLine) painter.setPen(pen) painter.drawRect(*(pt / self._dpi_ratio for pt in rect)) else: def _draw_rect_callback(painter): return self._draw_rect_callback = _draw_rect_callback self.update()