Skip to content

Commit d22bdd1

Browse files
committed
Handle DPI changes in TkAgg backend on Windows.
1 parent 69a01a6 commit d22bdd1

File tree

4 files changed

+212
-20
lines changed

4 files changed

+212
-20
lines changed

lib/matplotlib/backends/_backend_tk.py

+66-7
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,16 @@ def __init__(self, canvas, num, window):
409409
if self.toolbar:
410410
backend_tools.add_tools_to_container(self.toolbar)
411411

412+
# If the window has per-monitor DPI awareness, then setup a Tk variable
413+
# to store the DPI, which will be updated by the C code, and the trace
414+
# will handle it on the Python side.
415+
window_frame = int(window.wm_frame(), 16)
416+
window_dpi = tk.IntVar(master=window, value=96,
417+
name=f'window_dpi{window_frame}')
418+
if _tkagg.enable_dpi_awareness(window_frame, window.tk.interpaddr()):
419+
self._window_dpi = window_dpi # Prevent garbage collection.
420+
window_dpi.trace_add('write', self._update_window_dpi)
421+
412422
self._shown = False
413423

414424
def _get_toolbar(self):
@@ -420,6 +430,13 @@ def _get_toolbar(self):
420430
toolbar = None
421431
return toolbar
422432

433+
def _update_window_dpi(self, *args):
434+
newdpi = self._window_dpi.get()
435+
self.window.call('tk', 'scaling', newdpi / 72)
436+
if self.toolbar and hasattr(self.toolbar, '_rescale'):
437+
self.toolbar._rescale()
438+
self.canvas._update_device_pixel_ratio()
439+
423440
def resize(self, width, height):
424441
max_size = 1_400_000 # the measured max on xorg 1.20.8 was 1_409_023
425442

@@ -532,6 +549,33 @@ def __init__(self, canvas, window, *, pack_toolbar=True):
532549
if pack_toolbar:
533550
self.pack(side=tk.BOTTOM, fill=tk.X)
534551

552+
def _rescale(self):
553+
"""
554+
Scale all children of the toolbar to current DPI setting.
555+
556+
Before this is called, the Tk scaling setting will have been updated to
557+
match the new DPI. Tk widgets do not update for changes to scaling, but
558+
all measurements made after the change will match the new scaling. Thus
559+
this function re-applies all the same sizes in points, which Tk will
560+
scale correctly to pixels.
561+
"""
562+
for widget in self.winfo_children():
563+
if isinstance(widget, (tk.Button, tk.Checkbutton)):
564+
if hasattr(widget, '_image_file'):
565+
# Explicit class because ToolbarTk calls _rescale.
566+
NavigationToolbar2Tk._set_image_for_button(self, widget)
567+
else:
568+
# Text-only button is handled by the font setting instead.
569+
pass
570+
elif isinstance(widget, tk.Frame):
571+
widget.configure(height='22p', pady='1p')
572+
widget.pack_configure(padx='4p')
573+
elif isinstance(widget, tk.Label):
574+
pass # Text is handled by the font setting instead.
575+
else:
576+
_log.warning('Unknown child class %s', widget.winfo_class)
577+
self._label_font.configure(size=10)
578+
535579
def _update_buttons_checked(self):
536580
# sync button checkstates to match active mode
537581
for text, mode in [('Zoom', _Mode.ZOOM), ('Pan', _Mode.PAN)]:
@@ -573,6 +617,22 @@ def set_cursor(self, cursor):
573617
except tkinter.TclError:
574618
pass
575619

620+
def _set_image_for_button(self, button):
621+
"""
622+
Set the image for a button based on its pixel size.
623+
624+
The pixel size is determined by the DPI scaling of the window.
625+
"""
626+
if button._image_file is None:
627+
return
628+
629+
size = button.winfo_pixels('24p')
630+
with Image.open(button._image_file.replace('.png', '_large.png')
631+
if size > 24 else button._image_file) as im:
632+
image = ImageTk.PhotoImage(im.resize((size, size)), master=self)
633+
button.configure(image=image, height='24p', width='24p')
634+
button._ntimage = image # Prevent garbage collection.
635+
576636
def _Button(self, text, image_file, toggle, command):
577637
if not toggle:
578638
b = tk.Button(master=self, text=text, command=command)
@@ -587,14 +647,10 @@ def _Button(self, text, image_file, toggle, command):
587647
master=self, text=text, command=command,
588648
indicatoron=False, variable=var)
589649
b.var = var
650+
b._image_file = image_file
590651
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.
652+
# Explicit class because ToolbarTk calls _Button.
653+
NavigationToolbar2Tk._set_image_for_button(self, b)
598654
else:
599655
b.configure(font=self._label_font)
600656
b.pack(side=tk.LEFT)
@@ -748,6 +804,9 @@ def __init__(self, toolmanager, window):
748804
self.pack(side=tk.TOP, fill=tk.X)
749805
self._groups = {}
750806

807+
def _rescale(self):
808+
return NavigationToolbar2Tk._rescale(self)
809+
751810
def add_toolitem(
752811
self, name, group, position, image_file, description, toggle):
753812
frame = self._get_groupframe(group)

setupext.py

+2-2
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

+138-11
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@
2727
#endif
2828

2929
#ifdef WIN32_DLL
30+
#include <string>
3031
#include <windows.h>
32+
#include <commctrl.h>
3133
#define PSAPI_VERSION 1
3234
#include <psapi.h> // Must be linked with 'psapi' library
3335
#define dlsym GetProcAddress
@@ -49,6 +51,11 @@ static int convert_voidptr(PyObject *obj, void *p)
4951
// extension module or loaded Tk libraries at run-time.
5052
static Tk_FindPhoto_t TK_FIND_PHOTO;
5153
static Tk_PhotoPutBlock_NoComposite_t TK_PHOTO_PUT_BLOCK_NO_COMPOSITE;
54+
#ifdef WIN32_DLL
55+
// Global vars for Tcl functions. We load these symbols from the tkinter
56+
// extension module or loaded Tcl libraries at run-time.
57+
static Tcl_SetVar_t TCL_SETVAR;
58+
#endif
5259

5360
static PyObject *mpl_tk_blit(PyObject *self, PyObject *args)
5461
{
@@ -95,17 +102,119 @@ static PyObject *mpl_tk_blit(PyObject *self, PyObject *args)
95102
}
96103
}
97104

105+
#ifdef WIN32_DLL
106+
LRESULT CALLBACK
107+
DpiSubclassProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam,
108+
UINT_PTR uIdSubclass, DWORD_PTR dwRefData)
109+
{
110+
switch (uMsg) {
111+
case WM_DPICHANGED:
112+
// This function is a subclassed window procedure, and so is run during
113+
// the Tcl/Tk event loop. Unfortunately, Tkinter has a *second* lock on
114+
// Tcl threading that is not exposed publicly, but is currently taken
115+
// while we're in the window procedure. So while we can take the GIL to
116+
// call Python code, we must not also call *any* Tk code from Python.
117+
// So stay with Tcl calls in C only.
118+
{
119+
// This variable naming must match the name used in
120+
// lib/matplotlib/backends/_backend_tk.py:FigureManagerTk.
121+
std::string var_name("window_dpi");
122+
var_name += std::to_string((unsigned long long)hwnd);
123+
124+
// X is high word, Y is low word, but they are always equal.
125+
std::string dpi = std::to_string(LOWORD(wParam));
126+
127+
Tcl_Interp* interp = (Tcl_Interp*)dwRefData;
128+
TCL_SETVAR(interp, var_name.c_str(), dpi.c_str(), 0);
129+
}
130+
return 0;
131+
case WM_NCDESTROY:
132+
RemoveWindowSubclass(hwnd, DpiSubclassProc, uIdSubclass);
133+
break;
134+
}
135+
136+
return DefSubclassProc(hwnd, uMsg, wParam, lParam);
137+
}
138+
#endif
139+
140+
static PyObject*
141+
mpl_tk_enable_dpi_awareness(PyObject* self, PyObject*const* args,
142+
Py_ssize_t nargs)
143+
{
144+
if (nargs != 2) {
145+
return PyErr_Format(PyExc_TypeError,
146+
"enable_dpi_awareness() takes 2 positional "
147+
"arguments but %zd were given",
148+
nargs);
149+
}
150+
151+
#ifdef WIN32_DLL
152+
HWND frame_handle = NULL;
153+
Tcl_Interp *interp = NULL;
154+
155+
if (!convert_voidptr(args[0], &frame_handle)) {
156+
return NULL;
157+
}
158+
if (!convert_voidptr(args[1], &interp)) {
159+
return NULL;
160+
}
161+
162+
#ifdef _DPI_AWARENESS_CONTEXTS_
163+
HMODULE user32 = LoadLibrary("user32.dll");
164+
165+
typedef DPI_AWARENESS_CONTEXT (WINAPI *GetWindowDpiAwarenessContext_t)(HWND);
166+
GetWindowDpiAwarenessContext_t GetWindowDpiAwarenessContextPtr =
167+
(GetWindowDpiAwarenessContext_t)GetProcAddress(
168+
user32, "GetWindowDpiAwarenessContext");
169+
if (GetWindowDpiAwarenessContextPtr == NULL) {
170+
FreeLibrary(user32);
171+
Py_RETURN_FALSE;
172+
}
173+
174+
typedef BOOL (WINAPI *AreDpiAwarenessContextsEqual_t)(DPI_AWARENESS_CONTEXT,
175+
DPI_AWARENESS_CONTEXT);
176+
AreDpiAwarenessContextsEqual_t AreDpiAwarenessContextsEqualPtr =
177+
(AreDpiAwarenessContextsEqual_t)GetProcAddress(
178+
user32, "AreDpiAwarenessContextsEqual");
179+
if (AreDpiAwarenessContextsEqualPtr == NULL) {
180+
FreeLibrary(user32);
181+
Py_RETURN_FALSE;
182+
}
183+
184+
DPI_AWARENESS_CONTEXT ctx = GetWindowDpiAwarenessContextPtr(frame_handle);
185+
bool per_monitor = (
186+
AreDpiAwarenessContextsEqualPtr(
187+
ctx, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) ||
188+
AreDpiAwarenessContextsEqualPtr(
189+
ctx, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE));
190+
191+
if (per_monitor) {
192+
// Per monitor aware means we need to handle WM_DPICHANGED by wrapping
193+
// the Window Procedure, and the Python side needs to trace the Tk
194+
// window_dpi variable stored on interp.
195+
SetWindowSubclass(frame_handle, DpiSubclassProc, 0, (DWORD_PTR)interp);
196+
}
197+
FreeLibrary(user32);
198+
return PyBool_FromLong(per_monitor);
199+
#endif
200+
#endif
201+
202+
Py_RETURN_NONE;
203+
}
204+
98205
static PyMethodDef functions[] = {
99206
{ "blit", (PyCFunction)mpl_tk_blit, METH_VARARGS },
207+
{ "enable_dpi_awareness", (PyCFunction)mpl_tk_enable_dpi_awareness,
208+
METH_FASTCALL },
100209
{ NULL, NULL } /* sentinel */
101210
};
102211

103-
// Functions to fill global Tk function pointers by dynamic loading
212+
// Functions to fill global Tcl/Tk function pointers by dynamic loading.
104213

105214
template <class T>
106215
int load_tk(T lib)
107216
{
108-
// Try to fill Tk global vars with function pointers. Return the number of
217+
// Try to fill Tk global vars with function pointers. Return the number of
109218
// functions found.
110219
return
111220
!!(TK_FIND_PHOTO =
@@ -116,27 +225,40 @@ int load_tk(T lib)
116225

117226
#ifdef WIN32_DLL
118227

119-
/*
120-
* On Windows, we can't load the tkinter module to get the Tk symbols, because
121-
* Windows does not load symbols into the library name-space of importing
122-
* modules. So, knowing that tkinter has already been imported by Python, we
123-
* scan all modules in the running process for the Tk function names.
228+
template <class T>
229+
int load_tcl(T lib)
230+
{
231+
// Try to fill Tcl global vars with function pointers. Return the number of
232+
// functions found.
233+
return
234+
!!(TCL_SETVAR = (Tcl_SetVar_t)dlsym(lib, "Tcl_SetVar"));
235+
}
236+
237+
/* On Windows, we can't load the tkinter module to get the Tcl/Tk symbols,
238+
* because Windows does not load symbols into the library name-space of
239+
* importing modules. So, knowing that tkinter has already been imported by
240+
* Python, we scan all modules in the running process for the Tcl/Tk function
241+
* names.
124242
*/
125243

126244
void load_tkinter_funcs(void)
127245
{
128-
// Load Tk functions by searching all modules in current process.
246+
// Load Tcl/Tk functions by searching all modules in current process.
129247
HMODULE hMods[1024];
130248
HANDLE hProcess;
131249
DWORD cbNeeded;
132250
unsigned int i;
251+
bool tcl_ok = false, tk_ok = false;
133252
// Returns pseudo-handle that does not need to be closed
134253
hProcess = GetCurrentProcess();
135-
// Iterate through modules in this process looking for Tk names.
254+
// Iterate through modules in this process looking for Tcl/Tk names.
136255
if (EnumProcessModules(hProcess, hMods, sizeof(hMods), &cbNeeded)) {
137256
for (i = 0; i < (cbNeeded / sizeof(HMODULE)); i++) {
138-
if (load_tk(hMods[i])) {
139-
return;
257+
if (!tcl_ok) {
258+
tcl_ok = load_tcl(hMods[i]);
259+
}
260+
if (!tk_ok) {
261+
tk_ok = load_tk(hMods[i]);
140262
}
141263
}
142264
}
@@ -211,6 +333,11 @@ PyMODINIT_FUNC PyInit__tkagg(void)
211333
load_tkinter_funcs();
212334
if (PyErr_Occurred()) {
213335
return NULL;
336+
#ifdef WIN32_DLL
337+
} else if (!TCL_SETVAR) {
338+
PyErr_SetString(PyExc_RuntimeError, "Failed to load Tcl_SetVar");
339+
return NULL;
340+
#endif
214341
} else if (!TK_FIND_PHOTO) {
215342
PyErr_SetString(PyExc_RuntimeError, "Failed to load Tk_FindPhoto");
216343
return NULL;

src/_tkmini.h

+6
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,12 @@ typedef void (*Tk_PhotoPutBlock_NoComposite_t) (Tk_PhotoHandle handle,
9595
Tk_PhotoImageBlock *blockPtr, int x, int y,
9696
int width, int height);
9797

98+
#ifdef WIN32_DLL
99+
/* Typedefs derived from function signatures in Tcl header */
100+
typedef const char *(*Tcl_SetVar_t)(Tcl_Interp *interp, const char *varName,
101+
const char *newValue, int flags);
102+
#endif
103+
98104
#ifdef __cplusplus
99105
}
100106
#endif

0 commit comments

Comments
 (0)