diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 8615967fd981..a1cd76470e23 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -2107,7 +2107,7 @@ def _set_device_pixel_ratio(self, ratio): self._device_pixel_ratio = ratio return True - def get_width_height(self): + def get_width_height(self, *, physical=False): """ Return the figure width and height in integral points or pixels. @@ -2115,13 +2115,20 @@ def get_width_height(self): it), the truncation to integers occurs after scaling by the device pixel ratio. + Parameters + ---------- + physical : bool, default: False + Whether to return true physical pixels or logical pixels. Physical + pixels may be used by backends that support HiDPI, but still + configure the canvas using its actual size. + Returns ------- width, height : int The size of the figure, in points or pixels, depending on the backend. """ - return tuple(int(size / self.device_pixel_ratio) + return tuple(int(size / (1 if physical else self.device_pixel_ratio)) for size in self.figure.bbox.max) @classmethod diff --git a/lib/matplotlib/backends/_backend_tk.py b/lib/matplotlib/backends/_backend_tk.py index bc69e08aa396..2f13408cd0f3 100644 --- a/lib/matplotlib/backends/_backend_tk.py +++ b/lib/matplotlib/backends/_backend_tk.py @@ -5,11 +5,13 @@ import os.path import sys import tkinter as tk -from tkinter.simpledialog import SimpleDialog import tkinter.filedialog +import tkinter.font import tkinter.messagebox +from tkinter.simpledialog import SimpleDialog import numpy as np +from PIL import Image, ImageTk import matplotlib as mpl from matplotlib import _api, backend_tools, cbook, _c_internal_utils @@ -164,10 +166,9 @@ class FigureCanvasTk(FigureCanvasBase): alternative="get_tk_widget().bind('', ..., True)") def __init__(self, figure=None, master=None, resize_callback=None): super().__init__(figure) - self._idle = True - self._idle_callback = None + self._idle_draw_id = None self._event_loop_id = None - w, h = self.figure.bbox.size.astype(int) + w, h = self.get_width_height(physical=True) self._tkcanvas = tk.Canvas( master=master, background="white", width=w, height=h, borderwidth=0, highlightthickness=0) @@ -176,6 +177,7 @@ def __init__(self, figure=None, master=None, resize_callback=None): self._tkcanvas.create_image(w//2, h//2, image=self._tkphoto) self._resize_callback = resize_callback self._tkcanvas.bind("", self.resize) + self._tkcanvas.bind("", self._update_device_pixel_ratio) self._tkcanvas.bind("", self.key_press) self._tkcanvas.bind("", self.motion_notify_event) self._tkcanvas.bind("", self.enter_notify_event) @@ -210,6 +212,18 @@ def filter_destroy(event): self._master = master self._tkcanvas.focus_set() + def _update_device_pixel_ratio(self, event=None): + # Tk gives scaling with respect to 72 DPI, but most (all?) screens are + # scaled vs 96 dpi, and pixel ratio settings are given in whole + # percentages, so round to 2 digits. + ratio = round(self._master.call('tk', 'scaling') / (96 / 72), 2) + if self._set_device_pixel_ratio(ratio): + # The easiest way to resize the canvas is to resize the canvas + # widget itself, since we implement all the logic for resizing the + # canvas backing store on that event. + w, h = self.get_width_height(physical=True) + self._tkcanvas.configure(width=w, height=h) + def resize(self, event): width, height = event.width, event.height if self._resize_callback is not None: @@ -230,18 +244,16 @@ def resize(self, event): def draw_idle(self): # docstring inherited - if not self._idle: + if self._idle_draw_id: return - self._idle = False - def idle_draw(*args): try: self.draw() finally: - self._idle = True + self._idle_draw_id = None - self._idle_callback = self._tkcanvas.after_idle(idle_draw) + self._idle_draw_id = self._tkcanvas.after_idle(idle_draw) def get_tk_widget(self): """ @@ -407,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): @@ -418,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 @@ -447,8 +476,8 @@ def destroy(*args): self._shown = True def destroy(self, *args): - if self.canvas._idle_callback: - self.canvas._tkcanvas.after_cancel(self.canvas._idle_callback) + if self.canvas._idle_draw_id: + self.canvas._tkcanvas.after_cancel(self.canvas._idle_draw_id) if self.canvas._event_loop_id: self.canvas._tkcanvas.after_cancel(self.canvas._event_loop_id) @@ -514,22 +543,52 @@ def __init__(self, canvas, window, *, pack_toolbar=True): if tooltip_text is not None: ToolTip.createToolTip(button, tooltip_text) + self._label_font = tkinter.font.Font(size=10) + # This filler item ensures the toolbar is always at least two text # lines high. Otherwise the canvas gets redrawn as the mouse hovers # over images because those use two-line messages which resize the # toolbar. - label = tk.Label(master=self, + label = tk.Label(master=self, font=self._label_font, text='\N{NO-BREAK SPACE}\n\N{NO-BREAK SPACE}') label.pack(side=tk.RIGHT) self.message = tk.StringVar(master=self) - self._message_label = tk.Label(master=self, textvariable=self.message) + self._message_label = tk.Label(master=self, font=self._label_font, + textvariable=self.message) self._message_label.pack(side=tk.RIGHT) NavigationToolbar2.__init__(self, canvas) 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)]: @@ -571,15 +630,25 @@ 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 tk.TkVersion >= 8.6: - PhotoImage = tk.PhotoImage - else: - from PIL.ImageTk import PhotoImage - image = (PhotoImage(master=self, file=image_file) - if image_file is not None else None) if not toggle: - b = tk.Button(master=self, text=text, image=image, command=command) + b = tk.Button(master=self, text=text, command=command) else: # There is a bug in tkinter included in some python 3.6 versions # that without this variable, produces a "visual" toggling of @@ -588,18 +657,22 @@ def _Button(self, text, image_file, toggle, command): # https://bugs.python.org/issue25684 var = tk.IntVar(master=self) b = tk.Checkbutton( - master=self, text=text, image=image, command=command, + master=self, text=text, command=command, indicatoron=False, variable=var) b.var = var - b._ntimage = image + b._image_file = image_file + if image_file is not None: + # 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) return b def _Spacer(self): - # Buttons are 30px high. Make this 26px tall +2px padding to center it. - s = tk.Frame( - master=self, height=26, relief=tk.RIDGE, pady=2, bg="DarkGray") - s.pack(side=tk.LEFT, padx=5) + # Buttons are also 18pt high. + s = tk.Frame(master=self, height='18p', relief=tk.RIDGE, bg='DarkGray') + s.pack(side=tk.LEFT, padx='3p') return s def save_figure(self, *args): @@ -734,13 +807,18 @@ def __init__(self, toolmanager, window): tk.Frame.__init__(self, master=window, width=int(width), height=int(height), borderwidth=2) + self._label_font = tkinter.font.Font(size=10) self._message = tk.StringVar(master=self) - self._message_label = tk.Label(master=self, textvariable=self._message) + self._message_label = tk.Label(master=self, font=self._label_font, + textvariable=self._message) self._message_label.pack(side=tk.RIGHT) self._toolitems = {} 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) @@ -847,6 +925,7 @@ def new_figure_manager_given_figure(cls, num, figure): with _restore_foreground_window_at_end(): if cbook._get_running_interactive_framework() is None: cbook._setup_new_guiapp() + _c_internal_utils.Win32_SetProcessDpiAwareness_max() window = tk.Tk(className="matplotlib") window.withdraw() diff --git a/setupext.py b/setupext.py index cefd444c3933..476d426cf8a5 100644 --- a/setupext.py +++ b/setupext.py @@ -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) diff --git a/src/_c_internal_utils.c b/src/_c_internal_utils.c index 4a61fe5b6ee7..532824c5d05b 100644 --- a/src/_c_internal_utils.c +++ b/src/_c_internal_utils.c @@ -124,6 +124,47 @@ mpl_SetForegroundWindow(PyObject* module, PyObject *arg) #endif } +static PyObject* +mpl_SetProcessDpiAwareness_max(PyObject* module) +{ +#ifdef _WIN32 +#ifdef _DPI_AWARENESS_CONTEXTS_ + // These functions and options were added in later Windows 10 updates, so + // must be loaded dynamically. + typedef BOOL (WINAPI *IsValidDpiAwarenessContext_t)(DPI_AWARENESS_CONTEXT); + typedef BOOL (WINAPI *SetProcessDpiAwarenessContext_t)(DPI_AWARENESS_CONTEXT); + + HMODULE user32 = LoadLibrary("user32.dll"); + IsValidDpiAwarenessContext_t IsValidDpiAwarenessContextPtr = + (IsValidDpiAwarenessContext_t)GetProcAddress( + user32, "IsValidDpiAwarenessContext"); + SetProcessDpiAwarenessContext_t SetProcessDpiAwarenessContextPtr = + (SetProcessDpiAwarenessContext_t)GetProcAddress( + user32, "SetProcessDpiAwarenessContext"); + if (IsValidDpiAwarenessContextPtr != NULL && SetProcessDpiAwarenessContextPtr != NULL) { + if (IsValidDpiAwarenessContextPtr(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)) { + // Added in Creators Update of Windows 10. + SetProcessDpiAwarenessContextPtr(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + } else if (IsValidDpiAwarenessContextPtr(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE)) { + // Added in Windows 10. + SetProcessDpiAwarenessContextPtr(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE); + } else if (IsValidDpiAwarenessContextPtr(DPI_AWARENESS_CONTEXT_SYSTEM_AWARE)) { + // Added in Windows 10. + SetProcessDpiAwarenessContextPtr(DPI_AWARENESS_CONTEXT_SYSTEM_AWARE); + } + } else { + // Added in Windows Vista. + SetProcessDPIAware(); + } + FreeLibrary(user32); +#else + // Added in Windows Vista. + SetProcessDPIAware(); +#endif +#endif + Py_RETURN_NONE; +} + static PyMethodDef functions[] = { {"display_is_valid", (PyCFunction)mpl_display_is_valid, METH_NOARGS, "display_is_valid()\n--\n\n" @@ -151,6 +192,11 @@ static PyMethodDef functions[] = { "Win32_SetForegroundWindow(hwnd, /)\n--\n\n" "Wrapper for Windows' SetForegroundWindow. On non-Windows platforms, \n" "a no-op."}, + {"Win32_SetProcessDpiAwareness_max", + (PyCFunction)mpl_SetProcessDpiAwareness_max, METH_NOARGS, + "Win32_SetProcessDpiAwareness_max()\n--\n\n" + "Set Windows' process DPI awareness to best option available.\n" + "On non-Windows platforms, does nothing."}, {NULL, NULL}}; // sentinel. static PyModuleDef util_module = { PyModuleDef_HEAD_INIT, "_c_internal_utils", "", 0, functions, NULL, NULL, NULL, NULL}; diff --git a/src/_tkagg.cpp b/src/_tkagg.cpp index 5f058d14e0f0..fc1fe2d82787 100644 --- a/src/_tkagg.cpp +++ b/src/_tkagg.cpp @@ -27,7 +27,9 @@ #endif #ifdef WIN32_DLL +#include #include +#include #define PSAPI_VERSION 1 #include // Must be linked with 'psapi' library #define dlsym GetProcAddress @@ -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) { @@ -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 + // 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, + 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 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 = @@ -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 +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]); } } } @@ -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; diff --git a/src/_tkmini.h b/src/_tkmini.h index d99b73291987..e45184166b67 100644 --- a/src/_tkmini.h +++ b/src/_tkmini.h @@ -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