From de4526ab4879a54e782a5f4c3e4ae97d2fb1611b Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Wed, 4 Nov 2020 22:51:29 -0500 Subject: [PATCH 1/7] tk: Make toolbar sizes DPI-independent. --- lib/matplotlib/backends/_backend_tk.py | 27 +++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/lib/matplotlib/backends/_backend_tk.py b/lib/matplotlib/backends/_backend_tk.py index bc69e08aa396..9d46ec7852ad 100644 --- a/lib/matplotlib/backends/_backend_tk.py +++ b/lib/matplotlib/backends/_backend_tk.py @@ -10,6 +10,7 @@ import tkinter.messagebox import numpy as np +from PIL import Image, ImageTk import matplotlib as mpl from matplotlib import _api, backend_tools, cbook, _c_internal_utils @@ -572,14 +573,8 @@ def set_cursor(self, cursor): pass 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 +583,24 @@ 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 + 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.config(image=image, height='18p', width='18p') + b._ntimage = image # Prevent garbage collection. 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): From 3802778e879fb36de2e75b9f1394887ed91cbffa Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 12 Nov 2020 20:44:55 -0500 Subject: [PATCH 2/7] Shorten Tk idle handling. Also, rename variable to match GTK3 backend. --- lib/matplotlib/backends/_backend_tk.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/lib/matplotlib/backends/_backend_tk.py b/lib/matplotlib/backends/_backend_tk.py index 9d46ec7852ad..95dd199e24fa 100644 --- a/lib/matplotlib/backends/_backend_tk.py +++ b/lib/matplotlib/backends/_backend_tk.py @@ -165,8 +165,7 @@ 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) self._tkcanvas = tk.Canvas( @@ -231,18 +230,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): """ @@ -448,8 +445,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) From 497fd23eae003d22b1218bbfec461749c418d21a Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Wed, 23 Dec 2020 01:39:03 -0500 Subject: [PATCH 3/7] Support returning physical size of the canvas. --- lib/matplotlib/backend_bases.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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 From c06cc86d47d983aa1845b40612cd99c6dcf4b821 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Wed, 23 Dec 2020 01:44:04 -0500 Subject: [PATCH 4/7] Add initial support for HiDPI in TkAgg on Windows. At the moment, Tk does not support updating the 'scaling' value when the monitor pixel ratio changes, or the window is moved to a different one. --- lib/matplotlib/backends/_backend_tk.py | 16 +++++++++++++++- src/_c_internal_utils.c | 14 ++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/lib/matplotlib/backends/_backend_tk.py b/lib/matplotlib/backends/_backend_tk.py index 95dd199e24fa..6fe96de58416 100644 --- a/lib/matplotlib/backends/_backend_tk.py +++ b/lib/matplotlib/backends/_backend_tk.py @@ -167,7 +167,7 @@ def __init__(self, figure=None, master=None, resize_callback=None): super().__init__(figure) 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 +176,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 +211,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: @@ -845,6 +858,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_SetDpiAwareness() window = tk.Tk(className="matplotlib") window.withdraw() diff --git a/src/_c_internal_utils.c b/src/_c_internal_utils.c index 4a61fe5b6ee7..77b0ef64251d 100644 --- a/src/_c_internal_utils.c +++ b/src/_c_internal_utils.c @@ -124,6 +124,15 @@ mpl_SetForegroundWindow(PyObject* module, PyObject *arg) #endif } +static PyObject* +mpl_SetDpiAwareness(PyObject* module) +{ +#ifdef _WIN32 + SetProcessDPIAware(); +#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 +160,11 @@ static PyMethodDef functions[] = { "Win32_SetForegroundWindow(hwnd, /)\n--\n\n" "Wrapper for Windows' SetForegroundWindow. On non-Windows platforms, \n" "a no-op."}, + {"Win32_SetDpiAwareness", + (PyCFunction)mpl_SetDpiAwareness, METH_NOARGS, + "Win32_SetDpiAwareness()\n--\n\n" + "Set Windows' process DPI awareness to be enabled. On non-Windows\n" + "platforms, does nothing."}, {NULL, NULL}}; // sentinel. static PyModuleDef util_module = { PyModuleDef_HEAD_INIT, "_c_internal_utils", "", 0, functions, NULL, NULL, NULL, NULL}; From d4422a26c8f71eb36e7a4df97335312524db7f33 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Wed, 21 Apr 2021 01:56:24 -0400 Subject: [PATCH 5/7] tk: Use a common Font object for toolbar labels. --- lib/matplotlib/backends/_backend_tk.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/backends/_backend_tk.py b/lib/matplotlib/backends/_backend_tk.py index 6fe96de58416..20e2c7f6d8a8 100644 --- a/lib/matplotlib/backends/_backend_tk.py +++ b/lib/matplotlib/backends/_backend_tk.py @@ -5,9 +5,10 @@ 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 @@ -525,16 +526,19 @@ 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) @@ -602,8 +606,10 @@ def _Button(self, text, image_file, toggle, command): if size > 24 else image_file) as im: image = ImageTk.PhotoImage(im.resize((size, size)), master=self) - b.config(image=image, height='18p', width='18p') + b.configure(image=image, height='18p', width='18p') b._ntimage = image # Prevent garbage collection. + else: + b.configure(font=self._label_font) b.pack(side=tk.LEFT) return b @@ -745,8 +751,10 @@ 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) From 559c095b82c5916f9b09f34a0b132f144ad54b74 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 22 Apr 2021 01:14:21 -0400 Subject: [PATCH 6/7] Set Windows DPI awareness to best possible setting. --- lib/matplotlib/backends/_backend_tk.py | 2 +- src/_c_internal_utils.c | 44 ++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/lib/matplotlib/backends/_backend_tk.py b/lib/matplotlib/backends/_backend_tk.py index 20e2c7f6d8a8..6de2870e280f 100644 --- a/lib/matplotlib/backends/_backend_tk.py +++ b/lib/matplotlib/backends/_backend_tk.py @@ -866,7 +866,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_SetDpiAwareness() + _c_internal_utils.Win32_SetProcessDpiAwareness_max() window = tk.Tk(className="matplotlib") window.withdraw() diff --git a/src/_c_internal_utils.c b/src/_c_internal_utils.c index 77b0ef64251d..532824c5d05b 100644 --- a/src/_c_internal_utils.c +++ b/src/_c_internal_utils.c @@ -125,10 +125,42 @@ mpl_SetForegroundWindow(PyObject* module, PyObject *arg) } static PyObject* -mpl_SetDpiAwareness(PyObject* module) +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; } @@ -160,11 +192,11 @@ static PyMethodDef functions[] = { "Win32_SetForegroundWindow(hwnd, /)\n--\n\n" "Wrapper for Windows' SetForegroundWindow. On non-Windows platforms, \n" "a no-op."}, - {"Win32_SetDpiAwareness", - (PyCFunction)mpl_SetDpiAwareness, METH_NOARGS, - "Win32_SetDpiAwareness()\n--\n\n" - "Set Windows' process DPI awareness to be enabled. On non-Windows\n" - "platforms, does nothing."}, + {"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}; From 741ee02d184586747155aa69ca2e5a8c584a3beb Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 22 Apr 2021 21:12:59 -0400 Subject: [PATCH 7/7] Handle DPI changes in TkAgg backend on Windows. --- lib/matplotlib/backends/_backend_tk.py | 73 ++++++++++-- setupext.py | 4 +- src/_tkagg.cpp | 149 +++++++++++++++++++++++-- src/_tkmini.h | 6 + 4 files changed, 212 insertions(+), 20 deletions(-) diff --git a/lib/matplotlib/backends/_backend_tk.py b/lib/matplotlib/backends/_backend_tk.py index 6de2870e280f..2f13408cd0f3 100644 --- a/lib/matplotlib/backends/_backend_tk.py +++ b/lib/matplotlib/backends/_backend_tk.py @@ -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): @@ -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 @@ -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)]: @@ -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) @@ -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) @@ -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) 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/_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