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(frame, &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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy