zenmaster43
zenmaster43

Reputation: 25

How do I exit my python3 application cleanly from asyncio event loop run_forever() when user clicks tkinter root window close box?

I'm trying to make a python3 application for my Raspberry Pi 4B and I have the tkinter windows working fine, but need to add asynchronous handling to allow tkinter widgets to respond while processing asynchronous actions initiated by the window's widgets.

The test code is using asyncio and tkinter. However, without root.mainloop(), since asyncio loop.run_forever() is called at the end instead. The idea is that when the user clicks the main window's close box, RequestQuit() gets called to set the quitRequested flag and then when control returns to the event loop, root.after_idle(AfterIdle) would cause AfterIdle to be called, where the flag is checked and if true, the event loop is stopped, or that failing, the app is killed with exit(0).

The loop WM_DELETE_WINDOW protocol coroutine RequestQuit is somehow not getting called when the user clicks the main window close box, so the AfterIdle coroutine never gets the flag to quit and I have to kill the app by quitting XQuartz.

I'm using ssh via Terminal on MacOS X Big Sur 11.5.2, connected to a Raspberry Pi 4B with Python 3.7.3.

What have I missed here?

(I haven't included the widgets or their handlers or the asynchronous processing here, for brevity, since they aren't part of the problem at hand.)

from tkinter import *
from tkinter import messagebox
import aiotkinter
import asyncio

afterIdleProcessingIntervalMsec = 500 # Adjust for UI responsiveness here.
busyProcessing = False
quitRequested = False

def RequestQuit():
  global quitRequested
  global busyProcessing
  if busyProcessing:
    answer = messagebox.askquestion('Exit application', 'Do you really want to abort the ongoing processing?', icon='warning')
    if answer == 'yes':
      quitRequested = True

def AfterIdle():
  global quitRequested
  global loop
  global root
  if not quitRequested:
    root.after(afterIdleProcessingIntervalMsec, AfterIdle)
  else:
    print("Destroying GUI at: ", time.time())
    try:
      loop.stop()
      root.destroy()
    except:
      exit(0)

if __name__ == '__main__':
  global root
  global loop
  asyncio.set_event_loop_policy(aiotkinter.TkinterEventLoopPolicy())
  loop = asyncio.get_event_loop()
  root = Tk()
  root.protocol("WM_DELETE_WINDOW", RequestQuit)
  root.after_idle(AfterIdle)
  # Create and pack widgets here.
  loop.run_forever()

Upvotes: 1

Views: 1347

Answers (1)

Paul Cornelius
Paul Cornelius

Reputation: 10936

The reason why your program doesn't work is that there is no Tk event loop, or its equivalent. Without it, Tk will not process events; no Tk callback functions will run. So your program doesn't respond to the WM_DELETE_WINDOW event, or any other.

Fortunately Tk can be used to perform the equivalent of an event loop as an asyncio.Task, and it's not even difficult. The basic concept is to write a function like this, where "w" is any tk widget:

async def new_tk_loop():
    while some_boolean:
        w.update()
        await asyncio.sleep(sleep_interval_in_seconds)

This function should be created as an asyncio.Task when you are ready to start processing tk events, and should continue to run until you are ready to stop doing that.

Here is a class, TkPod, that I use as the basic foundation of any Tk + asyncio program. There is also a trivial little demo program, illustrating how to close the Tk loop from another Task. If you click the "X" before 5 seconds pass, the program will close immediately by exiting the mainloop function. After 5 seconds the program will close by cancelling the mainloop task.

I use a default sleep interval of 0.05 seconds, which seems to work pretty well.

When exiting such a program there are a few things to think about.

When you click on the "X" button on the main window, the object sets its app_closing variable to false. If you need to do some other clean-up, you can subclass Tk and over-ride the method close_app.

Exiting the mainloop doesn't call the destroy function. If you need to do that, you must do it separately. The class is a context manager, so you can make sure that destroy is called using a with block.

Like any asyncio Task, mainloop can be cancelled. If you do that, you need to catch that exception to avoid a traceback.

#! python3.8

import asyncio
import tkinter as tk

class TkPod(tk.Tk):
    def __init__(self, sleep_interval=0.05):
        self.sleep_interval = sleep_interval
        self.app_closing = False
        self.loop = asyncio.get_event_loop()
        super().__init__()
        self.protocol("WM_DELETE_WINDOW", self.close_app)
        # Globally suppress the Tk menu tear-off feature
        # In the following line, "*tearOff" works as documented
        # while "*tearoff" does not.
        self.option_add("*tearOff", 0)
        
    def __enter__(self):
        return self
    
    def __exit__(self, *_x):
        self.destroy()
        
    def close_app(self):
        self.app_closing = True
    
    # I don't know what the argument n is for.  
    # I include it here because pylint complains otherwise.
    async def mainloop(self, _n=0):
        while not self.app_closing:
            self.update()
            await asyncio.sleep(self.sleep_interval)
        
async def main():
    async def die_in5s(t):
        await asyncio.sleep(5.0)
        t.cancel()
        print("It's over...")
        
    with TkPod() as root:
        label = tk.Label(root, text="Hello")
        label.grid()
        t = asyncio.create_task(root.mainloop())
        asyncio.create_task(die_in5s(t))
        try:
            await t
        except asyncio.CancelledError:
            pass
        
if __name__ == "__main__":
    asyncio.run(main())
    

Upvotes: 0

Related Questions