55import os .path
66import sys
77import tkinter as tk
8- from tkinter .simpledialog import SimpleDialog
98import tkinter .filedialog
9+ import tkinter .font
1010import tkinter .messagebox
11+ from tkinter .simpledialog import SimpleDialog
1112
1213import numpy as np
14+ from PIL import Image , ImageTk
1315
1416import matplotlib as mpl
1517from matplotlib import _api , backend_tools , cbook , _c_internal_utils
@@ -164,10 +166,9 @@ class FigureCanvasTk(FigureCanvasBase):
164166 alternative = "get_tk_widget().bind('<Configure>', ..., True)" )
165167 def __init__ (self , figure = None , master = None , resize_callback = None ):
166168 super ().__init__ (figure )
167- self ._idle = True
168- self ._idle_callback = None
169+ self ._idle_draw_id = None
169170 self ._event_loop_id = None
170- w , h = self .figure . bbox . size . astype ( int )
171+ w , h = self .get_width_height ( physical = True )
171172 self ._tkcanvas = tk .Canvas (
172173 master = master , background = "white" ,
173174 width = w , height = h , borderwidth = 0 , highlightthickness = 0 )
@@ -176,6 +177,7 @@ def __init__(self, figure=None, master=None, resize_callback=None):
176177 self ._tkcanvas .create_image (w // 2 , h // 2 , image = self ._tkphoto )
177178 self ._resize_callback = resize_callback
178179 self ._tkcanvas .bind ("<Configure>" , self .resize )
180+ self ._tkcanvas .bind ("<Map>" , self ._update_device_pixel_ratio )
179181 self ._tkcanvas .bind ("<Key>" , self .key_press )
180182 self ._tkcanvas .bind ("<Motion>" , self .motion_notify_event )
181183 self ._tkcanvas .bind ("<Enter>" , self .enter_notify_event )
@@ -210,6 +212,18 @@ def filter_destroy(event):
210212 self ._master = master
211213 self ._tkcanvas .focus_set ()
212214
215+ def _update_device_pixel_ratio (self , event = None ):
216+ # Tk gives scaling with respect to 72 DPI, but most (all?) screens are
217+ # scaled vs 96 dpi, and pixel ratio settings are given in whole
218+ # percentages, so round to 2 digits.
219+ ratio = round (self ._master .call ('tk' , 'scaling' ) / (96 / 72 ), 2 )
220+ if self ._set_device_pixel_ratio (ratio ):
221+ # The easiest way to resize the canvas is to resize the canvas
222+ # widget itself, since we implement all the logic for resizing the
223+ # canvas backing store on that event.
224+ w , h = self .get_width_height (physical = True )
225+ self ._tkcanvas .configure (width = w , height = h )
226+
213227 def resize (self , event ):
214228 width , height = event .width , event .height
215229 if self ._resize_callback is not None :
@@ -230,18 +244,16 @@ def resize(self, event):
230244
231245 def draw_idle (self ):
232246 # docstring inherited
233- if not self ._idle :
247+ if self ._idle_draw_id :
234248 return
235249
236- self ._idle = False
237-
238250 def idle_draw (* args ):
239251 try :
240252 self .draw ()
241253 finally :
242- self ._idle = True
254+ self ._idle_draw_id = None
243255
244- self ._idle_callback = self ._tkcanvas .after_idle (idle_draw )
256+ self ._idle_draw_id = self ._tkcanvas .after_idle (idle_draw )
245257
246258 def get_tk_widget (self ):
247259 """
@@ -407,6 +419,16 @@ def __init__(self, canvas, num, window):
407419 if self .toolbar :
408420 backend_tools .add_tools_to_container (self .toolbar )
409421
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+
410432 self ._shown = False
411433
412434 def _get_toolbar (self ):
@@ -418,6 +440,13 @@ def _get_toolbar(self):
418440 toolbar = None
419441 return toolbar
420442
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+
421450 def resize (self , width , height ):
422451 max_size = 1_400_000 # the measured max on xorg 1.20.8 was 1_409_023
423452
@@ -447,8 +476,8 @@ def destroy(*args):
447476 self ._shown = True
448477
449478 def destroy (self , * args ):
450- if self .canvas ._idle_callback :
451- self .canvas ._tkcanvas .after_cancel (self .canvas ._idle_callback )
479+ if self .canvas ._idle_draw_id :
480+ self .canvas ._tkcanvas .after_cancel (self .canvas ._idle_draw_id )
452481 if self .canvas ._event_loop_id :
453482 self .canvas ._tkcanvas .after_cancel (self .canvas ._event_loop_id )
454483
@@ -514,22 +543,52 @@ def __init__(self, canvas, window, *, pack_toolbar=True):
514543 if tooltip_text is not None :
515544 ToolTip .createToolTip (button , tooltip_text )
516545
546+ self ._label_font = tkinter .font .Font (size = 10 )
547+
517548 # This filler item ensures the toolbar is always at least two text
518549 # lines high. Otherwise the canvas gets redrawn as the mouse hovers
519550 # over images because those use two-line messages which resize the
520551 # toolbar.
521- label = tk .Label (master = self ,
552+ label = tk .Label (master = self , font = self . _label_font ,
522553 text = '\N{NO-BREAK SPACE} \n \N{NO-BREAK SPACE} ' )
523554 label .pack (side = tk .RIGHT )
524555
525556 self .message = tk .StringVar (master = self )
526- self ._message_label = tk .Label (master = self , textvariable = self .message )
557+ self ._message_label = tk .Label (master = self , font = self ._label_font ,
558+ textvariable = self .message )
527559 self ._message_label .pack (side = tk .RIGHT )
528560
529561 NavigationToolbar2 .__init__ (self , canvas )
530562 if pack_toolbar :
531563 self .pack (side = tk .BOTTOM , fill = tk .X )
532564
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+
533592 def _update_buttons_checked (self ):
534593 # sync button checkstates to match active mode
535594 for text , mode in [('Zoom' , _Mode .ZOOM ), ('Pan' , _Mode .PAN )]:
@@ -571,15 +630,25 @@ def set_cursor(self, cursor):
571630 except tkinter .TclError :
572631 pass
573632
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 ('18p' )
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 = '18p' , width = '18p' )
647+ button ._ntimage = image # Prevent garbage collection.
648+
574649 def _Button (self , text , image_file , toggle , command ):
575- if tk .TkVersion >= 8.6 :
576- PhotoImage = tk .PhotoImage
577- else :
578- from PIL .ImageTk import PhotoImage
579- image = (PhotoImage (master = self , file = image_file )
580- if image_file is not None else None )
581650 if not toggle :
582- b = tk .Button (master = self , text = text , image = image , command = command )
651+ b = tk .Button (master = self , text = text , command = command )
583652 else :
584653 # There is a bug in tkinter included in some python 3.6 versions
585654 # that without this variable, produces a "visual" toggling of
@@ -588,18 +657,22 @@ def _Button(self, text, image_file, toggle, command):
588657 # https://bugs.python.org/issue25684
589658 var = tk .IntVar (master = self )
590659 b = tk .Checkbutton (
591- master = self , text = text , image = image , command = command ,
660+ master = self , text = text , command = command ,
592661 indicatoron = False , variable = var )
593662 b .var = var
594- b ._ntimage = image
663+ b ._image_file = image_file
664+ if image_file is not None :
665+ # Explicit class because ToolbarTk calls _Button.
666+ NavigationToolbar2Tk ._set_image_for_button (self , b )
667+ else :
668+ b .configure (font = self ._label_font )
595669 b .pack (side = tk .LEFT )
596670 return b
597671
598672 def _Spacer (self ):
599- # Buttons are 30px high. Make this 26px tall +2px padding to center it.
600- s = tk .Frame (
601- master = self , height = 26 , relief = tk .RIDGE , pady = 2 , bg = "DarkGray" )
602- s .pack (side = tk .LEFT , padx = 5 )
673+ # Buttons are also 18pt high.
674+ s = tk .Frame (master = self , height = '18p' , relief = tk .RIDGE , bg = 'DarkGray' )
675+ s .pack (side = tk .LEFT , padx = '3p' )
603676 return s
604677
605678 def save_figure (self , * args ):
@@ -734,13 +807,18 @@ def __init__(self, toolmanager, window):
734807 tk .Frame .__init__ (self , master = window ,
735808 width = int (width ), height = int (height ),
736809 borderwidth = 2 )
810+ self ._label_font = tkinter .font .Font (size = 10 )
737811 self ._message = tk .StringVar (master = self )
738- self ._message_label = tk .Label (master = self , textvariable = self ._message )
812+ self ._message_label = tk .Label (master = self , font = self ._label_font ,
813+ textvariable = self ._message )
739814 self ._message_label .pack (side = tk .RIGHT )
740815 self ._toolitems = {}
741816 self .pack (side = tk .TOP , fill = tk .X )
742817 self ._groups = {}
743818
819+ def _rescale (self ):
820+ return NavigationToolbar2Tk ._rescale (self )
821+
744822 def add_toolitem (
745823 self , name , group , position , image_file , description , toggle ):
746824 frame = self ._get_groupframe (group )
@@ -847,6 +925,7 @@ def new_figure_manager_given_figure(cls, num, figure):
847925 with _restore_foreground_window_at_end ():
848926 if cbook ._get_running_interactive_framework () is None :
849927 cbook ._setup_new_guiapp ()
928+ _c_internal_utils .Win32_SetProcessDpiAwareness_max ()
850929 window = tk .Tk (className = "matplotlib" )
851930 window .withdraw ()
852931
0 commit comments