Tkinter Pillow Application 0.3

Version 0.3 of my Tkinter Pillow Application adds the ability to resize images, as well as Undo/Redo functionality. Versions 0.1 and 0.2 are here and here, and in this post I will just list the new code.

This is a screenshot of Version 0.3. The only visible differences are the Undo and Redo buttons (also present in the Edit menu) and the Image menu item which contains the Resize menu item.

The Code

The project consists of three Python files:

  • pillowappengine.py

  • pillowapptkinter.py

  • resizedialog.py

as well as a few graphics files for the buttons and icon. The files can be downloaded as a zip, or you can clone/download the Github repository if you prefer.

Source Code Links

ZIP File
GitHub

Undo and Redo

Applications such as spreadsheets or word processors handle simple inputs or changes such as typing a word or formatting a cell. These individual actions can be recorded and, if the users clicks the Undo button, they can be reversed to return the document to its exact previous state. This means that the stack used by undo and redo is very compact.

When editing images this is not the case. Even though most actions have an opposite equivalent, invoking them will of course not return the image to its exact previous state. Therefore every time a user makes a change to an image we need to keep a copy of the image in its previous state to revert to if the user changes their mind.

Adding undo/redo functionality requires changes to both pillowappengine.py and pillowapptkinter.py. I have only listed below the functions and methods affected, with new or changed code in yellow and code which existed in the previous version shown in white.

To this end I have added a list to the PillowAppEngine class, as well as a few extra variables which I'll describe as we come to them. The self.image attribute remains, and will be set to the current or most recent version of the image for display by the UI.

pillowappengine.py

    def __init__(self, on_image_change):

        self.image = None
        self._image_stack = []
        self._image_stack_index = None
        self._max_stack_size = 5
        self._undo_count = 0


        self.filepath = None
        self.saved = None
        self.on_image_change = on_image_change
.
.
.
    def open(self, filepath):

        """
        Attempts to create a Pillow Image from
        the filepath. If successful this becomes
        the "image" attribute.
        """

        try:
            image = Image.open(filepath)
            self.filepath = filepath
            self.saved = True
            self.__clear_stack()
            self.__add_to_stack(image)

            self.on_image_change()
        except Exception as e:
            self.__clear_stack()
            self.filepath = None
            raise
.
.
.
    def close(self):

        """
        Sets the image other attributes to None.
        """

        self.image = None
        self.filepath = None
        self.saved = None
        self.__clear_stack()
        self.on_image_change()
.
.
.
    def __clear_stack(self):

        self._image_stack.clear()
        self._image_stack_index = None
        self.image = None


    def __add_to_stack(self, image):

        if self._image_stack_index is None:
            self._image_stack.append(image)
            self._image_stack_index = 0
        else:
            self._image_stack_index += 1
            self._image_stack.insert(self._image_stack_index, image)

        self.image = image

        # Images in stack after current no longer needed for redo
        del self._image_stack[self._image_stack_index+1:]

        # If stack is larger than maximum remove first
        if len(self._image_stack) > self._max_stack_size:
            del self._image_stack[0]

        self._undo_count = 0


    def undoable(self):

        if len(self._image_stack) > 1 and self._image_stack_index > 0:
            return True
        else:
            return False


    def redoable(self):
        if self._undo_count > 0:
            return True
        else:
            return False


    def undo(self):

        if self.undoable():
            self._undo_count += 1
            self._image_stack_index -= 1
            self.image = self._image_stack[self._image_stack_index]

            self.on_image_change()


    def redo(self):
        if self.redoable():
            self._image_stack_index += 1
            self.image = self._image_stack[self._image_stack_index]
            self._undo_count -= 1
            self.on_image_change()

In __init__ I have added a list to the PillowAppEngine class for use as an undo/redo stack, as well as a few extra variables which I'll describe as we come to them. The self.image attribute remains, and will be set to the current or most recent version of the image for display by the UI.

In open we empty the stack and then add the newly opened image to it, and in close we also need to empty the stack.

The __clear_stack function is straightforward, and as well as clearing the stack it sets both the stack index and image attribute to None.

Next up is the __add_to_stack function which is rather more complex. If the stack is currently empty the image is simply added to it. If there are currently other images the new one is inserted after the current one. Note that we cannot just stick it on the end because if the action currently being carried out came after an undo the image which had been "undone" would be at the top of the stack, so adding the latest image to the top of the stack would mess up the sequence.

Next the image attribute is set to the latest image before a bit of housekeeping. Any images after the current one become unreachable by redo and are deleted, and if the stack is now larger than _max_stack_size (arbitrarily set to 5) the bottom item is deleted.

There are a couple of methods called undoable and redoable which check whether the stack is currently in a suitable state for either of these actions to be carried out.

Lastly we have the undo and redo functions themselves which simply move up or down the stack, setting the image attribute and calling on_image_change() to let the UI know it needs to update itself.

Now lets look at the changes to the UI.

pillowapptkinter.py

    def undo(self):

        """
        Used as an event handler for menu/button.
        """

        self.pae.undo()


    def redo(self):

        """
        Used as an event handler for menu/button.
        """

        self.pae.redo()

I have added Undo and Redo to the menu and toolbar but the code is no different from the other menu and toolbar items so is not listed here. What I have shown are the two event handler functions for the menu and toolbar items. As you can see they are extremely simple and just call the respective methods of the PillowAppEngine object.

Resize

Now lets move on to the resize functionality. This involves changing PillowAppEngine and the GUI, and also a new class for a dialog box. This is the new code in pillowapptkinter.py, set as the event handler for the Resize menu item.

pillowapptkinter.py

    def resize(self):

        """
        Used as an event handler for menu.
        """

        values = {"width": 0, "height": 0}

        resize_dialog = resizedialog.ResizeDialog(values)
        self.window.wait_window(resize_dialog.dlg)

        if values["width"] > 0 and values["width"] > 0 :
            self.pae.resize(values["width"], values["height"])

Firstly a dictionary is created with width and height keys. This is used to create a ResizeDialog object before we call the wait_window on the main window with the dialog as an argument. Basically this pauses the code in this function until the dialog is closed. Lastly we check that width and height are both more than 0 and, if so, we call the PillowAppEngine's resize method. The code for that method is listed below.

pillowappengine.py

    def resize(self, width, height):

        """
        Uses the Pillow resize method.
        """

        resized = self.image.resize((width, height))
        self.__add_to_stack(resized)
        self.saved = False
        self.on_image_change()

This method takes width and height arguments and then uses Pillow's resize method which returns a new image rather than editing in-place. This is then added to the stack before setting saved to false and calling on_image_change().

I'll wind up the code with the ResizeDialog class.

resizedialog.py

from tkinter import *


class ResizeDialog(object):

    def __init__(self, values):

        self.values = values

        self.dlg = Toplevel()

        self.dlg.title("Resize")

        self.dlg.grab_set()

        self.width_label = Label(self.dlg, text="Width")
        self.width_label.grid(row=0, column=0, padx=4, pady=4, sticky=W)

        self.width_box = Entry(self.dlg)
        self.width_box.grid(row=0, column=1, padx=4, pady=4, sticky=W)

        self.height_label = Label(self.dlg, text="Height")
        self.height_label.grid(row=1, column=0, padx=4, pady=4, sticky=W)

        self.height_box = Entry(self.dlg)
        self.height_box.grid(row=1, column=1, padx=4, pady=4, sticky=W)

        self.ok_button = Button(self.dlg, text='OK', command=self.ok)
        self.ok_button.grid(row=2, column=0, columnspan=2, padx=4, pady=4, sticky=W+E)

        self.cancel_button = Button(self.dlg, text='Cancel', command=self.cancel)
        self.cancel_button.grid(row=3, column=0, columnspan=2, padx=4, pady=4, sticky=W+E)


    def ok(self):

        self.values["width"] = int(self.width_box.get())
        self.values["height"] = int(self.height_box.get())

        self.dlg.destroy()


    def cancel(self):

        self.values["width"] = 0
        self.values["height"] = 0

        self.dlg.destroy()

The __init__ function takes a dictionary argument which will have its values set to those entered by the user. A Toplevel object is created, this being a Tkinter class for us as a dialog, and its title set. The grab_set is supposed to make the dialog modal (ie. block the main window until it is closed) but it seems rather unreliable.

The rest of the function should be familiar: it just creates controls to enter height and width values, and OK and Cancel buttons.

The other functions are event handlers for the buttons. The ok function sets the dictionary values to the values entered by the user, cast to ints. There is no validation yet, a task for the next version. Finally the dialog is destroyed.

In cancel we set both the height and width to 0 and destroy the dialog.

Now we can run the program with this command, and check out the new features:

Running the program

python3.7 pillowapptkinter.py