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

Skip to content

Commit ebbc746

Browse files
committed
Handle DPI changes in TkAgg backend on Windows.
1 parent 09f95f4 commit ebbc746

File tree

4 files changed

+212
-20
lines changed

4 files changed

+212
-20
lines changed

lib/matplotlib/backends/_backend_tk.py

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,16 @@ def __init__(self, canvas, num, window):
419419
if self.toolbar:
420420
backend_tools.add_tools_to_container(self.toolbar)
421421

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+
422432
self._shown = False
423433

424434
def _get_toolbar(self):
@@ -430,6 +440,13 @@ def _get_toolbar(self):
430440
toolbar = None
431441
return toolbar
432442

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+
433450
def resize(self, width, height):
434451
max_size = 1_400_000 # the measured max on xorg 1.20.8 was 1_409_023
435452

@@ -545,6 +562,33 @@ def __init__(self, canvas, window, *, pack_toolbar=True):
545562
if pack_toolbar:
546563
self.pack(side=tk.BOTTOM, fill=tk.X)
547564

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+
548592
def _update_buttons_checked(self):
549593
# sync button checkstates to match active mode
550594
for text, mode in [('Zoom', _Mode.ZOOM), ('Pan', _Mode.PAN)]:
@@ -586,6 +630,22 @@ def set_cursor(self, cursor):
586630
except tkinter.TclError:
587631
pass
588632

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('24p')
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='24p', width='24p')
647+
button._ntimage = image # Prevent garbage collection.
648+
589649
def _Button(self, text, image_file, toggle, command):
590650
if not toggle:
591651
b = tk.Button(master=self, text=text, command=command)
@@ -600,14 +660,10 @@ def _Button(self, text, image_file, toggle, command):
600660
master=self, text=text, command=command,
601661
indicatoron=False, variable=var)
602662
b.var = var
663+
b._image_file = image_file
603664
if image_file is not None:
604-
size = b.winfo_pixels('24p')
605-
with Image.open(image_file.replace('.png', '_large.png')
606-
if size > 24 else image_file) as im:
607-
image = ImageTk.PhotoImage(im.resize((size, size)),
608-
master=self)
609-
b.configure(image=image, height='24p', width='24p')
610-
b._ntimage = image # Prevent garbage collection.
665+
# Explicit class because ToolbarTk calls _Button.
666+
NavigationToolbar2Tk._set_image_for_button(self, b)
611667
else:
612668
b.configure(font=self._label_font)
613669
b.pack(side=tk.LEFT)
@@ -761,6 +817,9 @@ def __init__(self, toolmanager, window):
761817
self.pack(side=tk.TOP, fill=tk.X)
762818
self._groups = {}
763819

820+
def _rescale(self):
821+
return NavigationToolbar2Tk._rescale(self)
822+
764823
def add_toolitem(
765824
self, name, group, position, image_file, 10000 description, toggle):
766825
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: 138 additions & 11 deletions
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

Lines changed: 6 additions & 0 deletions
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)
0