0

I am currently building a tkinter application. The core concept is that the user has to click on squares.

enter image description here

Like you can see in the picture we have a grid of squares from which the user can choose some. By clicking on them the user should see a small animation like you can see in the gif.

enter image description here

Problem

In this gif you can see the problem. My solution uses the multiprocessing module of python. But it seems because of the many threads I open in the animation process the visualization slows down and stops functioning how i would like it to function.

My attempt is quite simple:

process = Process(target=self.anim,args=("someargs",))
process.run()

Is there a way to bundle those animations in one process and avoid many threads or is python/tkinter not providing any way to solve my issue?

Thank you for your help.

  • Look at `.after` scripts. Also if you use multiple threads with `tkinter`, you risk having python crash without an error traceback. [This](https://stackoverflow.com/a/67738164/11106801) is the correct way of making a `tkinter` `for` loop, with delay – TheLizzard Sep 04 '21 at 17:52
  • Seems like you could be doing this in one thread instead of having one thread per square. The trick is for each animation to yield while it waits for the next frame -- you didn't share your `anim` code so I have to guess, but I assume you're currently having each thread `sleep()` in between frames, which means that the thread isn't freed up until the animation completes (capping the number of simultaneous animations to the size of the Process pool, which based on your screencap looks like 1). – Samwise Sep 04 '21 at 17:55
  • @Samwise you are correct i am using the `sleep()` method. I will try that too. Do you have an idea how to get the frames in tkinter? – PucciLaCanton Sep 04 '21 at 18:22
  • @TheLizzard i tried it with the after method too but it led to the same problem as with the `multiprocessing` and `threading` modules. – PucciLaCanton Sep 04 '21 at 18:24
  • You need to post your actual code to get help. From quickly browsing the tkinter doc it seems like `after` should be useful but I don't know how you're actually using it. – Samwise Sep 04 '21 at 18:51
  • I already found my problem with the after method. It was because I updated the canvas after each change although the after method updates the canvas. – PucciLaCanton Sep 04 '21 at 19:38
  • 1
    Multiprocessing and multithreading is overkill for this problem. – Bryan Oakley Sep 04 '21 at 20:21
  • 1
    @PucciLaCanton: the `after` method doesn't update the canvas. The updating happens by the event loop between the calls to functions via `after`. – Bryan Oakley Sep 04 '21 at 20:22

1 Answers1

2

Try this:

import tkinter as tk

# Patially taken from: https://stackoverflow.com/a/17985217/11106801
def create_circle(self, x:int, y:int, r:int, **kwargs) -> int:
    return self.create_oval(x-r, y-r, x+r, y+r, **kwargs)
def resize_circle(self, id:int, x:int, y:int, r:int) -> None:
    self.coords(id, x-r, y-r, x+r, y+r)
tk.Canvas.create_circle = create_circle
tk.Canvas.resize_circle = resize_circle


# Defining constants:
WIDTH:int = 400
HEIGHT:int = 400
SQUARES_WIDTH:int = 40
SQUARES_HEIGHT:int = 40


# Each square will be it's own class to make it easier to work with
class Square:
    # This can cause problems for people that don't know `__slots__`
    # __slots__ = ("canvas", "id", "x", "y", "filled")

    def __init__(self, canvas:tk.Canvas, x:int, y:int):
        self.canvas:tk.Canvas = canvas
        self.x:int = x
        self.y:int = y
        self.id:int = None
        self.filled:bool = False

    def fill(self, for_loop_counter:int=0) -> None:
        """
        This implements a tkinter friendly for loop with a delay of
        10 milliseconds. It creates a grows a circle to `radius = 20`
        """
        # If the square is already filled jsut return
        if self.filled:
            return None

        x:int = self.x + SQUARES_WIDTH // 2
        y:int = self.y + SQUARES_WIDTH // 2
        # If this is the first time, create the circle
        if for_loop_counter == 0:
            self.id:int = self.canvas.create_circle(x, y, 0, outline="", fill="black")
        # Grow the cicle
        else:
            self.canvas.resize_circle(self.id, x, y, for_loop_counter)

        # If we reach the highest radius:
        if for_loop_counter == 20:
            self.fill_square()
        # Otherwise call `self.fill` in 10 milliseconds with
        # `for_loop_counter+1` as a parameter
        else:
            self.canvas.after(10, self.fill, for_loop_counter+1)

    def fill_square(self) -> None:
        """
        Removed the circle and fills in the square
        """
        self.canvas.delete(self.id)
        x2:int = self.x + SQUARES_WIDTH
        y2:int = self.y + SQUARES_HEIGHT
        self.id = self.canvas.create_rectangle(self.x, self.y, x2, y2, fill="black", outline="")
        self.filled:bool = True


class App:
    # This can cause problems for people that don't know `__slots__`
    # __slots__ = ("root", "canvas", "squares")

    def __init__(self):
        self.root:tk.Tk = tk.Tk()

        self.canvas:tk.Canvas = tk.Canvas(self.root, width=WIDTH, height=HEIGHT)
        self.canvas.pack()

        # Create the squares:
        self.squares:list[Square] = []

        for x in range(0, WIDTH, SQUARES_WIDTH):
            for y in range(0, HEIGHT, SQUARES_HEIGHT):
                square:Square = Square(self.canvas, x, y)
                self.squares.append(square)

        self.canvas.bind("<Button-1>", self.on_mouse_clicked)
        self.canvas.bind("<B1-Motion>", self.on_mouse_clicked)

    def on_mouse_clicked(self, event:tk.Event) -> None:
        # Search for the square that was pressed
        mouse_x:int = event.x
        mouse_y:int = event.y
        for square in self.squares:
            if 0 < mouse_x - square.x < SQUARES_WIDTH:
                if 0 < mouse_y - square.y < SQUARES_HEIGHT:
                    # Tell that square that it should fill itself
                    square.fill()
                    return None

    def mainloop(self) -> None:
        self.root.mainloop()


if __name__ == "__main__":
    app = App()
    app.mainloop()

This implements a tkinter friendly for loop by scheduling calls to <Square>.fill every 10 milliseconds until the radius is 20. Then it fills the whole square.

To test the code, just press anywhere on the window. You can also drag the mouse around.


For clearing squares as well:

import tkinter as tk

# Patially taken from: https://stackoverflow.com/a/17985217/11106801
def create_circle(self, x:int, y:int, r:int, **kwargs) -> int:
    return self.create_oval(x-r, y-r, x+r, y+r, **kwargs)
def resize_circle(self, id:int, x:int, y:int, r:int) -> None:
    self.coords(id, x-r, y-r, x+r, y+r)
tk.Canvas.create_circle = create_circle
tk.Canvas.resize_circle = resize_circle


# Defining constants:
WIDTH:int = 400
HEIGHT:int = 400
SQUARES_WIDTH:int = 40
SQUARES_HEIGHT:int = 40


# Each square will be it's own class to make it easier to work with
class Square:
    # This can cause problems for people that don't know `__slots__`
    # __slots__ = ("canvas", "id", "x", "y", "filled")

    def __init__(self, canvas:tk.Canvas, x:int, y:int):
        self.canvas:tk.Canvas = canvas
        self.x:int = x
        self.y:int = y
        self.id:int = None
        self.filled:bool = False

    def fill(self, for_loop_counter:int=0) -> None:
        """
        This implements a tkinter friendly for loop with a delay of
        10 milliseconds. It creates a grows a circle to `radius = 20`
        """
        x:int = self.x + SQUARES_WIDTH // 2
        y:int = self.y + SQUARES_WIDTH // 2
        # If this is the first time, create the circle
        if for_loop_counter == 0:
            # If the square is already filled just return
            if self.filled:
                return None
            self.filled:bool = True
            self.id:int = self.canvas.create_circle(x, y, 0, outline="", fill="black")
        # User wants to clear the square
        elif self.id is None:
            return None
        # Grow the cicle
        else:
            self.canvas.resize_circle(self.id, x, y, for_loop_counter)

        # If we reach the highest radius:
        if for_loop_counter == 20:
            self.fill_square()
        # Otherwise call `self.fill` in 10 milliseconds with
        # `for_loop_counter+1` as a parameter
        else:
            self.canvas.after(10, self.fill, for_loop_counter+1)

    def fill_square(self) -> None:
        """
        Removed the circle and fills in the square
        """
        x2:int = self.x + SQUARES_WIDTH
        y2:int = self.y + SQUARES_HEIGHT
        self.canvas.delete(self.id)
        self.id = self.canvas.create_rectangle(self.x, self.y, x2, y2, fill="black", outline="")

    def clear(self) -> None:
        """
        Clears the square
        """
        self.filled:bool = False
        self.canvas.delete(self.id)
        self.id:int = None


class App:
    # This can cause problems for people that don't know `__slots__`
    __slots__ = ("root", "canvas", "squares")

    def __init__(self):
        self.root:tk.Tk = tk.Tk()

        self.canvas:tk.Canvas = tk.Canvas(self.root, width=WIDTH, height=HEIGHT)
        self.canvas.pack()

        # Create the squares:
        self.squares:list[Square] = []

        for x in range(0, WIDTH, SQUARES_WIDTH):
            for y in range(0, HEIGHT, SQUARES_HEIGHT):
                square:Square = Square(self.canvas, x, y)
                self.squares.append(square)

        self.canvas.bind("<Button-1>", self.on_mouse_clicked)
        self.canvas.bind("<B1-Motion>", self.on_mouse_clicked)

        self.canvas.bind("<Button-3>", self.on_mouse_clicked)
        self.canvas.bind("<B3-Motion>", self.on_mouse_clicked)

    def on_mouse_clicked(self, event:tk.Event) -> None:
        # Search for the square that was pressed
        mouse_x:int = event.x
        mouse_y:int = event.y
        for square in self.squares:
            if 0 < mouse_x - square.x < SQUARES_WIDTH:
                if 0 < mouse_y - square.y < SQUARES_HEIGHT:
                    # If the right mouse button is pressed
                    if (event.state & 1024 != 0) or (event.num == 3):
                        # Tell that square that it should clear itself
                        square.clear()
                    else:
                        # Tell that square that it should fill itself
                        square.fill()
                    return None

    def mainloop(self) -> None:
        self.root.mainloop()


if __name__ == "__main__":
    app = App()
    app.mainloop()
TheLizzard
  • 7,248
  • 2
  • 11
  • 31
  • @PucciLaCanton I updated my answer with an explanation on how it works. For more detail please look at the comments in the code. Also I didn't implement the lines that are supposed to separate the different squares. – TheLizzard Sep 04 '21 at 18:53
  • Thank you very much. I fully understood the tkinter after loops now. The reason why my try with the after method was very slow was because of I updated the canvas after a square is changed, wich was not very efficient because the `after` method already updates the canvas. – PucciLaCanton Sep 04 '21 at 19:37
  • btw, does the `App` even need `__slots__`? (first time learning about them (found out about them from your answer)) as far as I understand they reserve space in memory for those attributes, so it would be useful if creating multiple instances, but `App()` would usually be created only once so the space saved wouldn't be that much? – Matiiss Sep 04 '21 at 19:39
  • @Matiiss I recently started using `__slots__`. Technically it's useless, but it let's the reader know what variable I will be using. Also it prevents me from some typos because python would raise an error. Also if someone wants to port this code to a language like C/C++, where you need to specify the class attributes it will be much easier this way. I don't use `__slots__` for the memory reduction. – TheLizzard Sep 04 '21 at 19:46
  • 1
    @PucciLaCanton `.after` doesn't actually update the canvas. The `.mainloop()` handles both the `.after` scheduled calls and redraws the widgets. When the `.after` is done, it returns back to the `.mainloop`, which as usual redraws the canvas. But in practise what you are saying is identical to what actually happens as long as the timeout in the `.after` isn't 0. Try changing it to 0 and the 20 to 20000 to see what will happen. – TheLizzard Sep 04 '21 at 19:51