backend_qt5.py 38 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043
  1. import functools
  2. import importlib
  3. import os
  4. import re
  5. import signal
  6. import sys
  7. import traceback
  8. import matplotlib
  9. from matplotlib import backend_tools, cbook
  10. from matplotlib._pylab_helpers import Gcf
  11. from matplotlib.backend_bases import (
  12. _Backend, FigureCanvasBase, FigureManagerBase, NavigationToolbar2,
  13. TimerBase, cursors, ToolContainerBase, StatusbarBase, MouseButton)
  14. import matplotlib.backends.qt_editor.figureoptions as figureoptions
  15. from matplotlib.backends.qt_editor._formsubplottool import UiSubplotTool
  16. from . import qt_compat
  17. from .qt_compat import (
  18. QtCore, QtGui, QtWidgets, __version__, QT_API,
  19. _devicePixelRatioF, _isdeleted, _setDevicePixelRatioF,
  20. )
  21. backend_version = __version__
  22. # SPECIAL_KEYS are keys that do *not* return their unicode name
  23. # instead they have manually specified names
  24. SPECIAL_KEYS = {QtCore.Qt.Key_Control: 'control',
  25. QtCore.Qt.Key_Shift: 'shift',
  26. QtCore.Qt.Key_Alt: 'alt',
  27. QtCore.Qt.Key_Meta: 'super',
  28. QtCore.Qt.Key_Return: 'enter',
  29. QtCore.Qt.Key_Left: 'left',
  30. QtCore.Qt.Key_Up: 'up',
  31. QtCore.Qt.Key_Right: 'right',
  32. QtCore.Qt.Key_Down: 'down',
  33. QtCore.Qt.Key_Escape: 'escape',
  34. QtCore.Qt.Key_F1: 'f1',
  35. QtCore.Qt.Key_F2: 'f2',
  36. QtCore.Qt.Key_F3: 'f3',
  37. QtCore.Qt.Key_F4: 'f4',
  38. QtCore.Qt.Key_F5: 'f5',
  39. QtCore.Qt.Key_F6: 'f6',
  40. QtCore.Qt.Key_F7: 'f7',
  41. QtCore.Qt.Key_F8: 'f8',
  42. QtCore.Qt.Key_F9: 'f9',
  43. QtCore.Qt.Key_F10: 'f10',
  44. QtCore.Qt.Key_F11: 'f11',
  45. QtCore.Qt.Key_F12: 'f12',
  46. QtCore.Qt.Key_Home: 'home',
  47. QtCore.Qt.Key_End: 'end',
  48. QtCore.Qt.Key_PageUp: 'pageup',
  49. QtCore.Qt.Key_PageDown: 'pagedown',
  50. QtCore.Qt.Key_Tab: 'tab',
  51. QtCore.Qt.Key_Backspace: 'backspace',
  52. QtCore.Qt.Key_Enter: 'enter',
  53. QtCore.Qt.Key_Insert: 'insert',
  54. QtCore.Qt.Key_Delete: 'delete',
  55. QtCore.Qt.Key_Pause: 'pause',
  56. QtCore.Qt.Key_SysReq: 'sysreq',
  57. QtCore.Qt.Key_Clear: 'clear', }
  58. if sys.platform == 'darwin':
  59. # in OSX, the control and super (aka cmd/apple) keys are switched, so
  60. # switch them back.
  61. SPECIAL_KEYS.update({QtCore.Qt.Key_Control: 'cmd', # cmd/apple key
  62. QtCore.Qt.Key_Meta: 'control',
  63. })
  64. # Define which modifier keys are collected on keyboard events.
  65. # Elements are (Modifier Flag, Qt Key) tuples.
  66. # Order determines the modifier order (ctrl+alt+...) reported by Matplotlib.
  67. _MODIFIER_KEYS = [
  68. (QtCore.Qt.ShiftModifier, QtCore.Qt.Key_Shift),
  69. (QtCore.Qt.ControlModifier, QtCore.Qt.Key_Control),
  70. (QtCore.Qt.AltModifier, QtCore.Qt.Key_Alt),
  71. (QtCore.Qt.MetaModifier, QtCore.Qt.Key_Meta),
  72. ]
  73. cursord = {
  74. cursors.MOVE: QtCore.Qt.SizeAllCursor,
  75. cursors.HAND: QtCore.Qt.PointingHandCursor,
  76. cursors.POINTER: QtCore.Qt.ArrowCursor,
  77. cursors.SELECT_REGION: QtCore.Qt.CrossCursor,
  78. cursors.WAIT: QtCore.Qt.WaitCursor,
  79. }
  80. SUPER = 0 # Deprecated.
  81. ALT = 1 # Deprecated.
  82. CTRL = 2 # Deprecated.
  83. SHIFT = 3 # Deprecated.
  84. MODIFIER_KEYS = [ # Deprecated.
  85. (SPECIAL_KEYS[key], mod, key) for mod, key in _MODIFIER_KEYS]
  86. # make place holder
  87. qApp = None
  88. def _create_qApp():
  89. """
  90. Only one qApp can exist at a time, so check before creating one.
  91. """
  92. global qApp
  93. if qApp is None:
  94. app = QtWidgets.QApplication.instance()
  95. if app is None:
  96. # check for DISPLAY env variable on X11 build of Qt
  97. if QtCore.qVersion() >= "5.":
  98. try:
  99. importlib.import_module(
  100. # i.e. PyQt5.QtX11Extras or PySide2.QtX11Extras.
  101. f"{QtWidgets.__package__}.QtX11Extras")
  102. is_x11_build = True
  103. except ImportError:
  104. is_x11_build = False
  105. else:
  106. is_x11_build = hasattr(QtGui, "QX11Info")
  107. if is_x11_build:
  108. display = os.environ.get('DISPLAY')
  109. if display is None or not re.search(r':\d', display):
  110. raise RuntimeError('Invalid DISPLAY variable')
  111. try:
  112. QtWidgets.QApplication.setAttribute(
  113. QtCore.Qt.AA_EnableHighDpiScaling)
  114. except AttributeError: # Attribute only exists for Qt>=5.6.
  115. pass
  116. qApp = QtWidgets.QApplication(["matplotlib"])
  117. qApp.lastWindowClosed.connect(qApp.quit)
  118. else:
  119. qApp = app
  120. try:
  121. qApp.setAttribute(QtCore.Qt.AA_UseHighDpiPixmaps)
  122. except AttributeError:
  123. pass
  124. def _allow_super_init(__init__):
  125. """
  126. Decorator for ``__init__`` to allow ``super().__init__`` on PyQt4/PySide2.
  127. """
  128. if QT_API == "PyQt5":
  129. return __init__
  130. else:
  131. # To work around lack of cooperative inheritance in PyQt4, PySide,
  132. # and PySide2, when calling FigureCanvasQT.__init__, we temporarily
  133. # patch QWidget.__init__ by a cooperative version, that first calls
  134. # QWidget.__init__ with no additional arguments, and then finds the
  135. # next class in the MRO with an __init__ that does support cooperative
  136. # inheritance (i.e., not defined by the PyQt4, PySide, PySide2, sip
  137. # or Shiboken packages), and manually call its `__init__`, once again
  138. # passing the additional arguments.
  139. qwidget_init = QtWidgets.QWidget.__init__
  140. def cooperative_qwidget_init(self, *args, **kwargs):
  141. qwidget_init(self)
  142. mro = type(self).__mro__
  143. next_coop_init = next(
  144. cls for cls in mro[mro.index(QtWidgets.QWidget) + 1:]
  145. if cls.__module__.split(".")[0] not in [
  146. "PyQt4", "sip", "PySide", "PySide2", "Shiboken"])
  147. next_coop_init.__init__(self, *args, **kwargs)
  148. @functools.wraps(__init__)
  149. def wrapper(self, *args, **kwargs):
  150. with cbook._setattr_cm(QtWidgets.QWidget,
  151. __init__=cooperative_qwidget_init):
  152. __init__(self, *args, **kwargs)
  153. return wrapper
  154. class TimerQT(TimerBase):
  155. """Subclass of `.TimerBase` using QTimer events."""
  156. def __init__(self, *args, **kwargs):
  157. # Create a new timer and connect the timeout() signal to the
  158. # _on_timer method.
  159. self._timer = QtCore.QTimer()
  160. self._timer.timeout.connect(self._on_timer)
  161. TimerBase.__init__(self, *args, **kwargs)
  162. def __del__(self):
  163. # The check for deletedness is needed to avoid an error at animation
  164. # shutdown with PySide2.
  165. if not _isdeleted(self._timer):
  166. self._timer_stop()
  167. def _timer_set_single_shot(self):
  168. self._timer.setSingleShot(self._single)
  169. def _timer_set_interval(self):
  170. self._timer.setInterval(self._interval)
  171. def _timer_start(self):
  172. self._timer.start()
  173. def _timer_stop(self):
  174. self._timer.stop()
  175. class FigureCanvasQT(QtWidgets.QWidget, FigureCanvasBase):
  176. required_interactive_framework = "qt5"
  177. _timer_cls = TimerQT
  178. # map Qt button codes to MouseEvent's ones:
  179. buttond = {QtCore.Qt.LeftButton: MouseButton.LEFT,
  180. QtCore.Qt.MidButton: MouseButton.MIDDLE,
  181. QtCore.Qt.RightButton: MouseButton.RIGHT,
  182. QtCore.Qt.XButton1: MouseButton.BACK,
  183. QtCore.Qt.XButton2: MouseButton.FORWARD,
  184. }
  185. @_allow_super_init
  186. def __init__(self, figure):
  187. _create_qApp()
  188. super().__init__(figure=figure)
  189. # We don't want to scale up the figure DPI more than once.
  190. # Note, we don't handle a signal for changing DPI yet.
  191. figure._original_dpi = figure.dpi
  192. self._update_figure_dpi()
  193. # In cases with mixed resolution displays, we need to be careful if the
  194. # dpi_ratio changes - in this case we need to resize the canvas
  195. # accordingly. We could watch for screenChanged events from Qt, but
  196. # the issue is that we can't guarantee this will be emitted *before*
  197. # the first paintEvent for the canvas, so instead we keep track of the
  198. # dpi_ratio value here and in paintEvent we resize the canvas if
  199. # needed.
  200. self._dpi_ratio_prev = None
  201. self._draw_pending = False
  202. self._is_drawing = False
  203. self._draw_rect_callback = lambda painter: None
  204. self.setAttribute(QtCore.Qt.WA_OpaquePaintEvent)
  205. self.setMouseTracking(True)
  206. self.resize(*self.get_width_height())
  207. palette = QtGui.QPalette(QtCore.Qt.white)
  208. self.setPalette(palette)
  209. def _update_figure_dpi(self):
  210. dpi = self._dpi_ratio * self.figure._original_dpi
  211. self.figure._set_dpi(dpi, forward=False)
  212. @property
  213. def _dpi_ratio(self):
  214. return _devicePixelRatioF(self)
  215. def _update_dpi(self):
  216. # As described in __init__ above, we need to be careful in cases with
  217. # mixed resolution displays if dpi_ratio is changing between painting
  218. # events.
  219. # Return whether we triggered a resizeEvent (and thus a paintEvent)
  220. # from within this function.
  221. if self._dpi_ratio != self._dpi_ratio_prev:
  222. # We need to update the figure DPI.
  223. self._update_figure_dpi()
  224. self._dpi_ratio_prev = self._dpi_ratio
  225. # The easiest way to resize the canvas is to emit a resizeEvent
  226. # since we implement all the logic for resizing the canvas for
  227. # that event.
  228. event = QtGui.QResizeEvent(self.size(), self.size())
  229. self.resizeEvent(event)
  230. # resizeEvent triggers a paintEvent itself, so we exit this one
  231. # (after making sure that the event is immediately handled).
  232. return True
  233. return False
  234. def get_width_height(self):
  235. w, h = FigureCanvasBase.get_width_height(self)
  236. return int(w / self._dpi_ratio), int(h / self._dpi_ratio)
  237. def enterEvent(self, event):
  238. try:
  239. x, y = self.mouseEventCoords(event.pos())
  240. except AttributeError:
  241. # the event from PyQt4 does not include the position
  242. x = y = None
  243. FigureCanvasBase.enter_notify_event(self, guiEvent=event, xy=(x, y))
  244. def leaveEvent(self, event):
  245. QtWidgets.QApplication.restoreOverrideCursor()
  246. FigureCanvasBase.leave_notify_event(self, guiEvent=event)
  247. def mouseEventCoords(self, pos):
  248. """
  249. Calculate mouse coordinates in physical pixels.
  250. Qt5 use logical pixels, but the figure is scaled to physical
  251. pixels for rendering. Transform to physical pixels so that
  252. all of the down-stream transforms work as expected.
  253. Also, the origin is different and needs to be corrected.
  254. """
  255. dpi_ratio = self._dpi_ratio
  256. x = pos.x()
  257. # flip y so y=0 is bottom of canvas
  258. y = self.figure.bbox.height / dpi_ratio - pos.y()
  259. return x * dpi_ratio, y * dpi_ratio
  260. def mousePressEvent(self, event):
  261. x, y = self.mouseEventCoords(event.pos())
  262. button = self.buttond.get(event.button())
  263. if button is not None:
  264. FigureCanvasBase.button_press_event(self, x, y, button,
  265. guiEvent=event)
  266. def mouseDoubleClickEvent(self, event):
  267. x, y = self.mouseEventCoords(event.pos())
  268. button = self.buttond.get(event.button())
  269. if button is not None:
  270. FigureCanvasBase.button_press_event(self, x, y,
  271. button, dblclick=True,
  272. guiEvent=event)
  273. def mouseMoveEvent(self, event):
  274. x, y = self.mouseEventCoords(event)
  275. FigureCanvasBase.motion_notify_event(self, x, y, guiEvent=event)
  276. def mouseReleaseEvent(self, event):
  277. x, y = self.mouseEventCoords(event)
  278. button = self.buttond.get(event.button())
  279. if button is not None:
  280. FigureCanvasBase.button_release_event(self, x, y, button,
  281. guiEvent=event)
  282. if QtCore.qVersion() >= "5.":
  283. def wheelEvent(self, event):
  284. x, y = self.mouseEventCoords(event)
  285. # from QWheelEvent::delta doc
  286. if event.pixelDelta().x() == 0 and event.pixelDelta().y() == 0:
  287. steps = event.angleDelta().y() / 120
  288. else:
  289. steps = event.pixelDelta().y()
  290. if steps:
  291. FigureCanvasBase.scroll_event(
  292. self, x, y, steps, guiEvent=event)
  293. else:
  294. def wheelEvent(self, event):
  295. x = event.x()
  296. # flipy so y=0 is bottom of canvas
  297. y = self.figure.bbox.height - event.y()
  298. # from QWheelEvent::delta doc
  299. steps = event.delta() / 120
  300. if event.orientation() == QtCore.Qt.Vertical:
  301. FigureCanvasBase.scroll_event(
  302. self, x, y, steps, guiEvent=event)
  303. def keyPressEvent(self, event):
  304. key = self._get_key(event)
  305. if key is not None:
  306. FigureCanvasBase.key_press_event(self, key, guiEvent=event)
  307. def keyReleaseEvent(self, event):
  308. key = self._get_key(event)
  309. if key is not None:
  310. FigureCanvasBase.key_release_event(self, key, guiEvent=event)
  311. def resizeEvent(self, event):
  312. # _dpi_ratio_prev will be set the first time the canvas is painted, and
  313. # the rendered buffer is useless before anyways.
  314. if self._dpi_ratio_prev is None:
  315. return
  316. w = event.size().width() * self._dpi_ratio
  317. h = event.size().height() * self._dpi_ratio
  318. dpival = self.figure.dpi
  319. winch = w / dpival
  320. hinch = h / dpival
  321. self.figure.set_size_inches(winch, hinch, forward=False)
  322. # pass back into Qt to let it finish
  323. QtWidgets.QWidget.resizeEvent(self, event)
  324. # emit our resize events
  325. FigureCanvasBase.resize_event(self)
  326. def sizeHint(self):
  327. w, h = self.get_width_height()
  328. return QtCore.QSize(w, h)
  329. def minumumSizeHint(self):
  330. return QtCore.QSize(10, 10)
  331. def _get_key(self, event):
  332. event_key = event.key()
  333. event_mods = int(event.modifiers()) # actually a bitmask
  334. # get names of the pressed modifier keys
  335. # 'control' is named 'control' when a standalone key, but 'ctrl' when a
  336. # modifier
  337. # bit twiddling to pick out modifier keys from event_mods bitmask,
  338. # if event_key is a MODIFIER, it should not be duplicated in mods
  339. mods = [SPECIAL_KEYS[key].replace('control', 'ctrl')
  340. for mod, key in _MODIFIER_KEYS
  341. if event_key != key and event_mods & mod]
  342. try:
  343. # for certain keys (enter, left, backspace, etc) use a word for the
  344. # key, rather than unicode
  345. key = SPECIAL_KEYS[event_key]
  346. except KeyError:
  347. # unicode defines code points up to 0x10ffff (sys.maxunicode)
  348. # QT will use Key_Codes larger than that for keyboard keys that are
  349. # are not unicode characters (like multimedia keys)
  350. # skip these
  351. # if you really want them, you should add them to SPECIAL_KEYS
  352. if event_key > sys.maxunicode:
  353. return None
  354. key = chr(event_key)
  355. # qt delivers capitalized letters. fix capitalization
  356. # note that capslock is ignored
  357. if 'shift' in mods:
  358. mods.remove('shift')
  359. else:
  360. key = key.lower()
  361. return '+'.join(mods + [key])
  362. def flush_events(self):
  363. # docstring inherited
  364. qApp.processEvents()
  365. def start_event_loop(self, timeout=0):
  366. # docstring inherited
  367. if hasattr(self, "_event_loop") and self._event_loop.isRunning():
  368. raise RuntimeError("Event loop already running")
  369. self._event_loop = event_loop = QtCore.QEventLoop()
  370. if timeout > 0:
  371. timer = QtCore.QTimer.singleShot(int(timeout * 1000),
  372. event_loop.quit)
  373. event_loop.exec_()
  374. def stop_event_loop(self, event=None):
  375. # docstring inherited
  376. if hasattr(self, "_event_loop"):
  377. self._event_loop.quit()
  378. def draw(self):
  379. """Render the figure, and queue a request for a Qt draw."""
  380. # The renderer draw is done here; delaying causes problems with code
  381. # that uses the result of the draw() to update plot elements.
  382. if self._is_drawing:
  383. return
  384. with cbook._setattr_cm(self, _is_drawing=True):
  385. super().draw()
  386. self.update()
  387. def draw_idle(self):
  388. """Queue redraw of the Agg buffer and request Qt paintEvent."""
  389. # The Agg draw needs to be handled by the same thread Matplotlib
  390. # modifies the scene graph from. Post Agg draw request to the
  391. # current event loop in order to ensure thread affinity and to
  392. # accumulate multiple draw requests from event handling.
  393. # TODO: queued signal connection might be safer than singleShot
  394. if not (getattr(self, '_draw_pending', False) or
  395. getattr(self, '_is_drawing', False)):
  396. self._draw_pending = True
  397. QtCore.QTimer.singleShot(0, self._draw_idle)
  398. def blit(self, bbox=None):
  399. # docstring inherited
  400. if bbox is None and self.figure:
  401. bbox = self.figure.bbox # Blit the entire canvas if bbox is None.
  402. # repaint uses logical pixels, not physical pixels like the renderer.
  403. l, b, w, h = [int(pt / self._dpi_ratio) for pt in bbox.bounds]
  404. t = b + h
  405. self.repaint(l, self.rect().height() - t, w, h)
  406. def _draw_idle(self):
  407. with self._idle_draw_cntx():
  408. if not self._draw_pending:
  409. return
  410. self._draw_pending = False
  411. if self.height() < 0 or self.width() < 0:
  412. return
  413. try:
  414. self.draw()
  415. except Exception:
  416. # Uncaught exceptions are fatal for PyQt5, so catch them.
  417. traceback.print_exc()
  418. def drawRectangle(self, rect):
  419. # Draw the zoom rectangle to the QPainter. _draw_rect_callback needs
  420. # to be called at the end of paintEvent.
  421. if rect is not None:
  422. x0, y0, w, h = [int(pt / self._dpi_ratio) for pt in rect]
  423. x1 = x0 + w
  424. y1 = y0 + h
  425. def _draw_rect_callback(painter):
  426. pen = QtGui.QPen(QtCore.Qt.black, 1 / self._dpi_ratio)
  427. pen.setDashPattern([3, 3])
  428. for color, offset in [
  429. (QtCore.Qt.black, 0), (QtCore.Qt.white, 3)]:
  430. pen.setDashOffset(offset)
  431. pen.setColor(color)
  432. painter.setPen(pen)
  433. # Draw the lines from x0, y0 towards x1, y1 so that the
  434. # dashes don't "jump" when moving the zoom box.
  435. painter.drawLine(x0, y0, x0, y1)
  436. painter.drawLine(x0, y0, x1, y0)
  437. painter.drawLine(x0, y1, x1, y1)
  438. painter.drawLine(x1, y0, x1, y1)
  439. else:
  440. def _draw_rect_callback(painter):
  441. return
  442. self._draw_rect_callback = _draw_rect_callback
  443. self.update()
  444. class MainWindow(QtWidgets.QMainWindow):
  445. closing = QtCore.Signal()
  446. def closeEvent(self, event):
  447. self.closing.emit()
  448. QtWidgets.QMainWindow.closeEvent(self, event)
  449. class FigureManagerQT(FigureManagerBase):
  450. """
  451. Attributes
  452. ----------
  453. canvas : `FigureCanvas`
  454. The FigureCanvas instance
  455. num : int or str
  456. The Figure number
  457. toolbar : qt.QToolBar
  458. The qt.QToolBar
  459. window : qt.QMainWindow
  460. The qt.QMainWindow
  461. """
  462. def __init__(self, canvas, num):
  463. FigureManagerBase.__init__(self, canvas, num)
  464. self.window = MainWindow()
  465. self.window.closing.connect(canvas.close_event)
  466. self.window.closing.connect(self._widgetclosed)
  467. self.window.setWindowTitle("Figure %d" % num)
  468. image = str(cbook._get_data_path('images/matplotlib.svg'))
  469. self.window.setWindowIcon(QtGui.QIcon(image))
  470. # Give the keyboard focus to the figure instead of the manager:
  471. # StrongFocus accepts both tab and click to focus and will enable the
  472. # canvas to process event without clicking.
  473. # https://doc.qt.io/qt-5/qt.html#FocusPolicy-enum
  474. self.canvas.setFocusPolicy(QtCore.Qt.StrongFocus)
  475. self.canvas.setFocus()
  476. self.window._destroying = False
  477. self.toolbar = self._get_toolbar(self.canvas, self.window)
  478. if self.toolmanager:
  479. backend_tools.add_tools_to_manager(self.toolmanager)
  480. if self.toolbar:
  481. backend_tools.add_tools_to_container(self.toolbar)
  482. if self.toolbar:
  483. self.window.addToolBar(self.toolbar)
  484. tbs_height = self.toolbar.sizeHint().height()
  485. else:
  486. tbs_height = 0
  487. # resize the main window so it will display the canvas with the
  488. # requested size:
  489. cs = canvas.sizeHint()
  490. cs_height = cs.height()
  491. height = cs_height + tbs_height
  492. self.window.resize(cs.width(), height)
  493. self.window.setCentralWidget(self.canvas)
  494. if matplotlib.is_interactive():
  495. self.window.show()
  496. self.canvas.draw_idle()
  497. self.window.raise_()
  498. def full_screen_toggle(self):
  499. if self.window.isFullScreen():
  500. self.window.showNormal()
  501. else:
  502. self.window.showFullScreen()
  503. def _widgetclosed(self):
  504. if self.window._destroying:
  505. return
  506. self.window._destroying = True
  507. try:
  508. Gcf.destroy(self)
  509. except AttributeError:
  510. pass
  511. # It seems that when the python session is killed,
  512. # Gcf can get destroyed before the Gcf.destroy
  513. # line is run, leading to a useless AttributeError.
  514. def _get_toolbar(self, canvas, parent):
  515. # must be inited after the window, drawingArea and figure
  516. # attrs are set
  517. if matplotlib.rcParams['toolbar'] == 'toolbar2':
  518. toolbar = NavigationToolbar2QT(canvas, parent, True)
  519. elif matplotlib.rcParams['toolbar'] == 'toolmanager':
  520. toolbar = ToolbarQt(self.toolmanager, self.window)
  521. else:
  522. toolbar = None
  523. return toolbar
  524. def resize(self, width, height):
  525. # these are Qt methods so they return sizes in 'virtual' pixels
  526. # so we do not need to worry about dpi scaling here.
  527. extra_width = self.window.width() - self.canvas.width()
  528. extra_height = self.window.height() - self.canvas.height()
  529. self.canvas.resize(width, height)
  530. self.window.resize(width + extra_width, height + extra_height)
  531. def show(self):
  532. self.window.show()
  533. if matplotlib.rcParams['figure.raise_window']:
  534. self.window.activateWindow()
  535. self.window.raise_()
  536. def destroy(self, *args):
  537. # check for qApp first, as PySide deletes it in its atexit handler
  538. if QtWidgets.QApplication.instance() is None:
  539. return
  540. if self.window._destroying:
  541. return
  542. self.window._destroying = True
  543. if self.toolbar:
  544. self.toolbar.destroy()
  545. self.window.close()
  546. def get_window_title(self):
  547. return self.window.windowTitle()
  548. def set_window_title(self, title):
  549. self.window.setWindowTitle(title)
  550. class NavigationToolbar2QT(NavigationToolbar2, QtWidgets.QToolBar):
  551. message = QtCore.Signal(str)
  552. toolitems = [*NavigationToolbar2.toolitems]
  553. toolitems.insert(
  554. # Add 'customize' action after 'subplots'
  555. [name for name, *_ in toolitems].index("Subplots") + 1,
  556. ("Customize", "Edit axis, curve and image parameters",
  557. "qt4_editor_options", "edit_parameters"))
  558. def __init__(self, canvas, parent, coordinates=True):
  559. """coordinates: should we show the coordinates on the right?"""
  560. QtWidgets.QToolBar.__init__(self, parent)
  561. self.setAllowedAreas(
  562. QtCore.Qt.TopToolBarArea | QtCore.Qt.BottomToolBarArea)
  563. self.coordinates = coordinates
  564. self._actions = {} # mapping of toolitem method names to QActions.
  565. for text, tooltip_text, image_file, callback in self.toolitems:
  566. if text is None:
  567. self.addSeparator()
  568. else:
  569. a = self.addAction(self._icon(image_file + '.png'),
  570. text, getattr(self, callback))
  571. self._actions[callback] = a
  572. if callback in ['zoom', 'pan']:
  573. a.setCheckable(True)
  574. if tooltip_text is not None:
  575. a.setToolTip(tooltip_text)
  576. # Add the (x, y) location widget at the right side of the toolbar
  577. # The stretch factor is 1 which means any resizing of the toolbar
  578. # will resize this label instead of the buttons.
  579. if self.coordinates:
  580. self.locLabel = QtWidgets.QLabel("", self)
  581. self.locLabel.setAlignment(
  582. QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)
  583. self.locLabel.setSizePolicy(
  584. QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding,
  585. QtWidgets.QSizePolicy.Ignored))
  586. labelAction = self.addWidget(self.locLabel)
  587. labelAction.setVisible(True)
  588. NavigationToolbar2.__init__(self, canvas)
  589. @cbook.deprecated("3.3", alternative="self.canvas.parent()")
  590. @property
  591. def parent(self):
  592. return self.canvas.parent()
  593. @cbook.deprecated("3.3", alternative="self.canvas.setParent()")
  594. @parent.setter
  595. def parent(self, value):
  596. pass
  597. @cbook.deprecated(
  598. "3.3", alternative="os.path.join(mpl.get_data_path(), 'images')")
  599. @property
  600. def basedir(self):
  601. return str(cbook._get_data_path('images'))
  602. def _icon(self, name):
  603. """
  604. Construct a `.QIcon` from an image file *name*, including the extension
  605. and relative to Matplotlib's "images" data directory.
  606. """
  607. if QtCore.qVersion() >= '5.':
  608. name = name.replace('.png', '_large.png')
  609. pm = QtGui.QPixmap(str(cbook._get_data_path('images', name)))
  610. _setDevicePixelRatioF(pm, _devicePixelRatioF(self))
  611. if self.palette().color(self.backgroundRole()).value() < 128:
  612. icon_color = self.palette().color(self.foregroundRole())
  613. mask = pm.createMaskFromColor(QtGui.QColor('black'),
  614. QtCore.Qt.MaskOutColor)
  615. pm.fill(icon_color)
  616. pm.setMask(mask)
  617. return QtGui.QIcon(pm)
  618. def edit_parameters(self):
  619. axes = self.canvas.figure.get_axes()
  620. if not axes:
  621. QtWidgets.QMessageBox.warning(
  622. self.canvas.parent(), "Error", "There are no axes to edit.")
  623. return
  624. elif len(axes) == 1:
  625. ax, = axes
  626. else:
  627. titles = [
  628. ax.get_label() or
  629. ax.get_title() or
  630. " - ".join(filter(None, [ax.get_xlabel(), ax.get_ylabel()])) or
  631. f"<anonymous {type(ax).__name__}>"
  632. for ax in axes]
  633. duplicate_titles = [
  634. title for title in titles if titles.count(title) > 1]
  635. for i, ax in enumerate(axes):
  636. if titles[i] in duplicate_titles:
  637. titles[i] += f" (id: {id(ax):#x})" # Deduplicate titles.
  638. item, ok = QtWidgets.QInputDialog.getItem(
  639. self.canvas.parent(),
  640. 'Customize', 'Select axes:', titles, 0, False)
  641. if not ok:
  642. return
  643. ax = axes[titles.index(item)]
  644. figureoptions.figure_edit(ax, self)
  645. def _update_buttons_checked(self):
  646. # sync button checkstates to match active mode
  647. if 'pan' in self._actions:
  648. self._actions['pan'].setChecked(self.mode.name == 'PAN')
  649. if 'zoom' in self._actions:
  650. self._actions['zoom'].setChecked(self.mode.name == 'ZOOM')
  651. def pan(self, *args):
  652. super().pan(*args)
  653. self._update_buttons_checked()
  654. def zoom(self, *args):
  655. super().zoom(*args)
  656. self._update_buttons_checked()
  657. def set_message(self, s):
  658. self.message.emit(s)
  659. if self.coordinates:
  660. self.locLabel.setText(s)
  661. def set_cursor(self, cursor):
  662. self.canvas.setCursor(cursord[cursor])
  663. def draw_rubberband(self, event, x0, y0, x1, y1):
  664. height = self.canvas.figure.bbox.height
  665. y1 = height - y1
  666. y0 = height - y0
  667. rect = [int(val) for val in (x0, y0, x1 - x0, y1 - y0)]
  668. self.canvas.drawRectangle(rect)
  669. def remove_rubberband(self):
  670. self.canvas.drawRectangle(None)
  671. def configure_subplots(self):
  672. image = str(cbook._get_data_path('images/matplotlib.png'))
  673. dia = SubplotToolQt(self.canvas.figure, self.canvas.parent())
  674. dia.setWindowIcon(QtGui.QIcon(image))
  675. dia.exec_()
  676. def save_figure(self, *args):
  677. filetypes = self.canvas.get_supported_filetypes_grouped()
  678. sorted_filetypes = sorted(filetypes.items())
  679. default_filetype = self.canvas.get_default_filetype()
  680. startpath = os.path.expanduser(
  681. matplotlib.rcParams['savefig.directory'])
  682. start = os.path.join(startpath, self.canvas.get_default_filename())
  683. filters = []
  684. selectedFilter = None
  685. for name, exts in sorted_filetypes:
  686. exts_list = " ".join(['*.%s' % ext for ext in exts])
  687. filter = '%s (%s)' % (name, exts_list)
  688. if default_filetype in exts:
  689. selectedFilter = filter
  690. filters.append(filter)
  691. filters = ';;'.join(filters)
  692. fname, filter = qt_compat._getSaveFileName(
  693. self.canvas.parent(), "Choose a filename to save to", start,
  694. filters, selectedFilter)
  695. if fname:
  696. # Save dir for next time, unless empty str (i.e., use cwd).
  697. if startpath != "":
  698. matplotlib.rcParams['savefig.directory'] = (
  699. os.path.dirname(fname))
  700. try:
  701. self.canvas.figure.savefig(fname)
  702. except Exception as e:
  703. QtWidgets.QMessageBox.critical(
  704. self, "Error saving file", str(e),
  705. QtWidgets.QMessageBox.Ok, QtWidgets.QMessageBox.NoButton)
  706. def set_history_buttons(self):
  707. can_backward = self._nav_stack._pos > 0
  708. can_forward = self._nav_stack._pos < len(self._nav_stack._elements) - 1
  709. if 'back' in self._actions:
  710. self._actions['back'].setEnabled(can_backward)
  711. if 'forward' in self._actions:
  712. self._actions['forward'].setEnabled(can_forward)
  713. class SubplotToolQt(UiSubplotTool):
  714. def __init__(self, targetfig, parent):
  715. UiSubplotTool.__init__(self, None)
  716. self._figure = targetfig
  717. for lower, higher in [("bottom", "top"), ("left", "right")]:
  718. self._widgets[lower].valueChanged.connect(
  719. lambda val: self._widgets[higher].setMinimum(val + .001))
  720. self._widgets[higher].valueChanged.connect(
  721. lambda val: self._widgets[lower].setMaximum(val - .001))
  722. self._attrs = ["top", "bottom", "left", "right", "hspace", "wspace"]
  723. self._defaults = {attr: vars(self._figure.subplotpars)[attr]
  724. for attr in self._attrs}
  725. # Set values after setting the range callbacks, but before setting up
  726. # the redraw callbacks.
  727. self._reset()
  728. for attr in self._attrs:
  729. self._widgets[attr].valueChanged.connect(self._on_value_changed)
  730. for action, method in [("Export values", self._export_values),
  731. ("Tight layout", self._tight_layout),
  732. ("Reset", self._reset),
  733. ("Close", self.close)]:
  734. self._widgets[action].clicked.connect(method)
  735. def _export_values(self):
  736. # Explicitly round to 3 decimals (which is also the spinbox precision)
  737. # to avoid numbers of the form 0.100...001.
  738. dialog = QtWidgets.QDialog()
  739. layout = QtWidgets.QVBoxLayout()
  740. dialog.setLayout(layout)
  741. text = QtWidgets.QPlainTextEdit()
  742. text.setReadOnly(True)
  743. layout.addWidget(text)
  744. text.setPlainText(
  745. ",\n".join("{}={:.3}".format(attr, self._widgets[attr].value())
  746. for attr in self._attrs))
  747. # Adjust the height of the text widget to fit the whole text, plus
  748. # some padding.
  749. size = text.maximumSize()
  750. size.setHeight(
  751. QtGui.QFontMetrics(text.document().defaultFont())
  752. .size(0, text.toPlainText()).height() + 20)
  753. text.setMaximumSize(size)
  754. dialog.exec_()
  755. def _on_value_changed(self):
  756. self._figure.subplots_adjust(**{attr: self._widgets[attr].value()
  757. for attr in self._attrs})
  758. self._figure.canvas.draw_idle()
  759. def _tight_layout(self):
  760. self._figure.tight_layout()
  761. for attr in self._attrs:
  762. widget = self._widgets[attr]
  763. widget.blockSignals(True)
  764. widget.setValue(vars(self._figure.subplotpars)[attr])
  765. widget.blockSignals(False)
  766. self._figure.canvas.draw_idle()
  767. def _reset(self):
  768. for attr, value in self._defaults.items():
  769. self._widgets[attr].setValue(value)
  770. class ToolbarQt(ToolContainerBase, QtWidgets.QToolBar):
  771. def __init__(self, toolmanager, parent):
  772. ToolContainerBase.__init__(self, toolmanager)
  773. QtWidgets.QToolBar.__init__(self, parent)
  774. self.setAllowedAreas(
  775. QtCore.Qt.TopToolBarArea | QtCore.Qt.BottomToolBarArea)
  776. message_label = QtWidgets.QLabel("")
  777. message_label.setAlignment(
  778. QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)
  779. message_label.setSizePolicy(
  780. QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding,
  781. QtWidgets.QSizePolicy.Ignored))
  782. self._message_action = self.addWidget(message_label)
  783. self._toolitems = {}
  784. self._groups = {}
  785. def add_toolitem(
  786. self, name, group, position, image_file, description, toggle):
  787. button = QtWidgets.QToolButton(self)
  788. if image_file:
  789. button.setIcon(NavigationToolbar2QT._icon(self, image_file))
  790. button.setText(name)
  791. if description:
  792. button.setToolTip(description)
  793. def handler():
  794. self.trigger_tool(name)
  795. if toggle:
  796. button.setCheckable(True)
  797. button.toggled.connect(handler)
  798. else:
  799. button.clicked.connect(handler)
  800. self._toolitems.setdefault(name, [])
  801. self._add_to_group(group, name, button, position)
  802. self._toolitems[name].append((button, handler))
  803. def _add_to_group(self, group, name, button, position):
  804. gr = self._groups.get(group, [])
  805. if not gr:
  806. sep = self.insertSeparator(self._message_action)
  807. gr.append(sep)
  808. before = gr[position]
  809. widget = self.insertWidget(before, button)
  810. gr.insert(position, widget)
  811. self._groups[group] = gr
  812. def toggle_toolitem(self, name, toggled):
  813. if name not in self._toolitems:
  814. return
  815. for button, handler in self._toolitems[name]:
  816. button.toggled.disconnect(handler)
  817. button.setChecked(toggled)
  818. button.toggled.connect(handler)
  819. def remove_toolitem(self, name):
  820. for button, handler in self._toolitems[name]:
  821. button.setParent(None)
  822. del self._toolitems[name]
  823. def set_message(self, s):
  824. self.widgetForAction(self._message_action).setText(s)
  825. @cbook.deprecated("3.3")
  826. class StatusbarQt(StatusbarBase, QtWidgets.QLabel):
  827. def __init__(self, window, *args, **kwargs):
  828. StatusbarBase.__init__(self, *args, **kwargs)
  829. QtWidgets.QLabel.__init__(self)
  830. window.statusBar().addWidget(self)
  831. def set_message(self, s):
  832. self.setText(s)
  833. class ConfigureSubplotsQt(backend_tools.ConfigureSubplotsBase):
  834. def trigger(self, *args):
  835. NavigationToolbar2QT.configure_subplots(
  836. self._make_classic_style_pseudo_toolbar())
  837. class SaveFigureQt(backend_tools.SaveFigureBase):
  838. def trigger(self, *args):
  839. NavigationToolbar2QT.save_figure(
  840. self._make_classic_style_pseudo_toolbar())
  841. class SetCursorQt(backend_tools.SetCursorBase):
  842. def set_cursor(self, cursor):
  843. NavigationToolbar2QT.set_cursor(
  844. self._make_classic_style_pseudo_toolbar(), cursor)
  845. class RubberbandQt(backend_tools.RubberbandBase):
  846. def draw_rubberband(self, x0, y0, x1, y1):
  847. NavigationToolbar2QT.draw_rubberband(
  848. self._make_classic_style_pseudo_toolbar(), None, x0, y0, x1, y1)
  849. def remove_rubberband(self):
  850. NavigationToolbar2QT.remove_rubberband(
  851. self._make_classic_style_pseudo_toolbar())
  852. class HelpQt(backend_tools.ToolHelpBase):
  853. def trigger(self, *args):
  854. QtWidgets.QMessageBox.information(None, "Help", self._get_help_html())
  855. class ToolCopyToClipboardQT(backend_tools.ToolCopyToClipboardBase):
  856. def trigger(self, *args, **kwargs):
  857. pixmap = self.canvas.grab()
  858. qApp.clipboard().setPixmap(pixmap)
  859. backend_tools.ToolSaveFigure = SaveFigureQt
  860. backend_tools.ToolConfigureSubplots = ConfigureSubplotsQt
  861. backend_tools.ToolSetCursor = SetCursorQt
  862. backend_tools.ToolRubberband = RubberbandQt
  863. backend_tools.ToolHelp = HelpQt
  864. backend_tools.ToolCopyToClipboard = ToolCopyToClipboardQT
  865. @_Backend.export
  866. class _BackendQT5(_Backend):
  867. FigureCanvas = FigureCanvasQT
  868. FigureManager = FigureManagerQT
  869. @staticmethod
  870. def trigger_manager_draw(manager):
  871. manager.canvas.draw_idle()
  872. @staticmethod
  873. def mainloop():
  874. old_signal = signal.getsignal(signal.SIGINT)
  875. # allow SIGINT exceptions to close the plot window.
  876. is_python_signal_handler = old_signal is not None
  877. if is_python_signal_handler:
  878. signal.signal(signal.SIGINT, signal.SIG_DFL)
  879. try:
  880. qApp.exec_()
  881. finally:
  882. # reset the SIGINT exception handler
  883. if is_python_signal_handler:
  884. signal.signal(signal.SIGINT, old_signal)