pythontkintertclwaitevent-driven

Is tkwait wait_variable/wait_window/wait_visibility broken?


I recently started to use tkwait casually and noticed that some functionality only works under special conditions. For example:

import tkinter as tk

def w(seconds):
    dummy = tk.Toplevel(root)
    dummy.title(seconds)
    dummy.after(seconds*1000, lambda x=dummy: x.destroy())
    dummy.wait_window(dummy)
    print(seconds)

root = tk.Tk()
for i in [5,2,10]:
    w(i)
root.mainloop()

The code above works just fine and as expected:

  1. The for loop calls the function
  2. The function runs and blocks the code for x seconds
  3. The window gets destroyed and the for loop continues

But in a more event driven environment these tkwait calls gets tricky. The documentation states quote:

If an event handler invokes tkwait again, the nested call to tkwait must complete before the outer call can complete.

Instead of an output of >>5 >>2 >>10 you will get >>10 >>2 >>5 because the nested call blocks the inner and the outer will block the inner. I suspect a nested event loop or an equivalent of the mainloop processes events in the normal fashion while waiting.

Am I doing something wrong by using this feature? Because if you think about it, nearly all tkinter dialog windows are using this feature and I've never read about this behavior before.

An event driven example might be:

import tkinter as tk

def w(seconds):
    dummy = tk.Toplevel(root)
    dummy.title(seconds)
    dummy.after(seconds*1000, lambda x=dummy: x.destroy())
    dummy.wait_window(dummy)
    print(seconds)

root = tk.Tk()
btn1 = tk.Button(
    root, command=lambda : w(5), text = '5 seconds')
btn2 = tk.Button(
    root, command=lambda : w(2), text = '2 seconds')
btn3 = tk.Button(
    root, command=lambda : w(10), text = '10 seconds')
btn1.pack()
btn2.pack()
btn3.pack()
root.mainloop()

As an additional problem that raises with wait_something is that it will prevent your process to finish if the wait_something never was released.


Solution

  • Basically, you need great care if you're using an inner event loop because:

    1. Conditions that would terminate the outer event loop aren't checked for until the inner event loop(s) are finished.
    2. It's really quite easy to end up recursively entering an inner event loop by accident.

    The recursive entry problem is usually most easily handled by disabling the path that enters the event loop while the inner event loop runs. There's often an obvious way to do this, such as disabling the button that you'd click.

    The condition handling is rather more difficult. In Tcl, you'd handle it by restructuring things slightly using a coroutine so that the thing that looks like an inner event loop isn't, but rather is just parking things until the condition is satisfied. That option is... rather more difficult to do in Python as the language implementation isn't fully non-recursive (and I'm not sure that Tkinter is set up to handle the mess of async function coloring). Fortunately, provided you're careful, it's not too difficult.

    It helps if you know that wait_window is waiting for a <Destroy> event where the target window is the toplevel (and not one of the inner components) and that destroying the main window will trigger it as all the other windows are also destroyed when you do that. In short, as long as you avoid reentrancy you'll be fine with it. You just need to arrange for the button that was clicked to be disabled while the wait is ongoing; that's good from a UX perspective too (the user can't do it, so don't provide the visual hint that they can).

    def w(seconds, button):
        dummy = tk.Toplevel(root)
        dummy.title(seconds)
        dummy.after(seconds*1000, lambda x=dummy: x.destroy())
        button["state"] = "disabled"  # <<< This, before the wait
        dummy.wait_window(dummy)
        button["state"] = "normal"    # <<< This, after the wait
        print(seconds)
    
    btn1 = tk.Button(root, text = '5 seconds')
    # Have to set the command after creation to bind the button handle to the callback
    btn1["command"] = (lambda : w(5, btn1))
    

    This all omits little things like error handling.