8000 Handle DPI changes in TkAgg backend on Windows. · matplotlib/matplotlib@1b9dc95 · GitHub
[go: up one dir, main page]

Skip to content

Commit 1b9dc95

Browse files
committed
Handle DPI changes in TkAgg backend on Windows.
1 parent 8c5b462 commit 1b9dc95

File tree

3 files changed

+170
-9
lines changed

3 files changed

+170
-9
lines changed

lib/matplotlib/backends/_backend_tk.py

Lines changed: 74 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import logging
44
import math
55
import os.path
6+
import queue
67
import sys
78
import tkinter as tk
89
import tkinter.filedialog
@@ -409,6 +410,13 @@ def __init__(self, canvas, num, window):
409410
if self.toolbar:
410411
backend_tools.add_tools_to_container(self.toolbar)
411412

413+
# If the window has per-monitor DPI awareness, then setup a poll to
414+
# check the DPI change queue, to then update the scaling.
415+
dpi_queue = queue.SimpleQueue()
416+
if _tkagg.enable_dpi_awareness(int(window.frame(), 16), dpi_queue):
417+
self._dpi_queue = dpi_queue
418+
window.after(100, self._update_dpi_ratio)
419+
412420
self._shown = False
413421

414422
def _get_toolbar(self):
@@ -420,6 +428,24 @@ def _get_toolbar(self):
420428
toolbar = None
421429
return toolbar
422430

431+
def _update_dpi_ratio(self):
432+
"""
433+
Polling function to update DPI ratio for Tk.
434+
435+
This must continuously check the queue, instead of waiting for a Tk
436+
event, due to thread locking issues in the Python/Tk interface.
437+
"""
438+
try:
439+
newdpi = self._dpi_queue.get(block=False)
440+
except queue.Empty:
441+
self.window.after(100, self._update_dpi_ratio)
442+
return
443+
self.window.call('tk', 'scaling', newdpi / 72)
444+
if self.toolbar and hasattr(self.toolbar, '_rescale'):
445+
self.toolbar._rescale()
446+
self.canvas._update_device_pixel_ratio()
447+
self.window.after(100, self._update_dpi_ratio)
448+
423449
def resize(self, width, height):
424450
max_size = 1_400_000 # the measured max on xorg 1.20.8 was 1_409_023
425451

@@ -532,6 +558,32 @@ def __init__(self, canvas, window, *, pack_toolbar=True):
532558
if pack_toolbar:
533559
self.pack(side=tk.BOTTOM, fill=tk.X)
534560

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

628+
def _set_image_for_button(self, button):
629+
"""
630+
Set the image for a button based on its pixel size.
631+
632+
The pixel size is determined by the DPI scaling of the window.
633+
"""
634+
if button._image_file is None:
635+
return
636+
637+
size = button.winfo_pixels('24p')
638+
with Image.open(button._image_file.replace('.png', '_large.png')
639+
if size > 24 else button._image_file) as im:
640+
image = ImageTk.PhotoImage(im.resize((size, size)), master=self)
641+
button.configure(image=image, height='24p', width='24p')
642+
button._ntimage = image # Prevent garbage collection.
643+
576644
def _Button(self, text, image_file, toggle, command):
577645
if not toggle:
578646
b = tk.Button(master=self, text=text, command=command)
@@ -587,14 +655,10 @@ def _Button(self, text, image_file, toggle, command):
587655
master=self, text=text, command=command,
588656
indicatoron=False, variable=var)
589657
b.var = var
658+
b._image_file = image_file
590659
if image_file is not None:
591-
size = b.winfo_pixels('24p')
592-
with Image.open(image_file.replace('.png', '_large.png')
593-
if size > 24 else image_file) as im:
594-
image = ImageTk.PhotoImage(im.resize((size, size)),
595-
master=self)
596-
b.configure(image=image, height='24p', width='24p')
597-
b._ntimage = image # Prevent garbage collection.
660+
# Explicit class because ToolbarTk calls _Button.
661+
NavigationToolbar2Tk._set_image_for_button(self, b)
598662
else:
599663
b.configure(font=self._label_font)
600664
b.pack(side=tk.LEFT)
@@ -748,6 +812,9 @@ def __init__(self, toolmanager, window):
748812
self.pack(side=tk.TOP, fill=tk.X)
749813
self._groups = {}
750814

815+
def _rescale(self):
816+
return NavigationToolbar2Tk._rescale(self)
817+
751818
def add_toolitem(
752819
self, name, group, position, image_file, description, toggle):
753820
frame = self._get_groupframe(group)

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/_tkagg.cpp

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
#ifdef WIN32_DLL
3030
#include <windows.h>
31+
#include <commctrl.h>
3132
#define PSAPI_VERSION 1
3233
#include <psapi.h> // Must be linked with 'psapi' library
3334
#define dlsym GetProcAddress
@@ -95,8 +96,101 @@ static PyObject *mpl_tk_blit(PyObject *self, PyObject *args)
9596
}
9697
}
9798

99+
#ifdef WIN32_DLL
100+
LRESULT CALLBACK
101+
DpiSubclassProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam,
102+
UINT_PTR uIdSubclass, DWORD_PTR dwRefData)
103+
{
104+
switch (uMsg) {
105+
case WM_DPICHANGED:
106+
// This function is a subclassed window procedure, and so is run during
107+
// the Tcl/Tk event loop. Thus, we must re-take the GIL. Unfortunately,
108+
// Tkinter has a *second* lock on Tcl threading that is not exposed
109+
// publicly, so we must not call *any* Tk code from here, thus need to
110+
// use a polled queue instead.
111+
{
112+
PyObject *queue = (PyObject *)dwRefData;
113+
PyGILState_STATE state = PyGILState_Ensure();
114+
PyObject_CallMethod(queue, "put", "i", LOWORD(wParam));
115+
PyGILState_Release(state);
116+
}
117+
return 0;
118+
case WM_NCDESTROY:
119+
RemoveWindowSubclass(hwnd, DpiSubclassProc, uIdSubclass);
120+
break;
121+
}
122+
123+
return DefSubclassProc(hwnd, uMsg, wParam, lParam);
124+
}
125+
#endif
126+
127+
static PyObject*
128+
mpl_tk_enable_dpi_awareness(PyObject* self, PyObject*const* args,
129+
Py_ssize_t nargs)
130+
{
131+
if (nargs != 2) {
132+
return PyErr_Format(PyExc_TypeError,
133+
"enable_dpi_awareness() takes 2 positional "
134+
"arguments but %zd were given",
135+
nargs);
136+
}
137+
138+
#ifdef WIN32_DLL
139+
PyObject* frame = args[0];
140+
PyObject* queue = args[1];
141+
HWND handle = NULL;
142+
143+
if (!convert_voidptr(f 1241 rame, &handle)) {
144+
return NULL;
145+
}
146+
147+
#ifdef _DPI_AWARENESS_CONTEXTS_
148+
HMODULE user32 = LoadLibrary("user32.dll");
149+
150+
typedef DPI_AWARENESS_CONTEXT (WINAPI *GetWindowDpiAwarenessContext_t)(HWND);
151+
GetWindowDpiAwarenessContext_t GetWindowDpiAwarenessContextPtr =
152+
(GetWindowDpiAwarenessContext_t)GetProcAddress(
153+
user32, "GetWindowDpiAwarenessContext");
154+
if (GetWindowDpiAwarenessContextPtr == NULL) {
155+
FreeLibrary(user32);
156+
Py_RETURN_FALSE;
157+
}
158+
159+
typedef BOOL (WINAPI *AreDpiAwarenessContextsEqual_t)(DPI_AWARENESS_CONTEXT,
160+
DPI_AWARENESS_CONTEXT);
161+
AreDpiAwarenessContextsEqual_t AreDpiAwarenessContextsEqualPtr =
162+
(AreDpiAwarenessContextsEqual_t)GetProcAddress(
163+
user32, "AreDpiAwarenessContextsEqual");
164+
if (AreDpiAwarenessContextsEqualPtr == NULL) {
165+
FreeLibrary(user32);
166+
Py_RETURN_FALSE;
167+
}
168+
169+
DPI_AWARENESS_CONTEXT ctx = GetWindowDpiAwarenessContextPtr(handle);
170+
bool per_monitor = (
171+
AreDpiAwarenessContextsEqualPtr(
172+
ctx, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) ||
173+
AreDpiAwarenessContextsEqualPtr(
174+
ctx, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE));
175+
176+
if (per_monitor) {
177+
// Per monitor aware means we need to handle WM_DPICHANGED by wrapping
178+
// the Window Procedure, and the Python side needs to poll to the
179+
// queue.
180+
SetWindowSubclass(handle, DpiSubclassProc, 0, (DWORD_PTR)queue);
181+
}
182+
FreeLibrary(user32);
183+
return PyBool_FromLong(per_monitor);
184+
#endif
185+
#endif
186+
187+
Py_RETURN_NONE;
188+
}
189+
98190
static PyMethodDef functions[] = {
99191
{ "blit", (PyCFunction)mpl_tk_blit, METH_VARARGS },
192+
{ "enable_dpi_awareness", (PyCFunction)mpl_tk_enable_dpi_awareness,
193+
METH_FASTCALL },
100194
{ NULL, NULL } /* sentinel */
101195
};
102196

0 commit comments

Comments
 (0)
0