Skip to content

Add HiDPI support in GTK. #20988

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Sep 16, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 39 additions & 26 deletions lib/matplotlib/backends/backend_gtk3.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -108,6 +107,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)
Expand Down Expand Up @@ -138,26 +139,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?
Expand All @@ -173,29 +183,21 @@ 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)
else:
x, y = event.x, event.y

# flipy so y=0 is bottom of canvas
y = self.get_allocation().height - y
x, y = self._mouse_event_coords(event)
FigureCanvasBase.motion_notify_event(self, x, y, guiEvent=event)
return False # finish event propagation?

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()
Expand All @@ -217,10 +219,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)
Expand All @@ -237,7 +250,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

Expand Down Expand Up @@ -326,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()

Expand Down
17 changes: 10 additions & 7 deletions lib/matplotlib/backends/backend_gtk3agg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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):
Expand All @@ -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)
Expand Down
6 changes: 4 additions & 2 deletions lib/matplotlib/backends/backend_gtk3cairo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
59 changes: 44 additions & 15 deletions lib/matplotlib/backends/backend_gtk4.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -65,6 +66,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.
Expand Down Expand Up @@ -108,20 +110,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())

Expand All @@ -136,21 +151,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()

Expand All @@ -171,6 +187,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.
Expand All @@ -184,7 +206,15 @@ def _post_draw(self, widget, ctx):
if self._rubberband_rect is None:
return

x0, y0, w, h = 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

Expand All @@ -200,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()

Expand Down Expand Up @@ -265,8 +295,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()

Expand Down
16 changes: 10 additions & 6 deletions lib/matplotlib/backends/backend_gtk4agg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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):
Expand All @@ -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)
Expand Down
Loading