8000 Merge pull request #19167 from QuLogic/tk-hidpi · matplotlib/matplotlib@86d6a0d · GitHub
[go: up one dir, main page]

Skip to content

Commit 86d6a0d

Browse files
authored
Merge pull request #19167 from QuLogic/tk-hidpi
ENH: Add support for HiDPI in TkAgg on Windows
2 parents cb21d7d + 741ee02 commit 86d6a0d

File tree

6 files changed

+307
-42
lines changed

6 files changed

+307
-42
lines changed

lib/matplotlib/backend_bases.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2107,21 +2107,28 @@ def _set_device_pixel_ratio(self, ratio):
21072107
self._device_pixel_ratio = ratio
21082108
return True
21092109

2110-
def get_width_height(self):
2110+
def get_width_height(self, *, physical=False):
21112111
"""
21122112
Return the figure width and height in integral points or pixels.
21132113
21142114
When the figure is used on High DPI screens (and the backend supports
21152115
it), the truncation to integers occurs after scaling by the device
21162116
pixel ratio.
21172117
2118+
Parameters
2119+
----------
2120+
physical : bool, default: False
2121+
Whether to return true physical pixels or logical pixels. Physical
2122+
pixels may be used by backends that support HiDPI, but still
2123+
configure the canvas using its actual size.
2124+
21182125
Returns
21192126
-------
21202127
width, height : int
21212128
The size of the figure, in points or pixels, depending on the
21222129
backend.
21232130
"""
2124-
return tuple(int(size / self.device_pixel_ratio)
2131+
return tuple(int(size / (1 if physical else self.device_pixel_ratio))
21252132
for size in self.figure.bbox.max)
21262133

21272134
@classmethod

lib/matplotlib/backends/_backend_tk.py

Lines changed: 106 additions & 27 deletions
< 92B1 /tr>
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55
import os.path
66
import sys
77
import tkinter as tk
8-
from tkinter.simpledialog import SimpleDialog
98
import tkinter.filedialog
9+
import tkinter.font
1010
import tkinter.messagebox
11+
from tkinter.simpledialog import SimpleDialog
1112

1213
import numpy as np
14+
from PIL import Image, ImageTk
1315

1416
import matplotlib as mpl
1517
from matplotlib import _api, backend_tools, cbook, _c_internal_utils
@@ -164,10 +166,9 @@ class FigureCanvasTk(FigureCanvasBase):
164166
alternative="get_tk_widget().bind('<Configure>', ..., True)")
165167
def __init__(self, figure=None, master=None, resize_callback=None):
166168
super().__init__(figure)
167-
self._idle = True
168-
self._idle_callback = None
169+
self._idle_draw_id = None
169170
self._event_loop_id = None
170-
w, h = self.figure.bbox.size.astype(int)
171+
w, h = self.get_width_height(physical=True)
171172
self._tkcanvas = tk.Canvas(
172173
master=master, background="white",
173174
width=w, height=h, borderwidth=0, highlightthickness=0)
@@ -176,6 +177,7 @@ def __init__(self, figure=None, master=None, resize_callback=None):
176177
self._tkcanvas.create_image(w//2, h//2, image=self._tkphoto)
177178
self._resize_callback = resize_callback
178179
self._tkcanvas.bind("<Configure>", self.resize)
180+
self._tkcanvas.bind("<Map>", self._update_device_pixel_ratio)
179181
self._tkcanvas.bind("<Key>", self.key_press)
180182
self._tkcanvas.bind("<Motion>", self.motion_notify_event)
181183
self._tkcanvas.bind("<Enter>", self.enter_notify_event)
@@ -210,6 +212,18 @@ def filter_destroy(event):
210212
self._master = master
211213
self._tkcanvas.focus_set()
212214

215+
def _update_device_pixel_ratio(self, event=None):
216+
# Tk gives scaling with respect to 72 DPI, but most (all?) screens are
217+
# scaled vs 96 dpi, and pixel ratio settings are given in whole
218+
# percentages, so round to 2 digits.
219+
ratio = round(self._master.call('tk', 'scaling') / (96 / 72), 2)
220+
if self._set_device_pixel_ratio(ratio):
221+
# The easiest way to resize the canvas is to resize the canvas
222+
# widget itself, since we implement all the logic for resizing the
223+
# canvas backing store on that event.
224+
w, h = self.get_width_height(physical=True)
225+
self._tkcanvas.configure(width=w, height=h)
226+
213227
def resize(self, event):
214228
width, height = event.width, event.height
215229
if self._resize_callback is not None:
@@ -230,18 +244,16 @@ def resize(self, event):
230244

231245
def draw_idle(self):
232246
# docstring inherited
233-
if not self._idle:
247+
if self._idle_draw_id:
234248
return
235249

236-
self._idle = False
237-
238250
def idle_draw(*args):
239251
try:
240252
self.draw()
241253
finally:
242-
self._idle = True
254+
self._idle_draw_id = None
243255

244-
self._idle_callback = self._tkcanvas.after_idle(idle_draw)
256+
self._idle_draw_id = self._tkcanvas.after_idle(idle_draw)
245257

246258
def get_tk_widget(self):
247259
"""
@@ -407,6 +419,16 @@ def __init__(self, canvas, num, window):
407419
if self.toolbar:
408420
backend_tools.add_tools_to_container(self.toolbar)
409421

422+
# If the window has per-monitor DPI awareness, then setup a Tk variable
423+
# to store the DPI, which will be updated by the C code, and the trace
424+
# will handle it on the Python side.
425+
window_frame = int(window.wm_frame(), 16)
426+
window_dpi = tk.IntVar(master=window, value=96,
427+
name=f'window_dpi{window_frame}')
428+
if _tkagg.enable_dpi_awareness(window_frame, window.tk.interpaddr()):
429+
self._window_dpi = window_dpi # Prevent garbage collection.
430+
window_dpi.trace_add('write', self._update_window_dpi)
431+
410432
self._shown = False
411433

412434
def _get_toolbar(self):
@@ -418,6 +440,13 @@ def _get_toolbar(self):
418440
toolbar = None
419441
return toolbar
420442

443+
def _update_window_dpi(self, *args):
444+
newdpi = self._window_dpi.get()
445+
self.window.call('tk', 'scaling', newdpi / 72)
446+
if self.toolbar and hasattr(self.toolbar, '_rescale'):
447+
self.toolbar._rescale()
448+
self.canvas._update_device_pixel_ratio()
449+
421450
def resize(self, width, height):
422451
max_size = 1_400_000 # the measured max on xorg 1.20.8 was 1_409_023
423452

@@ -447,8 +476,8 @@ def destroy(*args):
447476
self._shown = True
448477

449478
def destroy(self, *args):
450-
if self.canvas._idle_callback:
451-
self.canvas._tkcanvas.after_cancel(self.canvas._idle_callback)
479+
if self.canvas._idle_draw_id:
480+
self.canvas._tkcanvas.after_cancel(self.canvas._idle_draw_id)
452481
if self.canvas._event_loop_id:
453482
self.canvas._tkcanvas.after_cancel(self.canvas._event_loop_id)
454483

@@ -514,22 +543,52 @@ def __init__(self, canvas, window, *, pack_toolbar=True):
514543
if tooltip_text is not None:
515544
ToolTip.createToolTip(button, tooltip_text)
516545

546+
self._label_font = tkinter.font.Font(size=10)
547+
517548
# This filler item ensures the toolbar is always at least two text
518549
# lines high. Otherwise the canvas gets redrawn as the mouse hovers
519550
# over images because those use two-line messages which resize the
520551
# toolbar.
521-
label = tk.Label(master=self,
552+
label = tk.Label(master=self, font=self._label_font,
522553
text='\N{NO-BREAK SPACE}\n\N{NO-BREAK SPACE}')
523554
label.pack(side=tk.RIGHT)
524555

525556
self.message = tk.StringVar(master=self)
526-
self._message_label = tk.Label(master=self, textvariable=self.message)
557+
self._message_label = tk.Label(master=self, font=self._label_font,
558+
textvariable=self.message)
527559
self._message_label.pack(side=tk.RIGHT)
528560

529561
NavigationToolbar2.__init__(self, canvas)
530562
if pack_toolbar:
531563
self.pack(side=tk.BOTTOM, fill=tk.X)
532564

565+
def _rescale(self):
566+
"""
567+
Scale all children of the toolbar to current DPI setting.
568+
569+
Before this is called, the Tk scaling setting will have been updated to
570+
match the new DPI. Tk widgets do not update for changes to scaling, but
571+
all measurements made after the change will match the new scaling. Thus
572+
this function re-applies all the same sizes in points, which Tk will
573+
scale correctly to pixels.
574+
"""
575+
for widget in self.winfo_children():
576+
if isinstance(widget, (tk.Button, tk.Checkbutton)):
577+
if hasattr(widget, '_image_file'):
578+
# Explicit class because ToolbarTk calls _rescale.
579+
NavigationToolbar2Tk._set_image_for_button(self, widget)
580+
else:
581+
# Text-only button is handled by the font setting instead.
582+
pass
583+
elif isinstance(widget, tk.Frame):
584+
widget.configure(height='22p', pady='1p')
585+
widget.pack_configure(padx='4p')
586+
elif isinstance(widget, tk.Label):
587+
pass # Text is handled by the font setting instead.
588+
else:
589+
_log.warning('Unknown child class %s', widget.winfo_class)
590+
self._label_font.configure(size=10)
591+
533592
def _update_buttons_checked(self):
534593
# sync button checkstates to match active mode
535594
for text, mode in [('Zoom', _Mode.ZOOM), ('Pan', _Mode.PAN)]:
@@ -571,15 +630,25 @@ def set_cursor(self, cursor):
571630
except tkinter.TclError:
572631
pass
573632

633+
def _set_image_for_button(self, button):
634+
"""
635+
Set the image for a button based on its pixel size.
636+
637+
The pixel size is determined by the DPI scaling of the window.
638+
"""
639+
if button._image_file is None:
640+
return
641+
642+
size = button.winfo_pixels('18p')
643+
with Image.open(button._image_file.replace('.png', '_large.png')
644+
if size > 24 else button._image_file) as im:
645+
image = ImageTk.PhotoImage(im.resize((size, size)), master=self)
646+
button.configure(image=image, height='18p', width='18p')
647+
button._ntimage = image # Prevent garbage collection.
648+
574649
def _Button(self, text, image_file, toggle, command):
575-
if tk.TkVersion >= 8.6:
576-
PhotoImage = tk.PhotoImage
577-
else:
578-
from PIL.ImageTk import PhotoImage
579-
image = (PhotoImage(master=self, file=image_file)
580-
if image_file is not None else None)
581650
if not toggle:
582-
b = tk.Button(master=self, text=text, image=image, command=command)
651+
b = tk.Button(master=self, text=text, command=command)
583652
else:
584653
# There is a bug in tkinter included in some python 3.6 versions
585654
# that without this variable, produces a "visual" toggling of
@@ -588,18 +657,22 @@ def _Button(self, text, image_file, toggle, command):
588657
# https://bugs.python.org/issue25684
589658
var = tk.IntVar(master=self)
590659
b = tk.Checkbutton(
591-
master=self, text=text, image=image, command=command,
660+
master=self, text=text, command=command,
592661
indicatoron=False, variable=var)
593662
b.var = var
594-
b._ntimage = image
663+
b._image_file = image_file
664+
if image_file is not None:
665+
# Explicit class because ToolbarTk calls _Button.
666+
NavigationToolbar2Tk._set_image_for_button(self, b)
667+
else:
668+
b.configure(font=self._label_font)
595669
b.pack(side=tk.LEFT)
596670
return b
597671

598672
def _Spacer(self):
599-
# Buttons are 30px high. Make this 26px tall +2px padding to center it.
600-
s = tk.Frame(
601-
master=self, height=26, relief=tk.RIDGE, pady=2, bg="DarkGray")
602-
s.pack(side=tk.LEFT, padx=5)
673+
# Buttons are also 18pt high.
674+
s = tk.Frame(master=self, height='18p', relief=tk.RIDGE, bg='DarkGray')
675+
s.pack(side=tk.LEFT, padx='3p')
603676
return s
604677

605678
def save_figure(self, *args):
@@ -734,13 +807,18 @@ def __init__(self, toolmanager, window):
734807
tk.Frame.__init__(self, master=window,
735808
width=int(width), height=int(height),
736809
borderwidth=2)
810+
self._label_font = tkinter.font.Font(size=10)
737811
self._message = tk.StringVar(master=self)
738-
self._message_label = tk.Label(master=self, textvariable=self._message)
812+
self._message_label = tk.Label(master=self, font=self._label_font,
813+
textvariable=self._message)
739814
self._message_label.pack(side=tk.RIGHT)
740815
self._toolitems = {}
741816
self.pack(side=tk.TOP, fill=tk.X)
742817
self._groups = {}
743818

819+
def _rescale(self):
820+
return NavigationToolbar2Tk._rescale(self)
821+
744822
def add_toolitem(
745823
self, name, group, position, image_file, description, toggle):
746824
frame = self._get_groupframe(group)
@@ -847,6 +925,7 @@ def new_figure_manager_given_figure(cls, num, figure):
847925
with _restore_foreground_window_at_end():
848926
if cbook._get_running_interactive_framework() is None:
849927
cbook._setup_new_guiapp()
928+
_c_internal_utils.Win32_SetProcessDpiAwareness_max()
850929
window = tk.Tk(className="matplotlib")
851930
window.withdraw()
852931

setupext.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -444,8 +444,8 @@ def get_extensions(self):
444444
],
445445
include_dirs=["src"],
446446
# psapi library needed for finding Tcl/Tk at run time.
447-
libraries=({"linux": ["dl"], "win32": ["psapi"],
448-
"cygwin": ["psapi"]}.get(sys.platform, [])),
447+
libraries={"linux": ["dl"], "win32": ["comctl32", "psapi"],
448+
"cygwin": ["comctl32", "psapi"]}.get(sys.platform, []),
449449
extra_link_args={"win32": ["-mwindows"]}.get(sys.platform, []))
450450
add_numpy_flags(ext)
451451
add_libagg_flags(ext)

src/_c_internal_utils.c

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,47 @@ mpl_SetForegroundWindow(PyObject* module, PyObject *arg)
124124
#endif
125125
}
126126

127+
static PyObject*
128+
mpl_SetProcessDpiAwareness_max(PyObject* module)
129+
{
130+
#ifdef _WIN32
131+
#ifdef _DPI_AWARENESS_CONTEXTS_
132+
// These functions and options were added in later Windows 10 updates, so
133+
// must be loaded dynamically.
134+
typedef BOOL (WINAPI *IsValidDpiAwarenessContext_t)(DPI_AWARENESS_CONTEXT);
135+
typedef BOOL (WINAPI *SetProcessDpiAwarenessContext_t)(DPI_AWARENESS_CONTEXT);
136+
137+
HMODULE user32 = LoadLibrary("user32.dll");
138+
IsValidDpiAwarenessContext_t IsValidDpiAwarenessContextPtr =
139+
(IsValidDpiAwarenessContext_t)GetProcAddress(
140+
user32, "IsValidDpiAwarenessContext");
141+
SetProcessDpiAwarenessContext_t SetProcessDpiAwarenessContextPtr =
142+
(SetProcessDpiAwarenessContext_t)GetProcAddress(
143+
user32, "SetProcessDpiAwarenessContext");
144+
if (IsValidDpiAwarenessContextPtr != NULL && SetProcessDpiAwarenessContextPtr != NULL) {
145+
if (IsValidDpiAwarenessContextPtr(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)) {
146+
// Added in Creators Update of Windows 10.
147+
SetProcessDpiAwarenessContextPtr(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
148+
} else if (IsValidDpiAwarenessContextPtr(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE)) {
149+
// Added in Windows 10.
150+
SetProcessDpiAwarenessContextPtr(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE);
151+
} else if (IsValidDpiAwarenessContextPtr(DPI_AWARENESS_CONTEXT_SYSTEM_AWARE)) {
152+
// Added in Windows 10.
153+
SetProcessDpiAwarenessContextPtr(DPI_AWARENESS_CONTEXT_SYSTEM_AWARE);
154+
}
155+
} else {
156+
// Added in Windows Vista.
157+
SetProcessDPIAware();
158+
}
159+
FreeLibrary(user32);
160+
#else
161+
// Added in Windows Vista.
162+
SetProcessDPIAware();
163+
#endif
164+
#endif
165+
Py_RETURN_NONE;
166+
}
167+
127168
static PyMethodDef functions[] = {
128169
{"display_is_valid", (PyCFunction)mpl_display_is_valid, METH_NOARGS,
129170
"display_is_valid()\n--\n\n"
@@ -151,6 +192,11 @@ static PyMethodDef functions[] = {
151192
"Win32_SetForegroundWindow(hwnd, /)\n--\n\n"
152193
"Wrapper for Windows' SetForegroundWindow. On non-Windows platforms, \n"
153194
"a no-op."},
195+
{"Win32_SetProcessDpiAwareness_max",
196+
(PyCFunction)mpl_SetProcessDpiAwareness_max, METH_NOARGS,
197+
"Win32_SetProcessDpiAwareness_max()\n--\n\n"
198+
"Set Windows' process DPI awareness to best option available.\n"
199+
"On non-Windows platforms, does nothing."},
154200
{NULL, NULL}}; // sentinel.
155201
static PyModuleDef util_module = {
156202
PyModuleDef_HEAD_INIT, "_c_internal_utils", "", 0, functions, NULL, NULL, NULL, NULL};

0 commit comments

Comments
 (0)
0