From ce70a21445f63ffbf4e4b7868478e381b9bc58b1 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Fri, 3 Sep 2021 04:02:56 -0400 Subject: [PATCH 1/4] Add HiDPI support in GTK --- lib/matplotlib/backends/backend_gtk3.py | 60 +++++++++++++------- lib/matplotlib/backends/backend_gtk3agg.py | 17 +++--- lib/matplotlib/backends/backend_gtk3cairo.py | 6 +- lib/matplotlib/backends/backend_gtk4.py | 42 ++++++++++---- lib/matplotlib/backends/backend_gtk4agg.py | 16 ++++-- lib/matplotlib/backends/backend_gtk4cairo.py | 5 +- 6 files changed, 101 insertions(+), 45 deletions(-) diff --git a/lib/matplotlib/backends/backend_gtk3.py b/lib/matplotlib/backends/backend_gtk3.py index a0efcc934ea5..74e0f5e34dcb 100644 --- a/lib/matplotlib/backends/backend_gtk3.py +++ b/lib/matplotlib/backends/backend_gtk3.py @@ -108,6 +108,8 @@ def __init__(self, figure=None): self.connect('button_press_event', self.button_press_event) self.connect('button_release_event', self.button_release_event) self.connect('configure_event', self.configure_event) + self.connect('screen-changed', self._update_device_pixel_ratio) + self.connect('notify::scale-factor', self._update_device_pixel_ratio) self.connect('draw', self.on_draw_event) self.connect('draw', self._post_draw) self.connect('key_press_event', self.key_press_event) @@ -138,26 +140,35 @@ def set_cursor(self, cursor): context = GLib.MainContext.default() context.iteration(True) + def _mouse_event_coords(self, event): + """ + Calculate mouse coordinates in physical pixels. + + GTK use logical pixels, but the figure is scaled to physical pixels for + rendering. Transform to physical pixels so that all of the down-stream + transforms work as expected. + + Also, the origin is different and needs to be corrected. + """ + x = event.x * self.device_pixel_ratio + # flip y so y=0 is bottom of canvas + y = self.figure.bbox.height - event.y * self.device_pixel_ratio + return x, y + def scroll_event(self, widget, event): - x = event.x - # flipy so y=0 is bottom of canvas - y = self.get_allocation().height - event.y + x, y = self._mouse_event_coords(event) step = 1 if event.direction == Gdk.ScrollDirection.UP else -1 FigureCanvasBase.scroll_event(self, x, y, step, guiEvent=event) return False # finish event propagation? def button_press_event(self, widget, event): - x = event.x - # flipy so y=0 is bottom of canvas - y = self.get_allocation().height - event.y + x, y = self._mouse_event_coords(event) FigureCanvasBase.button_press_event( self, x, y, event.button, guiEvent=event) return False # finish event propagation? def button_release_event(self, widget, event): - x = event.x - # flipy so y=0 is bottom of canvas - y = self.get_allocation().height - event.y + x, y = self._mouse_event_coords(event) FigureCanvasBase.button_release_event( self, x, y, event.button, guiEvent=event) return False # finish event propagation? @@ -175,11 +186,12 @@ def key_release_event(self, widget, event): def motion_notify_event(self, widget, event): if event.is_hint: t, x, y, state = event.window.get_device_position(event.device) + # flipy so y=0 is bottom of canvas + x *= self.device_pixel_ratio + y = (self.get_allocation().height - y) * self.device_pixel_ratio else: - x, y = event.x, event.y + x, y = self._mouse_event_coords(event) - # flipy so y=0 is bottom of canvas - y = self.get_allocation().height - y FigureCanvasBase.motion_notify_event(self, x, y, guiEvent=event) return False # finish event propagation? @@ -187,15 +199,13 @@ def leave_notify_event(self, widget, event): FigureCanvasBase.leave_notify_event(self, event) def enter_notify_event(self, widget, event): - x = event.x - # flipy so y=0 is bottom of canvas - y = self.get_allocation().height - event.y + x, y = self._mouse_event_coords(event) FigureCanvasBase.enter_notify_event(self, guiEvent=event, xy=(x, y)) def size_allocate(self, widget, allocation): dpival = self.figure.dpi - winch = allocation.width / dpival - hinch = allocation.height / dpival + winch = allocation.width * self.device_pixel_ratio / dpival + hinch = allocation.height * self.device_pixel_ratio / dpival self.figure.set_size_inches(winch, hinch, forward=False) FigureCanvasBase.resize_event(self) self.draw_idle() @@ -217,10 +227,21 @@ def _get_key(self, event): key = f'{prefix}+{key}' return key + def _update_device_pixel_ratio(self, *args, **kwargs): + # We need to be careful in cases with mixed resolution displays if + # device_pixel_ratio changes. + if self._set_device_pixel_ratio(self.get_scale_factor()): + # The easiest way to resize the canvas is to emit a resize event + # since we implement all the logic for resizing the canvas for that + # event. + self.queue_resize() + self.queue_draw() + def configure_event(self, widget, event): if widget.get_property("window") is None: return - w, h = event.width, event.height + w = event.width * self.device_pixel_ratio + h = event.height * self.device_pixel_ratio if w < 3 or h < 3: return # empty fig # resize the figure (in inches) @@ -237,7 +258,8 @@ def _post_draw(self, widget, ctx): if self._rubberband_rect is None: return - x0, y0, w, h = self._rubberband_rect + x0, y0, w, h = (dim / self.device_pixel_ratio + for dim in self._rubberband_rect) x1 = x0 + w y1 = y0 + h diff --git a/lib/matplotlib/backends/backend_gtk3agg.py b/lib/matplotlib/backends/backend_gtk3agg.py index 9c26e21753ae..14206484e73d 100644 --- a/lib/matplotlib/backends/backend_gtk3agg.py +++ b/lib/matplotlib/backends/backend_gtk3agg.py @@ -18,9 +18,10 @@ def __init__(self, figure): self._bbox_queue = [] def on_draw_event(self, widget, ctx): - """GtkDrawable draw event, like expose_event in GTK 2.X.""" + scale = self.device_pixel_ratio allocation = self.get_allocation() - w, h = allocation.width, allocation.height + w = allocation.width * scale + h = allocation.height * scale if not len(self._bbox_queue): Gtk.render_background( @@ -43,7 +44,8 @@ def on_draw_event(self, widget, ctx): np.asarray(self.copy_from_bbox(bbox))) image = cairo.ImageSurface.create_for_data( buf.ravel().data, cairo.FORMAT_ARGB32, width, height) - ctx.set_source_surface(image, x, y) + image.set_device_scale(scale, scale) + ctx.set_source_surface(image, x / scale, y / scale) ctx.paint() if len(self._bbox_queue): @@ -57,11 +59,12 @@ def blit(self, bbox=None): if bbox is None: bbox = self.figure.bbox + scale = self.device_pixel_ratio allocation = self.get_allocation() - x = int(bbox.x0) - y = allocation.height - int(bbox.y1) - width = int(bbox.x1) - int(bbox.x0) - height = int(bbox.y1) - int(bbox.y0) + x = int(bbox.x0 / scale) + y = allocation.height - int(bbox.y1 / scale) + width = (int(bbox.x1) - int(bbox.x0)) // scale + height = (int(bbox.y1) - int(bbox.y0)) // scale self._bbox_queue.append(bbox) self.queue_draw_area(x, y, width, height) diff --git a/lib/matplotlib/backends/backend_gtk3cairo.py b/lib/matplotlib/backends/backend_gtk3cairo.py index 9759e15adac8..2e8558a66b2c 100644 --- a/lib/matplotlib/backends/backend_gtk3cairo.py +++ b/lib/matplotlib/backends/backend_gtk3cairo.py @@ -17,17 +17,19 @@ def __init__(self, figure): self._renderer = RendererGTK3Cairo(self.figure.dpi) def on_draw_event(self, widget, ctx): - """GtkDrawable draw event.""" with (self.toolbar._wait_cursor_for_draw_cm() if self.toolbar else nullcontext()): self._renderer.set_context(ctx) + scale = self.device_pixel_ratio + # Scale physical drawing to logical size. + ctx.scale(1 / scale, 1 / scale) allocation = self.get_allocation() Gtk.render_background( self.get_style_context(), ctx, allocation.x, allocation.y, allocation.width, allocation.height) self._renderer.set_width_height( - allocation.width, allocation.height) + allocation.width * scale, allocation.height * scale) self._renderer.dpi = self.figure.dpi self.figure.draw(self._renderer) diff --git a/lib/matplotlib/backends/backend_gtk4.py b/lib/matplotlib/backends/backend_gtk4.py index 4dde8f627444..e81736d93f60 100644 --- a/lib/matplotlib/backends/backend_gtk4.py +++ b/lib/matplotlib/backends/backend_gtk4.py @@ -65,6 +65,7 @@ def __init__(self, figure=None): self.set_draw_func(self._draw_func) self.connect('resize', self.resize_event) + self.connect('notify::scale-factor', self._update_device_pixel_ratio) click = Gtk.GestureClick() click.set_button(0) # All buttons. @@ -108,20 +109,33 @@ def set_cursor(self, cursor): # docstring inherited self.set_cursor_from_name(_mpl_to_gtk_cursor(cursor)) + def _mouse_event_coords(self, x, y): + """ + Calculate mouse coordinates in physical pixels. + + GTK use logical pixels, but the figure is scaled to physical pixels for + rendering. Transform to physical pixels so that all of the down-stream + transforms work as expected. + + Also, the origin is different and needs to be corrected. + """ + x = x * self.device_pixel_ratio + # flip y so y=0 is bottom of canvas + y = self.figure.bbox.height - y * self.device_pixel_ratio + return x, y + def scroll_event(self, controller, dx, dy): FigureCanvasBase.scroll_event(self, 0, 0, dy) return True def button_press_event(self, controller, n_press, x, y): - # flipy so y=0 is bottom of canvas - y = self.get_allocation().height - y + x, y = self._mouse_event_coords(x, y) FigureCanvasBase.button_press_event(self, x, y, controller.get_current_button()) self.grab_focus() def button_release_event(self, controller, n_press, x, y): - # flipy so y=0 is bottom of canvas - y = self.get_allocation().height - y + x, y = self._mouse_event_coords(x, y) FigureCanvasBase.button_release_event(self, x, y, controller.get_current_button()) @@ -136,21 +150,22 @@ def key_release_event(self, controller, keyval, keycode, state): return True def motion_notify_event(self, controller, x, y): - # flipy so y=0 is bottom of canvas - y = self.get_allocation().height - y + x, y = self._mouse_event_coords(x, y) FigureCanvasBase.motion_notify_event(self, x, y) def leave_notify_event(self, controller): FigureCanvasBase.leave_notify_event(self) def enter_notify_event(self, controller, x, y): - # flipy so y=0 is bottom of canvas - y = self.get_allocation().height - y + x, y = self._mouse_event_coords(x, y) FigureCanvasBase.enter_notify_event(self, xy=(x, y)) def resize_event(self, area, width, height): + self._update_device_pixel_ratio() dpi = self.figure.dpi - self.figure.set_size_inches(width / dpi, height / dpi, forward=False) + winch = width * self.device_pixel_ratio / dpi + hinch = height * self.device_pixel_ratio / dpi + self.figure.set_size_inches(winch, hinch, forward=False) FigureCanvasBase.resize_event(self) self.draw_idle() @@ -171,6 +186,12 @@ def _get_key(self, keyval, keycode, state): key = f'{prefix}+{key}' return key + def _update_device_pixel_ratio(self, *args, **kwargs): + # We need to be careful in cases with mixed resolution displays if + # device_pixel_ratio changes. + if self._set_device_pixel_ratio(self.get_scale_factor()): + self.draw() + def _draw_rubberband(self, rect): self._rubberband_rect = rect # TODO: Only update the rubberband area. @@ -184,7 +205,8 @@ def _post_draw(self, widget, ctx): if self._rubberband_rect is None: return - x0, y0, w, h = self._rubberband_rect + x0, y0, w, h = (dim / self.device_pixel_ratio + for dim in self._rubberband_rect) x1 = x0 + w y1 = y0 + h diff --git a/lib/matplotlib/backends/backend_gtk4agg.py b/lib/matplotlib/backends/backend_gtk4agg.py index b3439dc109cd..d47dd07fee3b 100644 --- a/lib/matplotlib/backends/backend_gtk4agg.py +++ b/lib/matplotlib/backends/backend_gtk4agg.py @@ -18,8 +18,10 @@ def __init__(self, figure): self._bbox_queue = [] def on_draw_event(self, widget, ctx): + scale = self.device_pixel_ratio allocation = self.get_allocation() - w, h = allocation.width, allocation.height + w = allocation.width * scale + h = allocation.height * scale if not len(self._bbox_queue): Gtk.render_background( @@ -42,7 +44,8 @@ def on_draw_event(self, widget, ctx): np.asarray(self.copy_from_bbox(bbox))) image = cairo.ImageSurface.create_for_data( buf.ravel().data, cairo.FORMAT_ARGB32, width, height) - ctx.set_source_surface(image, x, y) + image.set_device_scale(scale, scale) + ctx.set_source_surface(image, x / scale, y / scale) ctx.paint() if len(self._bbox_queue): @@ -56,11 +59,12 @@ def blit(self, bbox=None): if bbox is None: bbox = self.figure.bbox + scale = self.device_pixel_ratio allocation = self.get_allocation() - x = int(bbox.x0) - y = allocation.height - int(bbox.y1) - width = int(bbox.x1) - int(bbox.x0) - height = int(bbox.y1) - int(bbox.y0) + x = int(bbox.x0 / scale) + y = allocation.height - int(bbox.y1 / scale) + width = (int(bbox.x1) - int(bbox.x0)) // scale + height = (int(bbox.y1) - int(bbox.y0)) // scale self._bbox_queue.append(bbox) self.queue_draw_area(x, y, width, height) diff --git a/lib/matplotlib/backends/backend_gtk4cairo.py b/lib/matplotlib/backends/backend_gtk4cairo.py index 53d319392727..3aaf3ebedfcd 100644 --- a/lib/matplotlib/backends/backend_gtk4cairo.py +++ b/lib/matplotlib/backends/backend_gtk4cairo.py @@ -20,13 +20,16 @@ def on_draw_event(self, widget, ctx): with (self.toolbar._wait_cursor_for_draw_cm() if self.toolbar else nullcontext()): self._renderer.set_context(ctx) + scale = self.device_pixel_ratio + # Scale physical drawing to logical size. + ctx.scale(1 / scale, 1 / scale) allocation = self.get_allocation() Gtk.render_background( self.get_style_context(), ctx, allocation.x, allocation.y, allocation.width, allocation.height) self._renderer.set_width_height( - allocation.width, allocation.height) + allocation.width * scale, allocation.height * scale) self._renderer.dpi = self.figure.dpi self.figure.draw(self._renderer) From 0c0fc47bb8be2ee5a245f211c645891a237a0d0c Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Tue, 7 Sep 2021 20:23:39 -0400 Subject: [PATCH 2/4] gtk3: Remove POINTER_MOTION_HINT_MASK usage entirely It is deprecated since GTK 3.8, and event compression occurs in other ways which don't seem relevant to us. --- lib/matplotlib/backends/backend_gtk3.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/lib/matplotlib/backends/backend_gtk3.py b/lib/matplotlib/backends/backend_gtk3.py index 74e0f5e34dcb..0dadc276f40e 100644 --- a/lib/matplotlib/backends/backend_gtk3.py +++ b/lib/matplotlib/backends/backend_gtk3.py @@ -93,7 +93,6 @@ class FigureCanvasGTK3(Gtk.DrawingArea, FigureCanvasBase): | Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK | Gdk.EventMask.POINTER_MOTION_MASK - | Gdk.EventMask.POINTER_MOTION_HINT_MASK | Gdk.EventMask.SCROLL_MASK) def __init__(self, figure=None): @@ -184,14 +183,7 @@ def key_release_event(self, widget, event): return True # stop event propagation def motion_notify_event(self, widget, event): - if event.is_hint: - t, x, y, state = event.window.get_device_position(event.device) - # flipy so y=0 is bottom of canvas - x *= self.device_pixel_ratio - y = (self.get_allocation().height - y) * self.device_pixel_ratio - else: - x, y = self._mouse_event_coords(event) - + x, y = self._mouse_event_coords(event) FigureCanvasBase.motion_notify_event(self, x, y, guiEvent=event) return False # finish event propagation? From f53c28d84e1f09c0af37df61206820b8d989d27b Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 9 Sep 2021 17:59:12 -0400 Subject: [PATCH 3/4] Calculate initial GTK window size using logical DPI Otherwise, if the pixel ratio is changed before the window is created, the window will be scaled up an extra time. This occurs on GTK3, but not GTK4, due to some different order of events, but I changed the GTK4 backend to do the same for consistency. --- lib/matplotlib/backends/backend_gtk3.py | 3 +-- lib/matplotlib/backends/backend_gtk4.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/backends/backend_gtk3.py b/lib/matplotlib/backends/backend_gtk3.py index 0dadc276f40e..bef9798775b1 100644 --- a/lib/matplotlib/backends/backend_gtk3.py +++ b/lib/matplotlib/backends/backend_gtk3.py @@ -340,8 +340,7 @@ def __init__(self, canvas, num): self.vbox.pack_start(self.canvas, True, True, 0) # calculate size for window - w = int(self.canvas.figure.bbox.width) - h = int(self.canvas.figure.bbox.height) + w, h = self.canvas.get_width_height() self.toolbar = self._get_toolbar() diff --git a/lib/matplotlib/backends/backend_gtk4.py b/lib/matplotlib/backends/backend_gtk4.py index e81736d93f60..0ec58a1e06ef 100644 --- a/lib/matplotlib/backends/backend_gtk4.py +++ b/lib/matplotlib/backends/backend_gtk4.py @@ -287,8 +287,7 @@ def __init__(self, canvas, num): self.vbox.prepend(self.canvas) # calculate size for window - w = int(self.canvas.figure.bbox.width) - h = int(self.canvas.figure.bbox.height) + w, h = self.canvas.get_width_height() self.toolbar = self._get_toolbar() From 76c2c7f1f60085de997516ac1b7d6fff3cf6a3ca Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Wed, 15 Sep 2021 15:36:05 -0400 Subject: [PATCH 4/4] Fix rubberband on Cairo --- lib/matplotlib/backends/backend_gtk4.py | 18 +++++++++++++----- lib/matplotlib/backends/backend_gtk4cairo.py | 1 + 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/backends/backend_gtk4.py b/lib/matplotlib/backends/backend_gtk4.py index 0ec58a1e06ef..30719b1bba0f 100644 --- a/lib/matplotlib/backends/backend_gtk4.py +++ b/lib/matplotlib/backends/backend_gtk4.py @@ -52,6 +52,7 @@ def _mpl_to_gtk_cursor(mpl_cursor): class FigureCanvasGTK4(Gtk.DrawingArea, FigureCanvasBase): required_interactive_framework = "gtk4" _timer_cls = TimerGTK4 + _context_is_scaled = False def __init__(self, figure=None): FigureCanvasBase.__init__(self, figure) @@ -205,8 +206,15 @@ def _post_draw(self, widget, ctx): if self._rubberband_rect is None: return - x0, y0, w, h = (dim / self.device_pixel_ratio - for dim in self._rubberband_rect) + lw = 1 + dash = 3 + if not self._context_is_scaled: + x0, y0, w, h = (dim / self.device_pixel_ratio + for dim in self._rubberband_rect) + else: + x0, y0, w, h = self._rubberband_rect + lw *= self.device_pixel_ratio + dash *= self.device_pixel_ratio x1 = x0 + w y1 = y0 + h @@ -222,12 +230,12 @@ def _post_draw(self, widget, ctx): ctx.line_to(x1, y1) ctx.set_antialias(1) - ctx.set_line_width(1) - ctx.set_dash((3, 3), 0) + ctx.set_line_width(lw) + ctx.set_dash((dash, dash), 0) ctx.set_source_rgb(0, 0, 0) ctx.stroke_preserve() - ctx.set_dash((3, 3), 3) + ctx.set_dash((dash, dash), dash) ctx.set_source_rgb(1, 1, 1) ctx.stroke() diff --git a/lib/matplotlib/backends/backend_gtk4cairo.py b/lib/matplotlib/backends/backend_gtk4cairo.py index 3aaf3ebedfcd..05ddef53bd92 100644 --- a/lib/matplotlib/backends/backend_gtk4cairo.py +++ b/lib/matplotlib/backends/backend_gtk4cairo.py @@ -11,6 +11,7 @@ def set_context(self, ctx): class FigureCanvasGTK4Cairo(backend_gtk4.FigureCanvasGTK4, backend_cairo.FigureCanvasCairo): + _context_is_scaled = True def __init__(self, figure): super().__init__(figure)