diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 5af60bddf9c9..44da34ef06f9 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -1729,6 +1729,9 @@ def __init__(self, figure=None): self.mouse_grabber = None # the axes currently grabbing mouse self.toolbar = None # NavigationToolbar2 will set me self._is_idle_drawing = False + # We don't want to scale up the figure DPI more than once. + figure._original_dpi = figure.dpi + self._device_pixel_ratio = 1 callbacks = property(lambda self: self.figure._canvas_callbacks) button_pick_id = property(lambda self: self.figure._button_pick_id) @@ -2054,12 +2057,73 @@ def draw_idle(self, *args, **kwargs): with self._idle_draw_cntx(): self.draw(*args, **kwargs) + @property + def device_pixel_ratio(self): + """ + The ratio of physical to logical pixels used for the canvas on screen. + + By default, this is 1, meaning physical and logical pixels are the same + size. Subclasses that support High DPI screens may set this property to + indicate that said ratio is different. All Matplotlib interaction, + unless working directly with the canvas, remains in logical pixels. + + """ + return self._device_pixel_ratio + + def _set_device_pixel_ratio(self, ratio): + """ + Set the ratio of physical to logical pixels used for the canvas. + + Subclasses that support High DPI screens can set this property to + indicate that said ratio is different. The canvas itself will be + created at the physical size, while the client side will use the + logical size. Thus the DPI of the Figure will change to be scaled by + this ratio. Implementations that support High DPI screens should use + physical pixels for events so that transforms back to Axes space are + correct. + + By default, this is 1, meaning physical and logical pixels are the same + size. + + Parameters + ---------- + ratio : float + The ratio of logical to physical pixels used for the canvas. + + Returns + ------- + bool + Whether the ratio has changed. Backends may interpret this as a + signal to resize the window, repaint the canvas, or change any + other relevant properties. + """ + if self._device_pixel_ratio == ratio: + return False + # In cases with mixed resolution displays, we need to be careful if the + # device pixel ratio changes - in this case we need to resize the + # canvas accordingly. Some backends provide events that indicate a + # change in DPI, but those that don't will update this before drawing. + dpi = ratio * self.figure._original_dpi + self.figure._set_dpi(dpi, forward=False) + self._device_pixel_ratio = ratio + return True + def get_width_height(self): """ - Return the figure width and height in points or pixels - (depending on the backend), truncated to integers. + Return the figure width and height in integral points or pixels. + + When the figure is used on High DPI screens (and the backend supports + it), the truncation to integers occurs after scaling by the device + pixel ratio. + + Returns + ------- + width, height : int + The size of the figure, in points or pixels, depending on the + backend. """ - return int(self.figure.bbox.width), int(self.figure.bbox.height) + return tuple(int(size / self.device_pixel_ratio) + for size in self.figure.bbox.max) @classmethod def get_supported_filetypes(cls): diff --git a/lib/matplotlib/backends/backend_qt5.py b/lib/matplotlib/backends/backend_qt5.py index 0c2d32e1e8d1..2d4e6aa62cb2 100644 --- a/lib/matplotlib/backends/backend_qt5.py +++ b/lib/matplotlib/backends/backend_qt5.py @@ -213,15 +213,6 @@ def __init__(self, figure=None): _create_qApp() super().__init__(figure=figure) - # We don't want to scale up the figure DPI more than once. - # Note, we don't handle a signal for changing DPI yet. - self.figure._original_dpi = self.figure.dpi - self._update_figure_dpi() - # In cases with mixed resolution displays, we need to be careful if the - # dpi_ratio changes - in this case we need to resize the canvas - # accordingly. - self._dpi_ratio_prev = self._dpi_ratio - self._draw_pending = False self._is_drawing = False self._draw_rect_callback = lambda painter: None @@ -233,28 +224,13 @@ def __init__(self, figure=None): palette = QtGui.QPalette(QtCore.Qt.white) self.setPalette(palette) - def _update_figure_dpi(self): - dpi = self._dpi_ratio * self.figure._original_dpi - self.figure._set_dpi(dpi, forward=False) - - @property - def _dpi_ratio(self): - return _devicePixelRatioF(self) - def _update_pixel_ratio(self): - # We need to be careful in cases with mixed resolution displays if - # dpi_ratio changes. - if self._dpi_ratio != self._dpi_ratio_prev: - # We need to update the figure DPI. - self._update_figure_dpi() - self._dpi_ratio_prev = self._dpi_ratio + if self._set_device_pixel_ratio(_devicePixelRatioF(self)): # The easiest way to resize the canvas is to emit a resizeEvent # since we implement all the logic for resizing the canvas for # that event. event = QtGui.QResizeEvent(self.size(), self.size()) self.resizeEvent(event) - # resizeEvent triggers a paintEvent itself, so we exit this one - # (after making sure that the event is immediately handled). def _update_screen(self, screen): # Handler for changes to a window's attached screen. @@ -270,10 +246,6 @@ def showEvent(self, event): window.screenChanged.connect(self._update_screen) self._update_screen(window.screen()) - def get_width_height(self): - w, h = FigureCanvasBase.get_width_height(self) - return int(w / self._dpi_ratio), int(h / self._dpi_ratio) - def enterEvent(self, event): try: x, y = self.mouseEventCoords(event.pos()) @@ -296,11 +268,10 @@ def mouseEventCoords(self, pos): Also, the origin is different and needs to be corrected. """ - dpi_ratio = self._dpi_ratio x = pos.x() # flip y so y=0 is bottom of canvas - y = self.figure.bbox.height / dpi_ratio - pos.y() - return x * dpi_ratio, y * dpi_ratio + y = self.figure.bbox.height / self.device_pixel_ratio - pos.y() + return x * self.device_pixel_ratio, y * self.device_pixel_ratio def mousePressEvent(self, event): x, y = self.mouseEventCoords(event.pos()) @@ -361,8 +332,8 @@ def keyReleaseEvent(self, event): FigureCanvasBase.key_release_event(self, key, guiEvent=event) def resizeEvent(self, event): - w = event.size().width() * self._dpi_ratio - h = event.size().height() * self._dpi_ratio + w = event.size().width() * self.device_pixel_ratio + h = event.size().height() * self.device_pixel_ratio dpival = self.figure.dpi winch = w / dpival hinch = h / dpival @@ -460,7 +431,7 @@ def blit(self, bbox=None): if bbox is None and self.figure: bbox = self.figure.bbox # Blit the entire canvas if bbox is None. # repaint uses logical pixels, not physical pixels like the renderer. - l, b, w, h = [int(pt / self._dpi_ratio) for pt in bbox.bounds] + l, b, w, h = [int(pt / self.device_pixel_ratio) for pt in bbox.bounds] t = b + h self.repaint(l, self.rect().height() - t, w, h) @@ -481,11 +452,11 @@ def drawRectangle(self, rect): # Draw the zoom rectangle to the QPainter. _draw_rect_callback needs # to be called at the end of paintEvent. if rect is not None: - x0, y0, w, h = [int(pt / self._dpi_ratio) for pt in rect] + x0, y0, w, h = [int(pt / self.device_pixel_ratio) for pt in rect] x1 = x0 + w y1 = y0 + h def _draw_rect_callback(painter): - pen = QtGui.QPen(QtCore.Qt.black, 1 / self._dpi_ratio) + pen = QtGui.QPen(QtCore.Qt.black, 1 / self.device_pixel_ratio) pen.setDashPattern([3, 3]) for color, offset in [ (QtCore.Qt.black, 0), (QtCore.Qt.white, 3)]: diff --git a/lib/matplotlib/backends/backend_qt5agg.py b/lib/matplotlib/backends/backend_qt5agg.py index 897c28c38f04..3c5de72f7697 100644 --- a/lib/matplotlib/backends/backend_qt5agg.py +++ b/lib/matplotlib/backends/backend_qt5agg.py @@ -42,8 +42,8 @@ def paintEvent(self, event): # scale rect dimensions using the screen dpi ratio to get # correct values for the Figure coordinates (rather than # QT5's coords) - width = rect.width() * self._dpi_ratio - height = rect.height() * self._dpi_ratio + width = rect.width() * self.device_pixel_ratio + height = rect.height() * self.device_pixel_ratio left, top = self.mouseEventCoords(rect.topLeft()) # shift the "top" by the height of the image to get the # correct corner for our coordinate system @@ -61,7 +61,7 @@ def paintEvent(self, event): qimage = QtGui.QImage(buf, buf.shape[1], buf.shape[0], QtGui.QImage.Format_ARGB32_Premultiplied) - _setDevicePixelRatio(qimage, self._dpi_ratio) + _setDevicePixelRatio(qimage, self.device_pixel_ratio) # set origin using original QT coordinates origin = QtCore.QPoint(rect.left(), rect.top()) painter.drawImage(origin, qimage) diff --git a/lib/matplotlib/backends/backend_qt5cairo.py b/lib/matplotlib/backends/backend_qt5cairo.py index 4b6d7305e7c1..e15e0d858ad8 100644 --- a/lib/matplotlib/backends/backend_qt5cairo.py +++ b/lib/matplotlib/backends/backend_qt5cairo.py @@ -17,9 +17,8 @@ def draw(self): super().draw() def paintEvent(self, event): - dpi_ratio = self._dpi_ratio - width = int(dpi_ratio * self.width()) - height = int(dpi_ratio * self.height()) + width = int(self.device_pixel_ratio * self.width()) + height = int(self.device_pixel_ratio * self.height()) if (width, height) != self._renderer.get_canvas_width_height(): surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height) self._renderer.set_ctx_from_surface(surface) @@ -32,7 +31,7 @@ def paintEvent(self, event): # QImage under PySide on Python 3. if QT_API == 'PySide': ctypes.c_long.from_address(id(buf)).value = 1 - _setDevicePixelRatio(qimage, dpi_ratio) + _setDevicePixelRatio(qimage, self.device_pixel_ratio) painter = QtGui.QPainter(self) painter.eraseRect(event.rect()) painter.drawImage(0, 0, qimage) diff --git a/lib/matplotlib/backends/backend_webagg_core.py b/lib/matplotlib/backends/backend_webagg_core.py index ed0d6173a6fe..dbdd30e1aa50 100644 --- a/lib/matplotlib/backends/backend_webagg_core.py +++ b/lib/matplotlib/backends/backend_webagg_core.py @@ -138,10 +138,6 @@ def __init__(self, *args, **kwargs): # to the connected clients. self._current_image_mode = 'full' - # Store the DPI ratio of the browser. This is the scaling that - # occurs automatically for all images on a HiDPI display. - self._dpi_ratio = 1 - def show(self): # show the figure window from matplotlib.pyplot import show @@ -311,8 +307,8 @@ def handle_refresh(self, event): self.draw_idle() def handle_resize(self, event): - x, y = event.get('width', 800), event.get('height', 800) - x, y = int(x) * self._dpi_ratio, int(y) * self._dpi_ratio + x = int(event.get('width', 800)) * self.device_pixel_ratio + y = int(event.get('height', 800)) * self.device_pixel_ratio fig = self.figure # An attempt at approximating the figure size in pixels. fig.set_size_inches(x / fig.dpi, y / fig.dpi, forward=False) @@ -327,14 +323,15 @@ def handle_send_image_mode(self, event): # The client requests notification of what the current image mode is. self.send_event('image_mode', mode=self._current_image_mode) + def handle_set_device_pixel_ratio(self, event): + self._handle_set_device_pixel_ratio(event.get('device_pixel_ratio', 1)) + def handle_set_dpi_ratio(self, event): - dpi_ratio = event.get('dpi_ratio', 1) - if dpi_ratio != self._dpi_ratio: - # We don't want to scale up the figure dpi more than once. - if not hasattr(self.figure, '_original_dpi'): - self.figure._original_dpi = self.figure.dpi - self.figure.dpi = dpi_ratio * self.figure._original_dpi - self._dpi_ratio = dpi_ratio + # This handler is for backwards-compatibility with older ipympl. + self._handle_set_device_pixel_ratio(event.get('dpi_ratio', 1)) + + def _handle_set_device_pixel_ratio(self, device_pixel_ratio): + if self._set_device_pixel_ratio(device_pixel_ratio): self._force_full = True self.draw_idle() @@ -426,7 +423,8 @@ def _get_toolbar(self, canvas): def resize(self, w, h, forward=True): self._send_event( 'resize', - size=(w / self.canvas._dpi_ratio, h / self.canvas._dpi_ratio), + size=(w / self.canvas.device_pixel_ratio, + h / self.canvas.device_pixel_ratio), forward=forward) def set_window_title(self, title): diff --git a/lib/matplotlib/backends/web_backend/js/mpl.js b/lib/matplotlib/backends/web_backend/js/mpl.js index 4adeb987653f..8b716a6410f6 100644 --- a/lib/matplotlib/backends/web_backend/js/mpl.js +++ b/lib/matplotlib/backends/web_backend/js/mpl.js @@ -63,7 +63,9 @@ mpl.figure = function (figure_id, websocket, ondownload, parent_element) { fig.send_message('supports_binary', { value: fig.supports_binary }); fig.send_message('send_image_mode', {}); if (fig.ratio !== 1) { - fig.send_message('set_dpi_ratio', { dpi_ratio: fig.ratio }); + fig.send_message('set_device_pixel_ratio', { + device_pixel_ratio: fig.ratio, + }); } fig.send_message('refresh', {}); }; diff --git a/lib/matplotlib/tests/test_backend_qt.py b/lib/matplotlib/tests/test_backend_qt.py index d0d997e1ce2e..050781b87151 100644 --- a/lib/matplotlib/tests/test_backend_qt.py +++ b/lib/matplotlib/tests/test_backend_qt.py @@ -166,10 +166,10 @@ def on_key_press(event): @pytest.mark.backend('Qt5Agg', skip_on_importerror=True) -def test_pixel_ratio_change(): +def test_device_pixel_ratio_change(): """ Make sure that if the pixel ratio changes, the figure dpi changes but the - widget remains the same physical size. + widget remains the same logical size. """ prop = 'matplotlib.backends.backend_qt5.FigureCanvasQT.devicePixelRatioF' @@ -180,10 +180,8 @@ def test_pixel_ratio_change(): qt_canvas = fig.canvas qt_canvas.show() - def set_pixel_ratio(ratio): + def set_device_pixel_ratio(ratio): p.return_value = ratio - # Make sure the mocking worked - assert qt_canvas._dpi_ratio == ratio # The value here doesn't matter, as we can't mock the C++ QScreen # object, but can override the functional wrapper around it. @@ -194,43 +192,46 @@ def set_pixel_ratio(ratio): qt_canvas.draw() qt_canvas.flush_events() + # Make sure the mocking worked + assert qt_canvas.device_pixel_ratio == ratio + qt_canvas.manager.show() size = qt_canvas.size() screen = qt_canvas.window().windowHandle().screen() - set_pixel_ratio(3) + set_device_pixel_ratio(3) # The DPI and the renderer width/height change assert fig.dpi == 360 assert qt_canvas.renderer.width == 1800 assert qt_canvas.renderer.height == 720 - # The actual widget size and figure physical size don't change + # The actual widget size and figure logical size don't change. assert size.width() == 600 assert size.height() == 240 assert qt_canvas.get_width_height() == (600, 240) assert (fig.get_size_inches() == (5, 2)).all() - set_pixel_ratio(2) + set_device_pixel_ratio(2) # The DPI and the renderer width/height change assert fig.dpi == 240 assert qt_canvas.renderer.width == 1200 assert qt_canvas.renderer.height == 480 - # The actual widget size and figure physical size don't change + # The actual widget size and figure logical size don't change. assert size.width() == 600 assert size.height() == 240 assert qt_canvas.get_width_height() == (600, 240) assert (fig.get_size_inches() == (5, 2)).all() - set_pixel_ratio(1.5) + set_device_pixel_ratio(1.5) # The DPI and the renderer width/height change assert fig.dpi == 180 assert qt_canvas.renderer.width == 900 assert qt_canvas.renderer.height == 360 - # The actual widget size and figure physical size don't change + # The actual widget size and figure logical size don't change. assert size.width() == 600 assert size.height() == 240 assert qt_canvas.get_width_height() == (600, 240)