Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Handle DPI changes in TkAgg backend on Windows.
  • Loading branch information
QuLogic committed May 11, 2021
commit 741ee02d184586747155aa69ca2e5a8c584a3beb
73 changes: 66 additions & 7 deletions lib/matplotlib/backends/_backend_tk.py
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,16 @@ def __init__(self, canvas, num, window):
if self.toolbar:
backend_tools.add_tools_to_container(self.toolbar)

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

self._shown = False

def _get_toolbar(self):
Expand All @@ -430,6 +440,13 @@ def _get_toolbar(self):
toolbar = None
return toolbar

def _update_window_dpi(self, *args):
newdpi = self._window_dpi.get()
self.window.call('tk', 'scaling', newdpi / 72)
if self.toolbar and hasattr(self.toolbar, '_rescale'):
self.toolbar._rescale()
self.canvas._update_device_pixel_ratio()

def resize(self, width, height):
max_size = 1_400_000 # the measured max on xorg 1.20.8 was 1_409_023

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

def _rescale(self):
"""
Scale all children of the toolbar to current DPI setting.

Before this is called, the Tk scaling setting will have been updated to
match the new DPI. Tk widgets do not update for changes to scaling, but
all measurements made after the change will match the new scaling. Thus
this function re-applies all the same sizes in points, which Tk will
scale correctly to pixels.
"""
for widget in self.winfo_children():
if isinstance(widget, (tk.Button, tk.Checkbutton)):
if hasattr(widget, '_image_file'):
# Explicit class because ToolbarTk calls _rescale.
NavigationToolbar2Tk._set_image_for_button(self, widget)
else:
# Text-only button is handled by the font setting instead.
pass
elif isinstance(widget, tk.Frame):
widget.configure(height='22p', pady='1p')
widget.pack_configure(padx='4p')
elif isinstance(widget, tk.Label):
pass # Text is handled by the font setting instead.
else:
_log.warning('Unknown child class %s', widget.winfo_class)
self._label_font.configure(size=10)

def _update_buttons_checked(self):
# sync button checkstates to match active mode
for text, mode in [('Zoom', _Mode.ZOOM), ('Pan', _Mode.PAN)]:
Expand Down Expand Up @@ -586,6 +630,22 @@ def set_cursor(self, cursor):
except tkinter.TclError:
pass

def _set_image_for_button(self, button):
"""
Set the image for a button based on its pixel size.

The pixel size is determined by the DPI scaling of the window.
"""
if button._image_file is None:
return

size = button.winfo_pixels('18p')
with Image.open(button._image_file.replace('.png', '_large.png')
if size > 24 else button._image_file) as im:
image = ImageTk.PhotoImage(im.resize((size, size)), master=self)
button.configure(image=image, height='18p', width='18p')
button._ntimage = image # Prevent garbage collection.

def _Button(self, text, image_file, toggle, command):
if not toggle:
b = tk.Button(master=self, text=text, command=command)
Expand All @@ -600,14 +660,10 @@ def _Button(self, text, image_file, toggle, command):
master=self, text=text, command=command,
indicatoron=False, variable=var)
b.var = var
b._image_file = image_file
if image_file is not None:
size = b.winfo_pixels('18p')
with Image.open(image_file.replace('.png', '_large.png')
if size > 24 else image_file) as im:
image = ImageTk.PhotoImage(im.resize((size, size)),
master=self)
b.configure(image=image, height='18p', width='18p')
b._ntimage = image # Prevent garbage collection.
# Explicit class because ToolbarTk calls _Button.
NavigationToolbar2Tk._set_image_for_button(self, b)
else:
b.configure(font=self._label_font)
b.pack(side=tk.LEFT)
Expand Down Expand Up @@ -760,6 +816,9 @@ def __init__(self, toolmanager, window):
self.pack(side=tk.TOP, fill=tk.X)
self._groups = {}

def _rescale(self):
return NavigationToolbar2Tk._rescale(self)

def add_toolitem(
self, name, group, position, image_file, description, toggle):
frame = self._get_groupframe(group)
Expand Down
4 changes: 2 additions & 2 deletions setupext.py
Original file line number Diff line number Diff line change
Expand Up @@ -444,8 +444,8 @@ def get_extensions(self):
],
include_dirs=["src"],
# psapi library needed for finding Tcl/Tk at run time.
libraries=({"linux": ["dl"], "win32": ["psapi"],
"cygwin": ["psapi"]}.get(sys.platform, [])),
libraries={"linux": ["dl"], "win32": ["comctl32", "psapi"],
"cygwin": ["comctl32", "psapi"]}.get(sys.platform, []),
extra_link_args={"win32": ["-mwindows"]}.get(sys.platform, []))
add_numpy_flags(ext)
add_libagg_flags(ext)
Expand Down
149 changes: 138 additions & 11 deletions src/_tkagg.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@
#endif

#ifdef WIN32_DLL
#include <string>
#include <windows.h>
#include <commctrl.h>
#define PSAPI_VERSION 1
#include <psapi.h> // Must be linked with 'psapi' library
#define dlsym GetProcAddress
Expand All @@ -49,6 +51,11 @@ static int convert_voidptr(PyObject *obj, void *p)
// extension module or loaded Tk libraries at run-time.
static Tk_FindPhoto_t TK_FIND_PHOTO;
static Tk_PhotoPutBlock_NoComposite_t TK_PHOTO_PUT_BLOCK_NO_COMPOSITE;
#ifdef WIN32_DLL
// Global vars for Tcl functions. We load these symbols from the tkinter
// extension module or loaded Tcl libraries at run-time.
static Tcl_SetVar_t TCL_SETVAR;
#endif

static PyObject *mpl_tk_blit(PyObject *self, PyObject *args)
{
Expand Down Expand Up @@ -95,17 +102,119 @@ static PyObject *mpl_tk_blit(PyObject *self, PyObject *args)
}
}

#ifdef WIN32_DLL
LRESULT CALLBACK
DpiSubclassProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam,
UINT_PTR uIdSubclass, DWORD_PTR dwRefData)
{
switch (uMsg) {
case WM_DPICHANGED:
// This function is a subclassed window procedure, and so is run during

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider using Tcl_QueueEvent or Tcl_EvalEx to queue up a call to a tkinter-registered python function to avoid the polling loop. Failing that, I think this all occurs in the main thread, so you can use a deque instead of a SimpleQueue.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is not possible to call any Tk functions due to the Tkinter-internal Tcl thread lock. I'm fairly certain I tried Tcl_QueueEvent as well, but can do so again.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was under the impression that these windowprocs are called within Tcl_DoOneEvent, usually here:

https://github.com/python/cpython/blob/1e9f0933095403b215c2c4a0be7915d034ff7026/Modules/_tkinter.c#L2938

so I was expecting it to hold the tcl_lock already. If that's not the case... sorry for the distraction!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually no, I think I tried Tk_QueueEvent which does not work because of said locks. Looking at the API for Tcl_QueueEvent, I'm not sure how it could be used. It takes a pointer a function to call later, which would still be my code and unable to (un)lock anything in the Tkinter library.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that Tk/Tcl is threaded on Windows, so it doesn't go through that route, but this one:
https://github.com/python/cpython/blob/1e9f0933095403b215c2c4a0be7915d034ff7026/Modules/_tkinter.c#L2931

As an alternative, I tried calling Tcl_SetVar on a variable named per-window, and this might work without crashing. I need to do a bit more testing to be sure, since concurrency issues can be heisenbugs.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, I used a Tk variable now; this is nice as it cleans up the queue polling.

I think what happened originally is that I tried the variable approach from Python, and that failed due to the competing locks which I only found out about some time later.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like it, way better than the original and less magical than what I was imagining.

// the Tcl/Tk event loop. Unfortunately, Tkinter has a *second* lock on
// Tcl threading that is not exposed publicly, but is currently taken
// while we're in the window procedure. So while we can take the GIL to
// call Python code, we must not also call *any* Tk code from Python.
// So stay with Tcl calls in C only.
{
// This variable naming must match the name used in
// lib/matplotlib/backends/_backend_tk.py:FigureManagerTk.
std::string var_name("window_dpi");
var_name += std::to_string((unsigned long long)hwnd);

// X is high word, Y is low word, but they are always equal.
std::string dpi = std::to_string(LOWORD(wParam));

Tcl_Interp* interp = (Tcl_Interp*)dwRefData;
TCL_SETVAR(interp, var_name.c_str(), dpi.c_str(), 0);
}
return 0;
case WM_NCDESTROY:
RemoveWindowSubclass(hwnd, DpiSubclassProc, uIdSubclass);
break;
}

return DefSubclassProc(hwnd, uMsg, wParam, lParam);
}
#endif

static PyObject*
mpl_tk_enable_dpi_awareness(PyObject* self, PyObject*const* args,
Comment thread
QuLogic marked this conversation as resolved.
Py_ssize_t nargs)
{
if (nargs != 2) {
return PyErr_Format(PyExc_TypeError,
"enable_dpi_awareness() takes 2 positional "
"arguments but %zd were given",
nargs);
}

#ifdef WIN32_DLL
HWND frame_handle = NULL;
Tcl_Interp *interp = NULL;

if (!convert_voidptr(args[0], &frame_handle)) {
return NULL;
}
if (!convert_voidptr(args[1], &interp)) {
return NULL;
}

#ifdef _DPI_AWARENESS_CONTEXTS_
HMODULE user32 = LoadLibrary("user32.dll");

typedef DPI_AWARENESS_CONTEXT (WINAPI *GetWindowDpiAwarenessContext_t)(HWND);
GetWindowDpiAwarenessContext_t GetWindowDpiAwarenessContextPtr =
(GetWindowDpiAwarenessContext_t)GetProcAddress(
user32, "GetWindowDpiAwarenessContext");
if (GetWindowDpiAwarenessContextPtr == NULL) {
FreeLibrary(user32);
Py_RETURN_FALSE;
}

typedef BOOL (WINAPI *AreDpiAwarenessContextsEqual_t)(DPI_AWARENESS_CONTEXT,
DPI_AWARENESS_CONTEXT);
AreDpiAwarenessContextsEqual_t AreDpiAwarenessContextsEqualPtr =
(AreDpiAwarenessContextsEqual_t)GetProcAddress(
user32, "AreDpiAwarenessContextsEqual");
if (AreDpiAwarenessContextsEqualPtr == NULL) {
FreeLibrary(user32);
Py_RETURN_FALSE;
}

DPI_AWARENESS_CONTEXT ctx = GetWindowDpiAwarenessContextPtr(frame_handle);
bool per_monitor = (
AreDpiAwarenessContextsEqualPtr(
ctx, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) ||
AreDpiAwarenessContextsEqualPtr(
ctx, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE));

if (per_monitor) {
// Per monitor aware means we need to handle WM_DPICHANGED by wrapping
// the Window Procedure, and the Python side needs to trace the Tk
// window_dpi variable stored on interp.
SetWindowSubclass(frame_handle, DpiSubclassProc, 0, (DWORD_PTR)interp);
}
FreeLibrary(user32);
return PyBool_FromLong(per_monitor);
#endif
#endif

Py_RETURN_NONE;
}

static PyMethodDef functions[] = {
{ "blit", (PyCFunction)mpl_tk_blit, METH_VARARGS },
{ "enable_dpi_awareness", (PyCFunction)mpl_tk_enable_dpi_awareness,
METH_FASTCALL },
{ NULL, NULL } /* sentinel */
};

// Functions to fill global Tk function pointers by dynamic loading
// Functions to fill global Tcl/Tk function pointers by dynamic loading.

template <class T>
int load_tk(T lib)
{
// Try to fill Tk global vars with function pointers. Return the number of
// Try to fill Tk global vars with function pointers. Return the number of
// functions found.
return
!!(TK_FIND_PHOTO =
Expand All @@ -116,27 +225,40 @@ int load_tk(T lib)

#ifdef WIN32_DLL

/*
* On Windows, we can't load the tkinter module to get the Tk symbols, because
* Windows does not load symbols into the library name-space of importing
* modules. So, knowing that tkinter has already been imported by Python, we
* scan all modules in the running process for the Tk function names.
template <class T>
int load_tcl(T lib)
{
// Try to fill Tcl global vars with function pointers. Return the number of
// functions found.
return
!!(TCL_SETVAR = (Tcl_SetVar_t)dlsym(lib, "Tcl_SetVar"));
}

/* On Windows, we can't load the tkinter module to get the Tcl/Tk symbols,
* because Windows does not load symbols into the library name-space of
* importing modules. So, knowing that tkinter has already been imported by
* Python, we scan all modules in the running process for the Tcl/Tk function
* names.
*/

void load_tkinter_funcs(void)
{
// Load Tk functions by searching all modules in current process.
// Load Tcl/Tk functions by searching all modules in current process.
HMODULE hMods[1024];
HANDLE hProcess;
DWORD cbNeeded;
unsigned int i;
bool tcl_ok = false, tk_ok = false;
// Returns pseudo-handle that does not need to be closed
hProcess = GetCurrentProcess();
// Iterate through modules in this process looking for Tk names.
// Iterate through modules in this process looking for Tcl/Tk names.
if (EnumProcessModules(hProcess, hMods, sizeof(hMods), &cbNeeded)) {
for (i = 0; i < (cbNeeded / sizeof(HMODULE)); i++) {
if (load_tk(hMods[i])) {
return;
if (!tcl_ok) {
tcl_ok = load_tcl(hMods[i]);
}
if (!tk_ok) {
tk_ok = load_tk(hMods[i]);
}
}
}
Expand Down Expand Up @@ -211,6 +333,11 @@ PyMODINIT_FUNC PyInit__tkagg(void)
load_tkinter_funcs();
if (PyErr_Occurred()) {
return NULL;
#ifdef WIN32_DLL
} else if (!TCL_SETVAR) {
PyErr_SetString(PyExc_RuntimeError, "Failed to load Tcl_SetVar");
return NULL;
#endif
} else if (!TK_FIND_PHOTO) {
PyErr_SetString(PyExc_RuntimeError, "Failed to load Tk_FindPhoto");
return NULL;
Expand Down
6 changes: 6 additions & 0 deletions src/_tkmini.h
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@ typedef void (*Tk_PhotoPutBlock_NoComposite_t) (Tk_PhotoHandle handle,
Tk_PhotoImageBlock *blockPtr, int x, int y,
int width, int height);

#ifdef WIN32_DLL
/* Typedefs derived from function signatures in Tcl header */
typedef const char *(*Tcl_SetVar_t)(Tcl_Interp *interp, const char *varName,
const char *newValue, int flags);
#endif

#ifdef __cplusplus
}
#endif