Search code examples
pythonpython-3.xtkinterpython-asyncio

tkinter and asyncio, window drag/resize blocks event loop, single thread


Tkinter and asyncio have some issues working together: they both are event loops that want to block indefinitely, and if you try to run them both on the same thread, one will block the other from ever executing at all. This means that if you want to run the tk event loop (Tk.mainloop()), none of your asyncio tasks will run; and if you want to run the asyncio event loop, your GUI will never draw to the screen. To work around this, we can simulate Tk's event loop by calling Tk.update() as an asyncio Task (shown in ui_update_task() below). This works pretty well for me except for one problem: window manager events block the asyncio event loop. These include window drag/resize operations. I don't need to resize, so I've disabled it in my program (not disabled in MCVE below), but the user may need to drag the window, and I would very much like that my application continues to run during that time.

The goal of this question is to see if this can be solved in a single thread. There are several answers on here and other places that solve this problem by running tk's event loop in one thread and asyncio's event loop in another thread, often using queues to pass data from one thread to another. I have tested this and determined that is an undesirable solution to my problem for several reasons. I would like to accomplish this in a single thread, if possible.

I have also tried overrideredirect(True) to remove the title bar entirely and replace it with just a tk.Frame containing a label and X button, and implemented my own drag methods. This also has the undesirable side-effect of removing the task bar icon, which can be remedied by making an invisible root window that pretends to be your real window. This rabbit-hole of work-arounds could be worse but I'd really just prefer not having to reimplement and hack around so many basic window operations. However, if I can't find a solution to this problem, this will most likely be the route I take.

import asyncio
import tkinter as tk


class tk_async_window(tk.Tk):
    def __init__(self, loop, update_interval=1/20):
        super(tk_async_window, self).__init__()
        self.protocol('WM_DELETE_WINDOW', self.close)
        self.geometry('400x100')
        self.loop = loop
        self.tasks = []
        self.update_interval = update_interval

        self.status = 'working'
        self.status_label = tk.Label(self, text=self.status)
        self.status_label.pack(padx=10, pady=10)

        self.close_event = asyncio.Event()

    def close(self):
        self.close_event.set()

    async def ui_update_task(self, interval):
        while True:
            self.update()
            await asyncio.sleep(interval)

    async def status_label_task(self):
        """
        This keeps the Status label updated with an alternating number of dots so that you know the UI isn't
        frozen even when it's not doing anything.
        """
        dots = ''
        while True:
            self.status_label['text'] = 'Status: %s%s' % (self.status, dots)
            await asyncio.sleep(0.5)
            dots += '.'
            if len(dots) >= 4:
                dots = ''

    def initialize(self):
        coros = (
            self.ui_update_task(self.update_interval),
            self.status_label_task(),
            # additional network-bound tasks
        )
        for coro in coros:
            self.tasks.append(self.loop.create_task(coro))

async def main():
    gui = tk_async_window(asyncio.get_event_loop())
    gui.initialize()
    await gui.close_event.wait()
    gui.destroy()

if __name__ == '__main__':
    asyncio.run(main(), debug=True)

If you run the example code above, you'll see a window with a label that says: Status: working followed by 0-3 dots. If you hold the title bar, you'll notice that the dots will stop animating, meaning the asyncio event loop is being blocked. This is because the call to self.update() is being blocked in ui_update_task(). Upon release of the title bar, you should get a message in your console from asyncio: Executing <Handle <TaskWakeupMethWrapper object at 0x041F4B70>(<Future finis...events.py:396>) created at C:\Program Files (x86)\Python37-32\lib\asyncio\futures.py:288> took 1.984 seconds with the number of seconds being however long you were dragging the window. What I would like is some way to handle drag events without blocking asyncio or spawning new threads. Is there any way to accomplish this?


Solution

  • Effectively you are executing individual Tk updates inside the asyncio event loop, and are running into a place where update() blocks. Another option is to invert the logic and invoke a single step of the asyncio event loop from inside a Tkinter timer - i.e. use Widget.after to keep invoking run_once.

    Here is your code with the changes outlined above:

    import asyncio
    import tkinter as tk
    
    
    class tk_async_window(tk.Tk):
        def __init__(self, loop, update_interval=1/20):
            super(tk_async_window, self).__init__()
            self.protocol('WM_DELETE_WINDOW', self.close)
            self.geometry('400x100')
            self.loop = loop
            self.tasks = []
    
            self.status = 'working'
            self.status_label = tk.Label(self, text=self.status)
            self.status_label.pack(padx=10, pady=10)
    
            self.after(0, self.__update_asyncio, update_interval)
            self.close_event = asyncio.Event()
    
        def close(self):
            self.close_event.set()
    
        def __update_asyncio(self, interval):
            self.loop.call_soon(self.loop.stop)
            self.loop.run_forever()
            if self.close_event.is_set():
                self.quit()
            self.after(int(interval * 1000), self.__update_asyncio, interval)
    
        async def status_label_task(self):
            """
            This keeps the Status label updated with an alternating number of dots so that you know the UI isn't
            frozen even when it's not doing anything.
            """
            dots = ''
            while True:
                self.status_label['text'] = 'Status: %s%s' % (self.status, dots)
                await asyncio.sleep(0.5)
                dots += '.'
                if len(dots) >= 4:
                    dots = ''
    
        def initialize(self):
            coros = (
                self.status_label_task(),
                # additional network-bound tasks
            )
            for coro in coros:
                self.tasks.append(self.loop.create_task(coro))
    
    if __name__ == '__main__':
        gui = tk_async_window(asyncio.get_event_loop())
        gui.initialize()
        gui.mainloop()
        gui.destroy()
    

    Unfortunately I couldn't test it on my machine, because the issue with blocking update() doesn't seem to appear on Linux, where moving of the window is handled by the window manager component of the desktop rather than the program itself.