backend_qt.py 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089
  1. import functools
  2. import os
  3. import sys
  4. import traceback
  5. import matplotlib as mpl
  6. from matplotlib import _api, backend_tools, cbook
  7. from matplotlib._pylab_helpers import Gcf
  8. from matplotlib.backend_bases import (
  9. _Backend, FigureCanvasBase, FigureManagerBase, NavigationToolbar2,
  10. TimerBase, cursors, ToolContainerBase, MouseButton,
  11. CloseEvent, KeyEvent, LocationEvent, MouseEvent, ResizeEvent,
  12. _allow_interrupt)
  13. import matplotlib.backends.qt_editor.figureoptions as figureoptions
  14. from . import qt_compat
  15. from .qt_compat import (
  16. QtCore, QtGui, QtWidgets, __version__, QT_API, _to_int, _isdeleted)
  17. # SPECIAL_KEYS are Qt::Key that do *not* return their Unicode name
  18. # instead they have manually specified names.
  19. SPECIAL_KEYS = {
  20. _to_int(getattr(QtCore.Qt.Key, k)): v for k, v in [
  21. ("Key_Escape", "escape"),
  22. ("Key_Tab", "tab"),
  23. ("Key_Backspace", "backspace"),
  24. ("Key_Return", "enter"),
  25. ("Key_Enter", "enter"),
  26. ("Key_Insert", "insert"),
  27. ("Key_Delete", "delete"),
  28. ("Key_Pause", "pause"),
  29. ("Key_SysReq", "sysreq"),
  30. ("Key_Clear", "clear"),
  31. ("Key_Home", "home"),
  32. ("Key_End", "end"),
  33. ("Key_Left", "left"),
  34. ("Key_Up", "up"),
  35. ("Key_Right", "right"),
  36. ("Key_Down", "down"),
  37. ("Key_PageUp", "pageup"),
  38. ("Key_PageDown", "pagedown"),
  39. ("Key_Shift", "shift"),
  40. # In macOS, the control and super (aka cmd/apple) keys are switched.
  41. ("Key_Control", "control" if sys.platform != "darwin" else "cmd"),
  42. ("Key_Meta", "meta" if sys.platform != "darwin" else "control"),
  43. ("Key_Alt", "alt"),
  44. ("Key_CapsLock", "caps_lock"),
  45. ("Key_F1", "f1"),
  46. ("Key_F2", "f2"),
  47. ("Key_F3", "f3"),
  48. ("Key_F4", "f4"),
  49. ("Key_F5", "f5"),
  50. ("Key_F6", "f6"),
  51. ("Key_F7", "f7"),
  52. ("Key_F8", "f8"),
  53. ("Key_F9", "f9"),
  54. ("Key_F10", "f10"),
  55. ("Key_F10", "f11"),
  56. ("Key_F12", "f12"),
  57. ("Key_Super_L", "super"),
  58. ("Key_Super_R", "super"),
  59. ]
  60. }
  61. # Define which modifier keys are collected on keyboard events.
  62. # Elements are (Qt::KeyboardModifiers, Qt::Key) tuples.
  63. # Order determines the modifier order (ctrl+alt+...) reported by Matplotlib.
  64. _MODIFIER_KEYS = [
  65. (_to_int(getattr(QtCore.Qt.KeyboardModifier, mod)),
  66. _to_int(getattr(QtCore.Qt.Key, key)))
  67. for mod, key in [
  68. ("ControlModifier", "Key_Control"),
  69. ("AltModifier", "Key_Alt"),
  70. ("ShiftModifier", "Key_Shift"),
  71. ("MetaModifier", "Key_Meta"),
  72. ]
  73. ]
  74. cursord = {
  75. k: getattr(QtCore.Qt.CursorShape, v) for k, v in [
  76. (cursors.MOVE, "SizeAllCursor"),
  77. (cursors.HAND, "PointingHandCursor"),
  78. (cursors.POINTER, "ArrowCursor"),
  79. (cursors.SELECT_REGION, "CrossCursor"),
  80. (cursors.WAIT, "WaitCursor"),
  81. (cursors.RESIZE_HORIZONTAL, "SizeHorCursor"),
  82. (cursors.RESIZE_VERTICAL, "SizeVerCursor"),
  83. ]
  84. }
  85. # lru_cache keeps a reference to the QApplication instance, keeping it from
  86. # being GC'd.
  87. @functools.lru_cache(1)
  88. def _create_qApp():
  89. app = QtWidgets.QApplication.instance()
  90. # Create a new QApplication and configure it if none exists yet, as only
  91. # one QApplication can exist at a time.
  92. if app is None:
  93. # display_is_valid returns False only if on Linux and neither X11
  94. # nor Wayland display can be opened.
  95. if not mpl._c_internal_utils.display_is_valid():
  96. raise RuntimeError('Invalid DISPLAY variable')
  97. # Check to make sure a QApplication from a different major version
  98. # of Qt is not instantiated in the process
  99. if QT_API in {'PyQt6', 'PySide6'}:
  100. other_bindings = ('PyQt5', 'PySide2')
  101. qt_version = 6
  102. elif QT_API in {'PyQt5', 'PySide2'}:
  103. other_bindings = ('PyQt6', 'PySide6')
  104. qt_version = 5
  105. else:
  106. raise RuntimeError("Should never be here")
  107. for binding in other_bindings:
  108. mod = sys.modules.get(f'{binding}.QtWidgets')
  109. if mod is not None and mod.QApplication.instance() is not None:
  110. other_core = sys.modules.get(f'{binding}.QtCore')
  111. _api.warn_external(
  112. f'Matplotlib is using {QT_API} which wraps '
  113. f'{QtCore.qVersion()} however an instantiated '
  114. f'QApplication from {binding} which wraps '
  115. f'{other_core.qVersion()} exists. Mixing Qt major '
  116. 'versions may not work as expected.'
  117. )
  118. break
  119. if qt_version == 5:
  120. try:
  121. QtWidgets.QApplication.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling)
  122. except AttributeError: # Only for Qt>=5.6, <6.
  123. pass
  124. try:
  125. QtWidgets.QApplication.setHighDpiScaleFactorRoundingPolicy(
  126. QtCore.Qt.HighDpiScaleFactorRoundingPolicy.PassThrough)
  127. except AttributeError: # Only for Qt>=5.14.
  128. pass
  129. app = QtWidgets.QApplication(["matplotlib"])
  130. if sys.platform == "darwin":
  131. image = str(cbook._get_data_path('images/matplotlib.svg'))
  132. icon = QtGui.QIcon(image)
  133. app.setWindowIcon(icon)
  134. app.setQuitOnLastWindowClosed(True)
  135. cbook._setup_new_guiapp()
  136. if qt_version == 5:
  137. app.setAttribute(QtCore.Qt.AA_UseHighDpiPixmaps)
  138. return app
  139. def _allow_interrupt_qt(qapp_or_eventloop):
  140. """A context manager that allows terminating a plot by sending a SIGINT."""
  141. # Use QSocketNotifier to read the socketpair while the Qt event loop runs.
  142. def prepare_notifier(rsock):
  143. sn = QtCore.QSocketNotifier(rsock.fileno(), QtCore.QSocketNotifier.Type.Read)
  144. @sn.activated.connect
  145. def _may_clear_sock():
  146. # Running a Python function on socket activation gives the interpreter a
  147. # chance to handle the signal in Python land. We also need to drain the
  148. # socket with recv() to re-arm it, because it will be written to as part of
  149. # the wakeup. (We need this in case set_wakeup_fd catches a signal other
  150. # than SIGINT and we shall continue waiting.)
  151. try:
  152. rsock.recv(1)
  153. except BlockingIOError:
  154. # This may occasionally fire too soon or more than once on Windows, so
  155. # be forgiving about reading an empty socket.
  156. pass
  157. # We return the QSocketNotifier so that the caller holds a reference, and we
  158. # also explicitly clean it up in handle_sigint(). Without doing both, deletion
  159. # of the socket notifier can happen prematurely or not at all.
  160. return sn
  161. def handle_sigint(sn):
  162. sn.deleteLater()
  163. QtCore.QCoreApplication.sendPostedEvents(sn, QtCore.QEvent.Type.DeferredDelete)
  164. if hasattr(qapp_or_eventloop, 'closeAllWindows'):
  165. qapp_or_eventloop.closeAllWindows()
  166. qapp_or_eventloop.quit()
  167. return _allow_interrupt(prepare_notifier, handle_sigint)
  168. class TimerQT(TimerBase):
  169. """Subclass of `.TimerBase` using QTimer events."""
  170. def __init__(self, *args, **kwargs):
  171. # Create a new timer and connect the timeout() signal to the
  172. # _on_timer method.
  173. self._timer = QtCore.QTimer()
  174. self._timer.timeout.connect(self._on_timer)
  175. super().__init__(*args, **kwargs)
  176. def __del__(self):
  177. # The check for deletedness is needed to avoid an error at animation
  178. # shutdown with PySide2.
  179. if not _isdeleted(self._timer):
  180. self._timer_stop()
  181. def _timer_set_single_shot(self):
  182. self._timer.setSingleShot(self._single)
  183. def _timer_set_interval(self):
  184. self._timer.setInterval(self._interval)
  185. def _timer_start(self):
  186. self._timer.start()
  187. def _timer_stop(self):
  188. self._timer.stop()
  189. class FigureCanvasQT(FigureCanvasBase, QtWidgets.QWidget):
  190. required_interactive_framework = "qt"
  191. _timer_cls = TimerQT
  192. manager_class = _api.classproperty(lambda cls: FigureManagerQT)
  193. buttond = {
  194. getattr(QtCore.Qt.MouseButton, k): v for k, v in [
  195. ("LeftButton", MouseButton.LEFT),
  196. ("RightButton", MouseButton.RIGHT),
  197. ("MiddleButton", MouseButton.MIDDLE),
  198. ("XButton1", MouseButton.BACK),
  199. ("XButton2", MouseButton.FORWARD),
  200. ]
  201. }
  202. def __init__(self, figure=None):
  203. _create_qApp()
  204. super().__init__(figure=figure)
  205. self._draw_pending = False
  206. self._is_drawing = False
  207. self._draw_rect_callback = lambda painter: None
  208. self._in_resize_event = False
  209. self.setAttribute(QtCore.Qt.WidgetAttribute.WA_OpaquePaintEvent)
  210. self.setMouseTracking(True)
  211. self.resize(*self.get_width_height())
  212. palette = QtGui.QPalette(QtGui.QColor("white"))
  213. self.setPalette(palette)
  214. @QtCore.Slot()
  215. def _update_pixel_ratio(self):
  216. if self._set_device_pixel_ratio(
  217. self.devicePixelRatioF() or 1): # rarely, devicePixelRatioF=0
  218. # The easiest way to resize the canvas is to emit a resizeEvent
  219. # since we implement all the logic for resizing the canvas for
  220. # that event.
  221. event = QtGui.QResizeEvent(self.size(), self.size())
  222. self.resizeEvent(event)
  223. @QtCore.Slot(QtGui.QScreen)
  224. def _update_screen(self, screen):
  225. # Handler for changes to a window's attached screen.
  226. self._update_pixel_ratio()
  227. if screen is not None:
  228. screen.physicalDotsPerInchChanged.connect(self._update_pixel_ratio)
  229. screen.logicalDotsPerInchChanged.connect(self._update_pixel_ratio)
  230. def eventFilter(self, source, event):
  231. if event.type() == QtCore.QEvent.Type.DevicePixelRatioChange:
  232. self._update_pixel_ratio()
  233. return super().eventFilter(source, event)
  234. def showEvent(self, event):
  235. # Set up correct pixel ratio, and connect to any signal changes for it,
  236. # once the window is shown (and thus has these attributes).
  237. window = self.window().windowHandle()
  238. current_version = tuple(int(x) for x in QtCore.qVersion().split('.', 2)[:2])
  239. if current_version >= (6, 6):
  240. self._update_pixel_ratio()
  241. window.installEventFilter(self)
  242. else:
  243. window.screenChanged.connect(self._update_screen)
  244. self._update_screen(window.screen())
  245. def set_cursor(self, cursor):
  246. # docstring inherited
  247. self.setCursor(_api.check_getitem(cursord, cursor=cursor))
  248. def mouseEventCoords(self, pos=None):
  249. """
  250. Calculate mouse coordinates in physical pixels.
  251. Qt uses logical pixels, but the figure is scaled to physical
  252. pixels for rendering. Transform to physical pixels so that
  253. all of the down-stream transforms work as expected.
  254. Also, the origin is different and needs to be corrected.
  255. """
  256. if pos is None:
  257. pos = self.mapFromGlobal(QtGui.QCursor.pos())
  258. elif hasattr(pos, "position"): # qt6 QtGui.QEvent
  259. pos = pos.position()
  260. elif hasattr(pos, "pos"): # qt5 QtCore.QEvent
  261. pos = pos.pos()
  262. # (otherwise, it's already a QPoint)
  263. x = pos.x()
  264. # flip y so y=0 is bottom of canvas
  265. y = self.figure.bbox.height / self.device_pixel_ratio - pos.y()
  266. return x * self.device_pixel_ratio, y * self.device_pixel_ratio
  267. def enterEvent(self, event):
  268. # Force querying of the modifiers, as the cached modifier state can
  269. # have been invalidated while the window was out of focus.
  270. mods = QtWidgets.QApplication.instance().queryKeyboardModifiers()
  271. if self.figure is None:
  272. return
  273. LocationEvent("figure_enter_event", self,
  274. *self.mouseEventCoords(event),
  275. modifiers=self._mpl_modifiers(mods),
  276. guiEvent=event)._process()
  277. def leaveEvent(self, event):
  278. QtWidgets.QApplication.restoreOverrideCursor()
  279. if self.figure is None:
  280. return
  281. LocationEvent("figure_leave_event", self,
  282. *self.mouseEventCoords(),
  283. modifiers=self._mpl_modifiers(),
  284. guiEvent=event)._process()
  285. def mousePressEvent(self, event):
  286. button = self.buttond.get(event.button())
  287. if button is not None and self.figure is not None:
  288. MouseEvent("button_press_event", self,
  289. *self.mouseEventCoords(event), button,
  290. modifiers=self._mpl_modifiers(),
  291. guiEvent=event)._process()
  292. def mouseDoubleClickEvent(self, event):
  293. button = self.buttond.get(event.button())
  294. if button is not None and self.figure is not None:
  295. MouseEvent("button_press_event", self,
  296. *self.mouseEventCoords(event), button, dblclick=True,
  297. modifiers=self._mpl_modifiers(),
  298. guiEvent=event)._process()
  299. def mouseMoveEvent(self, event):
  300. if self.figure is None:
  301. return
  302. MouseEvent("motion_notify_event", self,
  303. *self.mouseEventCoords(event),
  304. buttons=self._mpl_buttons(event.buttons()),
  305. modifiers=self._mpl_modifiers(),
  306. guiEvent=event)._process()
  307. def mouseReleaseEvent(self, event):
  308. button = self.buttond.get(event.button())
  309. if button is not None and self.figure is not None:
  310. MouseEvent("button_release_event", self,
  311. *self.mouseEventCoords(event), button,
  312. modifiers=self._mpl_modifiers(),
  313. guiEvent=event)._process()
  314. def wheelEvent(self, event):
  315. # from QWheelEvent::pixelDelta doc: pixelDelta is sometimes not
  316. # provided (`isNull()`) and is unreliable on X11 ("xcb").
  317. if (event.pixelDelta().isNull()
  318. or QtWidgets.QApplication.instance().platformName() == "xcb"):
  319. steps = event.angleDelta().y() / 120
  320. else:
  321. steps = event.pixelDelta().y()
  322. if steps and self.figure is not None:
  323. MouseEvent("scroll_event", self,
  324. *self.mouseEventCoords(event), step=steps,
  325. modifiers=self._mpl_modifiers(),
  326. guiEvent=event)._process()
  327. def keyPressEvent(self, event):
  328. key = self._get_key(event)
  329. if key is not None and self.figure is not None:
  330. KeyEvent("key_press_event", self,
  331. key, *self.mouseEventCoords(),
  332. guiEvent=event)._process()
  333. def keyReleaseEvent(self, event):
  334. key = self._get_key(event)
  335. if key is not None and self.figure is not None:
  336. KeyEvent("key_release_event", self,
  337. key, *self.mouseEventCoords(),
  338. guiEvent=event)._process()
  339. def resizeEvent(self, event):
  340. if self._in_resize_event: # Prevent PyQt6 recursion
  341. return
  342. if self.figure is None:
  343. return
  344. self._in_resize_event = True
  345. try:
  346. w = event.size().width() * self.device_pixel_ratio
  347. h = event.size().height() * self.device_pixel_ratio
  348. dpival = self.figure.dpi
  349. winch = w / dpival
  350. hinch = h / dpival
  351. self.figure.set_size_inches(winch, hinch, forward=False)
  352. # pass back into Qt to let it finish
  353. QtWidgets.QWidget.resizeEvent(self, event)
  354. # emit our resize events
  355. ResizeEvent("resize_event", self)._process()
  356. self.draw_idle()
  357. finally:
  358. self._in_resize_event = False
  359. def sizeHint(self):
  360. w, h = self.get_width_height()
  361. return QtCore.QSize(w, h)
  362. def minimumSizeHint(self):
  363. return QtCore.QSize(10, 10)
  364. @staticmethod
  365. def _mpl_buttons(buttons):
  366. buttons = _to_int(buttons)
  367. # State *after* press/release.
  368. return {button for mask, button in FigureCanvasQT.buttond.items()
  369. if _to_int(mask) & buttons}
  370. @staticmethod
  371. def _mpl_modifiers(modifiers=None, *, exclude=None):
  372. if modifiers is None:
  373. modifiers = QtWidgets.QApplication.instance().keyboardModifiers()
  374. modifiers = _to_int(modifiers)
  375. # get names of the pressed modifier keys
  376. # 'control' is named 'control' when a standalone key, but 'ctrl' when a
  377. # modifier
  378. # bit twiddling to pick out modifier keys from modifiers bitmask,
  379. # if exclude is a MODIFIER, it should not be duplicated in mods
  380. return [SPECIAL_KEYS[key].replace('control', 'ctrl')
  381. for mask, key in _MODIFIER_KEYS
  382. if exclude != key and modifiers & mask]
  383. def _get_key(self, event):
  384. event_key = event.key()
  385. mods = self._mpl_modifiers(exclude=event_key)
  386. try:
  387. # for certain keys (enter, left, backspace, etc) use a word for the
  388. # key, rather than Unicode
  389. key = SPECIAL_KEYS[event_key]
  390. except KeyError:
  391. # Unicode defines code points up to 0x10ffff (sys.maxunicode)
  392. # QT will use Key_Codes larger than that for keyboard keys that are
  393. # not Unicode characters (like multimedia keys)
  394. # skip these
  395. # if you really want them, you should add them to SPECIAL_KEYS
  396. if event_key > sys.maxunicode:
  397. return None
  398. key = chr(event_key)
  399. # qt delivers capitalized letters. fix capitalization
  400. # note that capslock is ignored
  401. if 'shift' in mods:
  402. mods.remove('shift')
  403. else:
  404. key = key.lower()
  405. return '+'.join(mods + [key])
  406. def flush_events(self):
  407. # docstring inherited
  408. QtWidgets.QApplication.instance().processEvents()
  409. def start_event_loop(self, timeout=0):
  410. # docstring inherited
  411. if hasattr(self, "_event_loop") and self._event_loop.isRunning():
  412. raise RuntimeError("Event loop already running")
  413. self._event_loop = event_loop = QtCore.QEventLoop()
  414. if timeout > 0:
  415. _ = QtCore.QTimer.singleShot(int(timeout * 1000), event_loop.quit)
  416. with _allow_interrupt_qt(event_loop):
  417. qt_compat._exec(event_loop)
  418. def stop_event_loop(self, event=None):
  419. # docstring inherited
  420. if hasattr(self, "_event_loop"):
  421. self._event_loop.quit()
  422. def draw(self):
  423. """Render the figure, and queue a request for a Qt draw."""
  424. # The renderer draw is done here; delaying causes problems with code
  425. # that uses the result of the draw() to update plot elements.
  426. if self._is_drawing:
  427. return
  428. with cbook._setattr_cm(self, _is_drawing=True):
  429. super().draw()
  430. self.update()
  431. def draw_idle(self):
  432. """Queue redraw of the Agg buffer and request Qt paintEvent."""
  433. # The Agg draw needs to be handled by the same thread Matplotlib
  434. # modifies the scene graph from. Post Agg draw request to the
  435. # current event loop in order to ensure thread affinity and to
  436. # accumulate multiple draw requests from event handling.
  437. # TODO: queued signal connection might be safer than singleShot
  438. if not (getattr(self, '_draw_pending', False) or
  439. getattr(self, '_is_drawing', False)):
  440. self._draw_pending = True
  441. QtCore.QTimer.singleShot(0, self._draw_idle)
  442. def blit(self, bbox=None):
  443. # docstring inherited
  444. if bbox is None and self.figure:
  445. bbox = self.figure.bbox # Blit the entire canvas if bbox is None.
  446. # repaint uses logical pixels, not physical pixels like the renderer.
  447. l, b, w, h = (int(pt / self.device_pixel_ratio) for pt in bbox.bounds)
  448. t = b + h
  449. self.repaint(l, self.rect().height() - t, w, h)
  450. def _draw_idle(self):
  451. with self._idle_draw_cntx():
  452. if not self._draw_pending:
  453. return
  454. self._draw_pending = False
  455. if _isdeleted(self) or self.height() <= 0 or self.width() <= 0:
  456. return
  457. try:
  458. self.draw()
  459. except Exception:
  460. # Uncaught exceptions are fatal for PyQt5, so catch them.
  461. traceback.print_exc()
  462. def drawRectangle(self, rect):
  463. # Draw the zoom rectangle to the QPainter. _draw_rect_callback needs
  464. # to be called at the end of paintEvent.
  465. if rect is not None:
  466. x0, y0, w, h = (int(pt / self.device_pixel_ratio) for pt in rect)
  467. x1 = x0 + w
  468. y1 = y0 + h
  469. def _draw_rect_callback(painter):
  470. pen = QtGui.QPen(
  471. QtGui.QColor("black"),
  472. 1 / self.device_pixel_ratio
  473. )
  474. pen.setDashPattern([3, 3])
  475. for color, offset in [
  476. (QtGui.QColor("black"), 0),
  477. (QtGui.QColor("white"), 3),
  478. ]:
  479. pen.setDashOffset(offset)
  480. pen.setColor(color)
  481. painter.setPen(pen)
  482. # Draw the lines from x0, y0 towards x1, y1 so that the
  483. # dashes don't "jump" when moving the zoom box.
  484. painter.drawLine(x0, y0, x0, y1)
  485. painter.drawLine(x0, y0, x1, y0)
  486. painter.drawLine(x0, y1, x1, y1)
  487. painter.drawLine(x1, y0, x1, y1)
  488. else:
  489. def _draw_rect_callback(painter):
  490. return
  491. self._draw_rect_callback = _draw_rect_callback
  492. self.update()
  493. class MainWindow(QtWidgets.QMainWindow):
  494. closing = QtCore.Signal()
  495. def closeEvent(self, event):
  496. self.closing.emit()
  497. super().closeEvent(event)
  498. class FigureManagerQT(FigureManagerBase):
  499. """
  500. Attributes
  501. ----------
  502. canvas : `FigureCanvas`
  503. The FigureCanvas instance
  504. num : int or str
  505. The Figure number
  506. toolbar : qt.QToolBar
  507. The qt.QToolBar
  508. window : qt.QMainWindow
  509. The qt.QMainWindow
  510. """
  511. def __init__(self, canvas, num):
  512. self.window = MainWindow()
  513. super().__init__(canvas, num)
  514. self.window.closing.connect(self._widgetclosed)
  515. if sys.platform != "darwin":
  516. image = str(cbook._get_data_path('images/matplotlib.svg'))
  517. icon = QtGui.QIcon(image)
  518. self.window.setWindowIcon(icon)
  519. self.window._destroying = False
  520. if self.toolbar:
  521. self.window.addToolBar(self.toolbar)
  522. tbs_height = self.toolbar.sizeHint().height()
  523. else:
  524. tbs_height = 0
  525. # resize the main window so it will display the canvas with the
  526. # requested size:
  527. cs = canvas.sizeHint()
  528. cs_height = cs.height()
  529. height = cs_height + tbs_height
  530. self.window.resize(cs.width(), height)
  531. self.window.setCentralWidget(self.canvas)
  532. if mpl.is_interactive():
  533. self.window.show()
  534. self.canvas.draw_idle()
  535. # Give the keyboard focus to the figure instead of the manager:
  536. # StrongFocus accepts both tab and click to focus and will enable the
  537. # canvas to process event without clicking.
  538. # https://doc.qt.io/qt-5/qt.html#FocusPolicy-enum
  539. self.canvas.setFocusPolicy(QtCore.Qt.FocusPolicy.StrongFocus)
  540. self.canvas.setFocus()
  541. self.window.raise_()
  542. def full_screen_toggle(self):
  543. if self.window.isFullScreen():
  544. self.window.showNormal()
  545. else:
  546. self.window.showFullScreen()
  547. def _widgetclosed(self):
  548. CloseEvent("close_event", self.canvas)._process()
  549. if self.window._destroying:
  550. return
  551. self.window._destroying = True
  552. try:
  553. Gcf.destroy(self)
  554. except AttributeError:
  555. pass
  556. # It seems that when the python session is killed,
  557. # Gcf can get destroyed before the Gcf.destroy
  558. # line is run, leading to a useless AttributeError.
  559. def resize(self, width, height):
  560. # The Qt methods return sizes in 'virtual' pixels so we do need to
  561. # rescale from physical to logical pixels.
  562. width = int(width / self.canvas.device_pixel_ratio)
  563. height = int(height / self.canvas.device_pixel_ratio)
  564. extra_width = self.window.width() - self.canvas.width()
  565. extra_height = self.window.height() - self.canvas.height()
  566. self.canvas.resize(width, height)
  567. self.window.resize(width + extra_width, height + extra_height)
  568. @classmethod
  569. def start_main_loop(cls):
  570. qapp = QtWidgets.QApplication.instance()
  571. if qapp:
  572. with _allow_interrupt_qt(qapp):
  573. qt_compat._exec(qapp)
  574. def show(self):
  575. self.window._destroying = False
  576. self.window.show()
  577. if mpl.rcParams['figure.raise_window']:
  578. self.window.activateWindow()
  579. self.window.raise_()
  580. def destroy(self, *args):
  581. # check for qApp first, as PySide deletes it in its atexit handler
  582. if QtWidgets.QApplication.instance() is None:
  583. return
  584. if self.window._destroying:
  585. return
  586. self.window._destroying = True
  587. if self.toolbar:
  588. self.toolbar.destroy()
  589. self.window.close()
  590. def get_window_title(self):
  591. return self.window.windowTitle()
  592. def set_window_title(self, title):
  593. self.window.setWindowTitle(title)
  594. class NavigationToolbar2QT(NavigationToolbar2, QtWidgets.QToolBar):
  595. toolitems = [*NavigationToolbar2.toolitems]
  596. toolitems.insert(
  597. # Add 'customize' action after 'subplots'
  598. [name for name, *_ in toolitems].index("Subplots") + 1,
  599. ("Customize", "Edit axis, curve and image parameters",
  600. "qt4_editor_options", "edit_parameters"))
  601. def __init__(self, canvas, parent=None, coordinates=True):
  602. """coordinates: should we show the coordinates on the right?"""
  603. QtWidgets.QToolBar.__init__(self, parent)
  604. self.setAllowedAreas(QtCore.Qt.ToolBarArea(
  605. _to_int(QtCore.Qt.ToolBarArea.TopToolBarArea) |
  606. _to_int(QtCore.Qt.ToolBarArea.BottomToolBarArea)))
  607. self.coordinates = coordinates
  608. self._actions = {} # mapping of toolitem method names to QActions.
  609. self._subplot_dialog = None
  610. for text, tooltip_text, image_file, callback in self.toolitems:
  611. if text is None:
  612. self.addSeparator()
  613. else:
  614. slot = getattr(self, callback)
  615. # https://bugreports.qt.io/browse/PYSIDE-2512
  616. slot = functools.wraps(slot)(functools.partial(slot))
  617. slot = QtCore.Slot()(slot)
  618. a = self.addAction(self._icon(image_file + '.png'),
  619. text, slot)
  620. self._actions[callback] = a
  621. if callback in ['zoom', 'pan']:
  622. a.setCheckable(True)
  623. if tooltip_text is not None:
  624. a.setToolTip(tooltip_text)
  625. # Add the (x, y) location widget at the right side of the toolbar
  626. # The stretch factor is 1 which means any resizing of the toolbar
  627. # will resize this label instead of the buttons.
  628. if self.coordinates:
  629. self.locLabel = QtWidgets.QLabel("", self)
  630. self.locLabel.setAlignment(QtCore.Qt.AlignmentFlag(
  631. _to_int(QtCore.Qt.AlignmentFlag.AlignRight) |
  632. _to_int(QtCore.Qt.AlignmentFlag.AlignVCenter)))
  633. self.locLabel.setSizePolicy(QtWidgets.QSizePolicy(
  634. QtWidgets.QSizePolicy.Policy.Expanding,
  635. QtWidgets.QSizePolicy.Policy.Ignored,
  636. ))
  637. labelAction = self.addWidget(self.locLabel)
  638. labelAction.setVisible(True)
  639. NavigationToolbar2.__init__(self, canvas)
  640. def _icon(self, name):
  641. """
  642. Construct a `.QIcon` from an image file *name*, including the extension
  643. and relative to Matplotlib's "images" data directory.
  644. """
  645. # use a high-resolution icon with suffix '_large' if available
  646. # note: user-provided icons may not have '_large' versions
  647. path_regular = cbook._get_data_path('images', name)
  648. path_large = path_regular.with_name(
  649. path_regular.name.replace('.png', '_large.png'))
  650. filename = str(path_large if path_large.exists() else path_regular)
  651. pm = QtGui.QPixmap(filename)
  652. pm.setDevicePixelRatio(
  653. self.devicePixelRatioF() or 1) # rarely, devicePixelRatioF=0
  654. if self.palette().color(self.backgroundRole()).value() < 128:
  655. icon_color = self.palette().color(self.foregroundRole())
  656. mask = pm.createMaskFromColor(
  657. QtGui.QColor('black'),
  658. QtCore.Qt.MaskMode.MaskOutColor)
  659. pm.fill(icon_color)
  660. pm.setMask(mask)
  661. return QtGui.QIcon(pm)
  662. def edit_parameters(self):
  663. axes = self.canvas.figure.get_axes()
  664. if not axes:
  665. QtWidgets.QMessageBox.warning(
  666. self.canvas.parent(), "Error", "There are no Axes to edit.")
  667. return
  668. elif len(axes) == 1:
  669. ax, = axes
  670. else:
  671. titles = [
  672. ax.get_label() or
  673. ax.get_title() or
  674. ax.get_title("left") or
  675. ax.get_title("right") or
  676. " - ".join(filter(None, [ax.get_xlabel(), ax.get_ylabel()])) or
  677. f"<anonymous {type(ax).__name__}>"
  678. for ax in axes]
  679. duplicate_titles = [
  680. title for title in titles if titles.count(title) > 1]
  681. for i, ax in enumerate(axes):
  682. if titles[i] in duplicate_titles:
  683. titles[i] += f" (id: {id(ax):#x})" # Deduplicate titles.
  684. item, ok = QtWidgets.QInputDialog.getItem(
  685. self.canvas.parent(),
  686. 'Customize', 'Select Axes:', titles, 0, False)
  687. if not ok:
  688. return
  689. ax = axes[titles.index(item)]
  690. figureoptions.figure_edit(ax, self)
  691. def _update_buttons_checked(self):
  692. # sync button checkstates to match active mode
  693. if 'pan' in self._actions:
  694. self._actions['pan'].setChecked(self.mode.name == 'PAN')
  695. if 'zoom' in self._actions:
  696. self._actions['zoom'].setChecked(self.mode.name == 'ZOOM')
  697. def pan(self, *args):
  698. super().pan(*args)
  699. self._update_buttons_checked()
  700. def zoom(self, *args):
  701. super().zoom(*args)
  702. self._update_buttons_checked()
  703. def set_message(self, s):
  704. if self.coordinates:
  705. self.locLabel.setText(s)
  706. def draw_rubberband(self, event, x0, y0, x1, y1):
  707. height = self.canvas.figure.bbox.height
  708. y1 = height - y1
  709. y0 = height - y0
  710. rect = [int(val) for val in (x0, y0, x1 - x0, y1 - y0)]
  711. self.canvas.drawRectangle(rect)
  712. def remove_rubberband(self):
  713. self.canvas.drawRectangle(None)
  714. def configure_subplots(self):
  715. if self._subplot_dialog is None:
  716. self._subplot_dialog = SubplotToolQt(
  717. self.canvas.figure, self.canvas.parent())
  718. self.canvas.mpl_connect(
  719. "close_event", lambda e: self._subplot_dialog.reject())
  720. self._subplot_dialog.update_from_current_subplotpars()
  721. self._subplot_dialog.setModal(True)
  722. self._subplot_dialog.show()
  723. return self._subplot_dialog
  724. def save_figure(self, *args):
  725. filetypes = self.canvas.get_supported_filetypes_grouped()
  726. sorted_filetypes = sorted(filetypes.items())
  727. default_filetype = self.canvas.get_default_filetype()
  728. startpath = os.path.expanduser(mpl.rcParams['savefig.directory'])
  729. start = os.path.join(startpath, self.canvas.get_default_filename())
  730. filters = []
  731. selectedFilter = None
  732. for name, exts in sorted_filetypes:
  733. exts_list = " ".join(['*.%s' % ext for ext in exts])
  734. filter = f'{name} ({exts_list})'
  735. if default_filetype in exts:
  736. selectedFilter = filter
  737. filters.append(filter)
  738. filters = ';;'.join(filters)
  739. fname, filter = QtWidgets.QFileDialog.getSaveFileName(
  740. self.canvas.parent(), "Choose a filename to save to", start,
  741. filters, selectedFilter)
  742. if fname:
  743. # Save dir for next time, unless empty str (i.e., use cwd).
  744. if startpath != "":
  745. mpl.rcParams['savefig.directory'] = os.path.dirname(fname)
  746. try:
  747. self.canvas.figure.savefig(fname)
  748. except Exception as e:
  749. QtWidgets.QMessageBox.critical(
  750. self, "Error saving file", str(e),
  751. QtWidgets.QMessageBox.StandardButton.Ok,
  752. QtWidgets.QMessageBox.StandardButton.NoButton)
  753. return fname
  754. def set_history_buttons(self):
  755. can_backward = self._nav_stack._pos > 0
  756. can_forward = self._nav_stack._pos < len(self._nav_stack) - 1
  757. if 'back' in self._actions:
  758. self._actions['back'].setEnabled(can_backward)
  759. if 'forward' in self._actions:
  760. self._actions['forward'].setEnabled(can_forward)
  761. class SubplotToolQt(QtWidgets.QDialog):
  762. def __init__(self, targetfig, parent):
  763. super().__init__(parent)
  764. self.setWindowIcon(QtGui.QIcon(
  765. str(cbook._get_data_path("images/matplotlib.png"))))
  766. self.setObjectName("SubplotTool")
  767. self._spinboxes = {}
  768. main_layout = QtWidgets.QHBoxLayout()
  769. self.setLayout(main_layout)
  770. for group, spinboxes, buttons in [
  771. ("Borders",
  772. ["top", "bottom", "left", "right"],
  773. [("Export values", self._export_values)]),
  774. ("Spacings",
  775. ["hspace", "wspace"],
  776. [("Tight layout", self._tight_layout),
  777. ("Reset", self._reset),
  778. ("Close", self.close)])]:
  779. layout = QtWidgets.QVBoxLayout()
  780. main_layout.addLayout(layout)
  781. box = QtWidgets.QGroupBox(group)
  782. layout.addWidget(box)
  783. inner = QtWidgets.QFormLayout(box)
  784. for name in spinboxes:
  785. self._spinboxes[name] = spinbox = QtWidgets.QDoubleSpinBox()
  786. spinbox.setRange(0, 1)
  787. spinbox.setDecimals(3)
  788. spinbox.setSingleStep(0.005)
  789. spinbox.setKeyboardTracking(False)
  790. spinbox.valueChanged.connect(self._on_value_changed)
  791. inner.addRow(name, spinbox)
  792. layout.addStretch(1)
  793. for name, method in buttons:
  794. button = QtWidgets.QPushButton(name)
  795. # Don't trigger on <enter>, which is used to input values.
  796. button.setAutoDefault(False)
  797. button.clicked.connect(method)
  798. layout.addWidget(button)
  799. if name == "Close":
  800. button.setFocus()
  801. self._figure = targetfig
  802. self._defaults = {}
  803. self._export_values_dialog = None
  804. self.update_from_current_subplotpars()
  805. def update_from_current_subplotpars(self):
  806. self._defaults = {spinbox: getattr(self._figure.subplotpars, name)
  807. for name, spinbox in self._spinboxes.items()}
  808. self._reset() # Set spinbox current values without triggering signals.
  809. def _export_values(self):
  810. # Explicitly round to 3 decimals (which is also the spinbox precision)
  811. # to avoid numbers of the form 0.100...001.
  812. self._export_values_dialog = QtWidgets.QDialog()
  813. layout = QtWidgets.QVBoxLayout()
  814. self._export_values_dialog.setLayout(layout)
  815. text = QtWidgets.QPlainTextEdit()
  816. text.setReadOnly(True)
  817. layout.addWidget(text)
  818. text.setPlainText(
  819. ",\n".join(f"{attr}={spinbox.value():.3}"
  820. for attr, spinbox in self._spinboxes.items()))
  821. # Adjust the height of the text widget to fit the whole text, plus
  822. # some padding.
  823. size = text.maximumSize()
  824. size.setHeight(
  825. QtGui.QFontMetrics(text.document().defaultFont())
  826. .size(0, text.toPlainText()).height() + 20)
  827. text.setMaximumSize(size)
  828. self._export_values_dialog.show()
  829. def _on_value_changed(self):
  830. spinboxes = self._spinboxes
  831. # Set all mins and maxes, so that this can also be used in _reset().
  832. for lower, higher in [("bottom", "top"), ("left", "right")]:
  833. spinboxes[higher].setMinimum(spinboxes[lower].value() + .001)
  834. spinboxes[lower].setMaximum(spinboxes[higher].value() - .001)
  835. self._figure.subplots_adjust(
  836. **{attr: spinbox.value() for attr, spinbox in spinboxes.items()})
  837. self._figure.canvas.draw_idle()
  838. def _tight_layout(self):
  839. self._figure.tight_layout()
  840. for attr, spinbox in self._spinboxes.items():
  841. spinbox.blockSignals(True)
  842. spinbox.setValue(getattr(self._figure.subplotpars, attr))
  843. spinbox.blockSignals(False)
  844. self._figure.canvas.draw_idle()
  845. def _reset(self):
  846. for spinbox, value in self._defaults.items():
  847. spinbox.setRange(0, 1)
  848. spinbox.blockSignals(True)
  849. spinbox.setValue(value)
  850. spinbox.blockSignals(False)
  851. self._on_value_changed()
  852. class ToolbarQt(ToolContainerBase, QtWidgets.QToolBar):
  853. def __init__(self, toolmanager, parent=None):
  854. ToolContainerBase.__init__(self, toolmanager)
  855. QtWidgets.QToolBar.__init__(self, parent)
  856. self.setAllowedAreas(QtCore.Qt.ToolBarArea(
  857. _to_int(QtCore.Qt.ToolBarArea.TopToolBarArea) |
  858. _to_int(QtCore.Qt.ToolBarArea.BottomToolBarArea)))
  859. message_label = QtWidgets.QLabel("")
  860. message_label.setAlignment(QtCore.Qt.AlignmentFlag(
  861. _to_int(QtCore.Qt.AlignmentFlag.AlignRight) |
  862. _to_int(QtCore.Qt.AlignmentFlag.AlignVCenter)))
  863. message_label.setSizePolicy(QtWidgets.QSizePolicy(
  864. QtWidgets.QSizePolicy.Policy.Expanding,
  865. QtWidgets.QSizePolicy.Policy.Ignored,
  866. ))
  867. self._message_action = self.addWidget(message_label)
  868. self._toolitems = {}
  869. self._groups = {}
  870. def add_toolitem(
  871. self, name, group, position, image_file, description, toggle):
  872. button = QtWidgets.QToolButton(self)
  873. if image_file:
  874. button.setIcon(NavigationToolbar2QT._icon(self, image_file))
  875. button.setText(name)
  876. if description:
  877. button.setToolTip(description)
  878. def handler():
  879. self.trigger_tool(name)
  880. if toggle:
  881. button.setCheckable(True)
  882. button.toggled.connect(handler)
  883. else:
  884. button.clicked.connect(handler)
  885. self._toolitems.setdefault(name, [])
  886. self._add_to_group(group, name, button, position)
  887. self._toolitems[name].append((button, handler))
  888. def _add_to_group(self, group, name, button, position):
  889. gr = self._groups.get(group, [])
  890. if not gr:
  891. sep = self.insertSeparator(self._message_action)
  892. gr.append(sep)
  893. before = gr[position]
  894. widget = self.insertWidget(before, button)
  895. gr.insert(position, widget)
  896. self._groups[group] = gr
  897. def toggle_toolitem(self, name, toggled):
  898. if name not in self._toolitems:
  899. return
  900. for button, handler in self._toolitems[name]:
  901. button.toggled.disconnect(handler)
  902. button.setChecked(toggled)
  903. button.toggled.connect(handler)
  904. def remove_toolitem(self, name):
  905. for button, handler in self._toolitems.pop(name, []):
  906. button.setParent(None)
  907. def set_message(self, s):
  908. self.widgetForAction(self._message_action).setText(s)
  909. @backend_tools._register_tool_class(FigureCanvasQT)
  910. class ConfigureSubplotsQt(backend_tools.ConfigureSubplotsBase):
  911. def __init__(self, *args, **kwargs):
  912. super().__init__(*args, **kwargs)
  913. self._subplot_dialog = None
  914. def trigger(self, *args):
  915. NavigationToolbar2QT.configure_subplots(self)
  916. @backend_tools._register_tool_class(FigureCanvasQT)
  917. class SaveFigureQt(backend_tools.SaveFigureBase):
  918. def trigger(self, *args):
  919. NavigationToolbar2QT.save_figure(
  920. self._make_classic_style_pseudo_toolbar())
  921. @backend_tools._register_tool_class(FigureCanvasQT)
  922. class RubberbandQt(backend_tools.RubberbandBase):
  923. def draw_rubberband(self, x0, y0, x1, y1):
  924. NavigationToolbar2QT.draw_rubberband(
  925. self._make_classic_style_pseudo_toolbar(), None, x0, y0, x1, y1)
  926. def remove_rubberband(self):
  927. NavigationToolbar2QT.remove_rubberband(
  928. self._make_classic_style_pseudo_toolbar())
  929. @backend_tools._register_tool_class(FigureCanvasQT)
  930. class HelpQt(backend_tools.ToolHelpBase):
  931. def trigger(self, *args):
  932. QtWidgets.QMessageBox.information(None, "Help", self._get_help_html())
  933. @backend_tools._register_tool_class(FigureCanvasQT)
  934. class ToolCopyToClipboardQT(backend_tools.ToolCopyToClipboardBase):
  935. def trigger(self, *args, **kwargs):
  936. pixmap = self.canvas.grab()
  937. QtWidgets.QApplication.instance().clipboard().setPixmap(pixmap)
  938. FigureManagerQT._toolbar2_class = NavigationToolbar2QT
  939. FigureManagerQT._toolmanager_toolbar_class = ToolbarQt
  940. @_Backend.export
  941. class _BackendQT(_Backend):
  942. backend_version = __version__
  943. FigureCanvas = FigureCanvasQT
  944. FigureManager = FigureManagerQT
  945. mainloop = FigureManagerQT.start_main_loop