Correct scrolling/resizing behavior of FigureCanvasTkAgg canvas widget when the contained figure is resized

Viewed 732

I am trying to make an interactive plotting GUI using Tkinter and matplotlib (python 3.7 and matplotlib 3.0.0) I want the user to be able to resize the figure as it is displayed on the screen without resizing the window, and have achieved this by editing the dpi, width, and height properties of the figure. So far, this works, but if the figure is bigger than the display area, I want the user to be able to scroll to see the whole figure. And if the figure is smaller than the display area, I want the scrollbars to be disabled.

I have tried applying scrollbars directly to the FigureCanvasTkAgg object itself as well as embedding the FigureCanvasTkAgg canvas inside a second scrollable canvas, but it seems that the problem is that the drawable area of the FigureCanvasTkAgg widget doesn't change when the figure size changes. Minimal code reproducing the problem is below. Is there some property of the FigureCanvasTkAgg object that I'm missing that makes this work?

import tkinter as tk
from tkinter import ttk
from tkinter.simpledialog import askfloat
from matplotlib.figure import Figure
from matplotlib.axes   import Axes
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
          
class InteractivePlot(tk.Frame):
    def __init__(self,master,**kwargs):
        super().__init__(master,**kwargs)
        self._figure = Figure(dpi=150)
        self._canvas = FigureCanvasTkAgg(self._figure, master=self)
        self._sizebutton = tk.Button(self,text="Size (in.)", command=self._change_size)
        self._axis = self._figure.add_subplot(111)

        # Plot some data just to have something to look at.
        self._axis.plot([0,1,2,3,4,5],[1,1,3,3,5,5],label='Dummy Data')

        self._cwidg = self._canvas.get_tk_widget()
        self._scrx = ttk.Scrollbar(self,orient="horizontal", command=self._cwidg.xview)
        self._scry = ttk.Scrollbar(self,orient="vertical", command=self._cwidg.yview)
        self._cwidg.configure(yscrollcommand=self._scry.set, xscrollcommand=self._scrx.set)

        self._cwidg.bind(
            "<Configure>",
            lambda e: self._cwidg.configure(
                scrollregion=self._cwidg.bbox("all")
            )
        )

        self._sizebutton.grid(row=0,column=0,sticky='w')
        self._cwidg. grid(row=1,column=0,sticky='news')
        self._scrx.  grid(row=2,column=0,sticky='ew')
        self._scry.  grid(row=1,column=1,sticky='ns')

        self.rowconfigure(1,weight=1)
        self.columnconfigure(0,weight=1)

        self._canvas.draw()
    
    def _change_size(self):
        newsize = askfloat('Size','Input new size in inches')
        if newsize is None:
            return
        w = newsize
        h = newsize/1.8
        self._figure.set_figwidth(w)
        self._figure.set_figheight(h)
        self._canvas.draw()
        
root = tk.Tk()

plt = InteractivePlot(root,width=400,height=400)

plt.pack(fill=tk.BOTH,expand=True)

root.mainloop()
2 Answers

The main issue here is that the matplotlib figure is meant to resize with self._cwidg. Because the figure is assumed to always be of the same size as self._cwidg, matplotlib only redraws the portion of the figure which is visible in self._cwidg and the latter is not resized when the figure size changes.

A work around is to use an extra canvas self._scroll_canvas and embed self._cwidg as a window inside it. Then I modified _change_size() in the following way:

def _change_size(self):
    newsize = askfloat('Size', 'Input new size in inches')
    if newsize is None:
        return
    w = newsize
    h = newsize/1.8
    self._cwidg.configure(width=int(w*self._conv_ratio), height=int(h*self._conv_ratio))
    self._scroll_canvas.configure(scrollregion=self._scroll_canvas.bbox("all"))

I directly resize self._cwidg which in turns resizes the figure, ensuring that every part of it is redrawn. Then I update the scrollregion. Here is the full code:

import tkinter as tk
from tkinter import ttk
from tkinter.simpledialog import askfloat
from matplotlib.figure import Figure
from matplotlib.axes import Axes
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg

class InteractivePlot(tk.Frame):
    def __init__(self, master, **kwargs):
        super().__init__(master, **kwargs)

        self._scroll_canvas = tk.Canvas(self)
        self._figure = Figure(dpi=150)
        self._canvas = FigureCanvasTkAgg(self._figure, master=self._scroll_canvas)
        self._sizebutton = tk.Button(self, text="Size (in.)", command=self._change_size)
        self._axis = self._figure.add_subplot(111)

        # Plot some data just to have something to look at.
        self._axis.plot([0, 1, 2, 3, 4, 5], [1, 1, 3, 3, 5, 5], label='Dummy Data')



        self._cwidg = self._canvas.get_tk_widget()
        self._scroll_canvas.create_window(0, 0, anchor='nw', window=self._cwidg)

        self._scrx = ttk.Scrollbar(self, orient="horizontal", command=self._scroll_canvas.xview)
        self._scry = ttk.Scrollbar(self, orient="vertical", command=self._scroll_canvas.yview)
        self._scroll_canvas.configure(yscrollcommand=self._scry.set, xscrollcommand=self._scrx.set)


        self._sizebutton.grid(row=0, column=0, sticky='w')
        self._scroll_canvas.grid(row=1, column=0, sticky='news')
        self._scrx.grid(row=2, column=0, sticky='ew')
        self._scry.grid(row=1, column=1, sticky='ns')

        self.rowconfigure(1, weight=1)
        self.columnconfigure(0, weight=1)

        self._canvas.draw()

        wi = self._figure.get_figwidth()
        wp = self._cwidg.winfo_reqwidth(),
        self._conv_ratio = wp / wi  # get inch to pixel conversion factor
        self._scroll_canvas.configure(width=wp, height=self._cwidg.winfo_reqheight())
        self._scroll_canvas.configure(scrollregion=self._scroll_canvas.bbox("all"))

    def _change_size(self):
        newsize = askfloat('Size', 'Input new size in inches')
        if newsize is None:
            return
        w = newsize
        h = newsize/1.8
        self._cwidg.configure(width=int(w*self._conv_ratio), height=int(h*self._conv_ratio))
        self._scroll_canvas.configure(scrollregion=self._scroll_canvas.bbox("all"))

root = tk.Tk()
plt = InteractivePlot(root, width=400, height=400)
plt.pack(fill=tk.BOTH, expand=True)
root.mainloop()

Thanks to j_4321's answer, and after digging around in the source code for the FigureCanvasTk object, I came up with the following solution that does everything I need it to.

It looks like what the canvas object does is use tkinter.Canvas.create_image to generate a new image of the figure every time the canvas widget is resized, keeping the DPI of the figure constant and setting the width and height of the figure to keep it the same size as the canvas widget. Since I want the canvas widget to resize to the figure, I calculate the width and height from the figure properties and then pass a custom Event object to FigureCanvasTk.resize, the callback function for the canvas widget's <Configure> event.

The last trick is to set the scrollregion of the canvas to be only the size of the last canvas item created. It looks like previous iterations of the figure are not deleted from the canvas (seems like a memory leak?), so if you try to set the scrollregion to Canvas.bbox('all'), it will set the scrollregion to the size of the largest version of the figure, not the current version of the figure.

Here is the full example code:

import tkinter as tk
from tkinter import ttk
from tkinter.simpledialog import askfloat
from matplotlib.figure import Figure
from matplotlib.axes   import Axes
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
          
class InteractivePlot(tk.Frame):
    def __init__(self,master,**kwargs):
        super().__init__(master,**kwargs)
        self._figure = Figure(dpi=150)
        self._canvas = FigureCanvasTkAgg(self._figure, master=self)
        buttonframe = tk.Frame(self)
        self._sizebutton = tk.Button(buttonframe,text="Size (in.)", command=self._change_size)
        self._dpibutton = tk.Button(buttonframe,text="DPI", command=self._change_dpi)
        self._axis = self._figure.add_subplot(111)

        # Plot some data just to have something to look at.
        self._axis.plot([0,1,2,3,4,5],[1,1,3,3,5,5],label='Dummy Data')

        self._cwidg = self._canvas.get_tk_widget()
        self._scrx = ttk.Scrollbar(self,orient="horizontal", command=self._cwidg.xview)
        self._scry = ttk.Scrollbar(self,orient="vertical", command=self._cwidg.yview)
        self._cwidg.configure(yscrollcommand=self._scry.set, xscrollcommand=self._scrx.set)

        self._cwidg.bind("<Configure>",self._refresh)

        self._sizebutton.grid(row=0,column=0,sticky='w')
        self._dpibutton.grid(row=0,column=1,sticky='w')
        buttonframe.grid(row=0,column=0,columnspan=2,sticky='W')
        self._cwidg. grid(row=1,column=0,sticky='news')
        self._scrx.  grid(row=2,column=0,sticky='ew')
        self._scry.  grid(row=1,column=1,sticky='ns')

        self.rowconfigure(1,weight=1)
        self.columnconfigure(0,weight=1)

        # Refresh the canvas to show the new plot
        self._canvas.draw()
    
    # Figure size change button callback
    def _change_size(self):
        newsize = askfloat('Size','Input new size in inches')
        if newsize is None:
            return
        w = newsize
        h = newsize/1.8
        self._figure.set_figwidth(w)
        self._figure.set_figheight(h)
        self._refresh()

    # Figure DPI change button callback
    def _change_dpi(self):
        newdpi = askfloat('DPI', 'Input a new DPI for the figure')
        if newdpi is None:
            return
        self._figure.set_dpi(newdpi)
        self._refresh()
    
    # Refresh function to make the figure canvas widget display the entire figure
    def _refresh(self,event=None):
        # Get the width and height of the *figure* in pixels
        w = self._figure.get_figwidth()*self._figure.get_dpi()
        h = self._figure.get_figheight()*self._figure.get_dpi()

        # Generate a blank tkinter Event object
        evnt = tk.Event()
        # Set the "width" and "height" values of the event
        evnt.width = w
        evnt.height = h

        # Set the width and height of the canvas widget
        self._cwidg.configure(width=w,height=h)
        self._cwidg.update_idletasks()

        # Pass the generated event object to the FigureCanvasTk.resize() function
        self._canvas.resize(evnt)
        # Set the scroll region to *only* the area of the last canvas item created.
        # Otherwise, the scrollregion will always be the size of the largest iteration
        # of the figure.
        self._cwidg.configure(scrollregion=self._cwidg.bbox(self._cwidg.find_all()[-1]))
        
root = tk.Tk()

plt = InteractivePlot(root,width=400,height=400)

plt.pack(fill=tk.BOTH,expand=True)

root.mainloop()
Related