Search code examples
pythontkintercanvastasktkinter-canvas

Python Tkinter: Window freezing on user click during update_idletasks()


I am trying to make an app in tkinter to visualize data structures and algorithms. However, I have an issue with the way the program is updating the canvas during the visualisation process. When the visualisation process takes more than ~2 seconds, the program freezes if the user clicks on the window (not responding), and resumes when the visualisation is complete. I am sure what causes this problem, and it doesn't arise if the user never clicks. Any ideas as to the actual problem and/or issue? The app is still in a testing phase, so it isn't super clean, apologies for that.

"""Module for visualizing data structures and algorithms"""
import random
import tkinter as tk
from tkinter import HORIZONTAL


def bubble(data, draw_data, speed):
    data_length = len(data)

    for i in range(data_length):
        for j in range(0, data_length - i - 1):

            if data[j] > data[j + 1]:
                data[j], data[j + 1] = data[j + 1], data[j]

                # if swapped then color becomes Green else stays Red
                draw_data(data, ['Green' if x == j + 1 else 'Red' for x in range(len(data))])
                window.after(int(speed * 1000), bubble, data, draw_data, int(speed * 1000))

    # sorted elements generated with Green color
    draw_data(data, ['Green' for _ in range(len(data))])


window = tk.Tk()
window.minsize(700, 580)

app_width, app_height = 700, 580
screen_width, screen_height = window.winfo_screenwidth(), window.winfo_screenheight()

mid_x = (screen_width - app_width) // 2
mid_y = (screen_height - app_height) // 2

window.title("StructViz")
window.iconbitmap("./assets/favicon.ico")
window.geometry(f'{app_width}x{app_height}+{mid_x}+{mid_y}')

select_alg = tk.StringVar()
data = []


def regenerate():
    global data

    minval = int(minEntry.get())

    maxval = int(maxEntry.get())

    sizeval = int(amountEntry.get())

    data = []
    for _ in range(sizeval):
        data.append(random.randint(minval, maxval + 1))

    draw_data(data, ['Red' for _ in range(len(data))])


def draw_data(data, colorlist):
    structVizC.delete("all")

    canvas_height = 380
    canvas_width = 500
    x_width = canvas_width / (len(data) + 1)
    offset = 30
    spacing = 10

    normalized_data = [i / max(data) for i in data]

    for i, height in enumerate(normalized_data):
        x0 = i * x_width + offset + spacing
        y0 = canvas_height - height * 340

        x1 = ((i + 1) * x_width + offset)
        y1 = canvas_height

        structVizC.create_rectangle(x0, y0, x1, y1, fill=colorlist[i])
        structVizC.create_text(x0 + 2, y0, anchor='se', text=str(data[i]))
    window.update_idletasks()


def start_algorithm():
    global data
    bubble(data, draw_data, speedbar.get())


window.columnconfigure(0, weight=0)
window.columnconfigure(1, weight=45)
window.rowconfigure((0, 1), weight=1)
window.rowconfigure(2, weight=45)

navbarLB = tk.Listbox(window, selectmode=tk.SINGLE)
for item in ["Option 1", "Option 2", "Option 3"]:
    navbarLB.insert(tk.END, item)
navbarLB.grid(row=0, column=0, rowspan=3, sticky='nsew')

userSettingsF = (tk.Frame(window, background='bisque2')
                 .grid(row=0, column=1, columnspan=2, rowspan=2, sticky='news', padx=7, pady=5))

# Change navbarLB.get(0) when user generates a selected option ( from func selected_item() )
AlgorithmL = tk.Label(userSettingsF, text=navbarLB.get(0), background='bisque2')
AlgorithmL.grid(row=0, column=1, sticky='nw', padx=10, pady=10)

amountEntry = tk.Scale(userSettingsF, from_=5, to=40, label='Amount', background='bisque2',
                       orient=HORIZONTAL, resolution=1, cursor='arrow')
amountEntry.grid(row=0, column=1, sticky='n', padx=10, pady=10)

minEntry = tk.Scale(userSettingsF, from_=0, to=10, resolution=1, background='bisque2',
                    orient=HORIZONTAL, label="Minimum Value")
minEntry.grid(row=1, column=1, sticky='s', padx=10, pady=10)

maxEntry = tk.Scale(userSettingsF, from_=10, to=100, resolution=1, background='bisque2',
                    orient=HORIZONTAL, label="Maximum Value")
maxEntry.grid(row=1, column=1, sticky='se', padx=10, pady=10)

speedbar = tk.Scale(userSettingsF, from_=0.10, to=2.0, length=100, digits=2, background='bisque2',
                    resolution=0.1, orient=HORIZONTAL, label="Speed")
speedbar.grid(row=0, column=1, sticky='ne', padx=10, pady=10)

tk.Button(userSettingsF, text="Start", bg="Blue", command=start_algorithm, background='bisque2').grid(
    row=1, column=1, sticky='nw', padx=10, pady=10)

tk.Button(userSettingsF, text="Regenerate", bg="Red", command=regenerate, background='bisque2').grid(
    row=1, column=1, sticky='sw', padx=10, pady=10)

structVizC = tk.Canvas(window, background='bisque2')
structVizC.grid(row=2, column=1, sticky='news', padx=5, pady=5)

window.mainloop()

During visualisation the app should display the process. On user click, it should do nothing. Instead, it freezes (not responding) until the process is complete.


Solution

  • GUI doesn't freeze when I use window.update() instead of window.update_idletasks() or when I use both together.

    But real problem is bubble() which runs window.after(...) but it never exit bubble() after window.after() and it can't make delay before next drawing - so it can't control speed. And this also makes problem with update_idletasks().

    It needs to exit nested loops but it makes problem how to jump back into nested loop. And this may need to use yield which can exit function and later it can start function after yield instead of starting from the beginning.

    Here bubble() with yield but without window.after()

    def bubble(data, draw_data):
        data_length = len(data)
    
        for i in range(data_length):
            for j in range(0, data_length - i - 1):
    
                if data[j] > data[j + 1]:
                    data[j], data[j + 1] = data[j + 1], data[j]
                    draw_data(data, ['Green' if x == j + 1 else 'Red' for x in range(data_length)])
                    yield
        
        # sorted elements generated with Green color
        draw_data(data, ['Green' for _ in range(len(data))])
    
        # here python runs as default `return None`
    

    And here function which runs bubble() and it uses window.after()

    generator = None
    
    def repeater(data, draw_data, speed):
        global generator
        
        # run it only once - at start
        if not generator:
            generator = bubble(data, draw_data)
    
        try:
            # run function - it will raise `StopIteration` when it use `return` instead of `yield`
            next(generator)  
            
            # set next execution after some time
            window.after(speed, repeater, data, draw_data, speed)     
            
            # exit this function
            return
        except StopIteration:  # 
            # reset value after last execution 
            generator = None
    

    And now button starts repeater instead of bubble

    def start_algorithm():
        repeater(data, draw_data, int(speedbar.get()*1000))
    

    And now it can run with different speeds.
    And it also works correctly without window.update() and window.update_idletasks()


    Full working code.

    I increased Amount to 100, and reduced Speed to 0.01 - to see faster animation with more values.

    I also added boolean variable running_animation to stop animation when you press again button Start during animation. But it works rather like pause because when you press it again then it continues animation. I use it also to stop animation when you press Regenerate but it needs some changes to work correctly.

    import random
    import tkinter as tk
    
    # --- classes ---
    
    # --- functions ---
    
    def bubble(data, draw_data):
        data_length = len(data)
    
        for i in range(data_length):
            #print('i:', i)
            for j in range(0, data_length - i - 1):
                #print('j:', j)
    
                if data[j] > data[j + 1]:
                    data[j], data[j + 1] = data[j + 1], data[j]
                    draw_data(data, ['Green' if x == j + 1 else 'Red' for x in range(data_length)])
                    #print('before yield')
                    yield
                    #print('after yield') 
    
        #print('finish')
        # sorted elements generated with Green color
        draw_data(data, ['Green' for _ in range(len(data))])
    
    generator = None
    
    def repeater(data, draw_data, speed):
        global generator
        
        if not generator:
            generator = bubble(data, draw_data)
    
        try:
            #print('next')
            next(generator)
            
            if running_animation:
                #print('after:', )
                window.after(speed, repeater, data, draw_data, speed)     
            else:
                generator = None
            
            #print('return')
            return
        except StopIteration:
            #print('StopIteration')
            generator = None
    
    def regenerate():
        global data
        global running_animation
        
        print('regenerate')
    
        if running_animation:
            running_animation = False
            
        minval = int(minEntry.get())
        maxval = int(maxEntry.get())
        sizeval = int(amountEntry.get())
    
        data = []
        for _ in range(sizeval):
            data.append(random.randint(minval, maxval + 1))
    
        draw_data(data, ['Red' for _ in range(len(data))])
    
    
    def draw_data(data, colorlist):
        print('draw')
        
        structVizC.delete("all")
    
        canvas_height = 380
        canvas_width = 500
        x_width = canvas_width / (len(data) + 1)
        offset = 30
        spacing = 10
    
        normalized_data = [i / max(data) for i in data]
    
        for i, height in enumerate(normalized_data):
            x0 = i * x_width + offset + spacing
            y0 = canvas_height - height * 340
    
            x1 = ((i + 1) * x_width + offset)
            y1 = canvas_height
    
            structVizC.create_rectangle(x0, y0, x1, y1, fill=colorlist[i])
            structVizC.create_text(x0 + 2, y0, anchor='se', text=str(data[i]))
        
        #window.update_idletasks()
        #window.update()
    
    def start_algorithm():
        global running_animation 
        
        if not running_animation:
            running_animation = True
            repeater(data, draw_data, int(speedbar.get()*1000))
            start_button['text'] = 'Stop'
        else:
            running_animation = False
            start_button['text'] = 'Start'
    
    # --- main ---
    
    running_animation = False
    
    window = tk.Tk()
    window.minsize(700, 580)
    
    app_width, app_height = 700, 580
    screen_width, screen_height = window.winfo_screenwidth(), window.winfo_screenheight()
    
    mid_x = (screen_width - app_width) // 2
    mid_y = (screen_height - app_height) // 2
    
    window.title("StructViz")
    #window.iconbitmap("./assets/favicon.ico")
    window.geometry(f'{app_width}x{app_height}+{mid_x}+{mid_y}')
    
    select_alg = tk.StringVar()
    data = []
    
    
    window.columnconfigure(0, weight=0)
    window.columnconfigure(1, weight=45)
    window.rowconfigure((0, 1), weight=1)
    window.rowconfigure(2, weight=45)
    
    navbarLB = tk.Listbox(window, selectmode=tk.SINGLE)
    for item in ["Option 1", "Option 2", "Option 3"]:
        navbarLB.insert(tk.END, item)
    navbarLB.grid(row=0, column=0, rowspan=3, sticky='nsew')
    
    userSettingsF = (tk.Frame(window, background='bisque2')
                     .grid(row=0, column=1, columnspan=2, rowspan=2, sticky='news', padx=7, pady=5))
    
    # Change navbarLB.get(0) when user generates a selected option ( from func selected_item() )
    AlgorithmL = tk.Label(userSettingsF, text=navbarLB.get(0), background='bisque2')
    AlgorithmL.grid(row=0, column=1, sticky='nw', padx=10, pady=10)
    
    amountEntry = tk.Scale(userSettingsF, from_=5, to=100, label='Amount', background='bisque2',
                           orient="horizontal", resolution=1, cursor='arrow')
    amountEntry.grid(row=0, column=1, sticky='n', padx=10, pady=10)
    
    minEntry = tk.Scale(userSettingsF, from_=0, to=10, resolution=1, background='bisque2',
                        orient="horizontal", label="Minimum Value")
    minEntry.grid(row=1, column=1, sticky='s', padx=10, pady=10)
    
    maxEntry = tk.Scale(userSettingsF, from_=10, to=100, resolution=1, background='bisque2',
                        orient="horizontal", label="Maximum Value")
    maxEntry.grid(row=1, column=1, sticky='se', padx=10, pady=10)
    
    speedbar = tk.Scale(userSettingsF, from_=0.01, to=1.0, length=100, digits=3, background='bisque2',
                        resolution=0.01, orient="horizontal", label="Speed")
    speedbar.grid(row=0, column=1, sticky='ne', padx=10, pady=10)
    
    start_button = tk.Button(userSettingsF, text="Start", bg="Blue", command=start_algorithm, background='bisque2')
    start_button.grid(row=1, column=1, sticky='nw', padx=10, pady=10)
    
    tk.Button(userSettingsF, text="Regenerate", bg="Red", command=regenerate, background='bisque2').grid(
        row=1, column=1, sticky='sw', padx=10, pady=10)
    
    structVizC = tk.Canvas(window, background='bisque2')
    structVizC.grid(row=2, column=1, sticky='news', padx=5, pady=5)
    
    window.mainloop()