backend_qt.py 38 KB

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