Skip to content

Instantly share code, notes, and snippets.

@James-E-A
Last active August 18, 2025 16:44
Show Gist options
  • Save James-E-A/22438a80a6cb6998dc9d7f29113d5321 to your computer and use it in GitHub Desktop.
Save James-E-A/22438a80a6cb6998dc9d7f29113d5321 to your computer and use it in GitHub Desktop.
Python tkinter async mainloop
import asyncio, tkinter
import ctypes
import math
import threading
from types import coroutine
__all__ = ["ATk"]
if tkinter.TclVersion == 8.6:
try:
# Windows
_tcl = ctypes.CDLL("tcl86t")
_tk = ctypes.CDLL("tk86t")
except FileNotFoundError:
# Mac & Linux
_tcl = ctypes.CDLL("libtcl8.6") # FIXME: actually test on Mac and/or Linux
_tk = ctypes.CDLL("libtk8.6") # FIXME: actually test on Mac and/or Linux
elif tkinter.TclVersion == 9.0:
# FIXME: Tcl/Tk 9.0 added much better asynchronous APIs
try:
# Windows
_tcl = ctypes.CDLL("tcl90t")
_tk = ctypes.CDLL("tk90t")
except FileNotFoundError:
# Mac & Linux
_tcl = ctypes.CDLL("libtcl9.0") # FIXME: actually test on Mac and/or Linux
_tk = ctypes.CDLL("libtk9.0") # FIXME: actually test on Mac and/or Linux
else:
raise NotImplementedError(f"Tcl/Tk {tkinter.TclVersion} not supported yet")
Tcl_WaitForEvent = _tcl["Tcl_WaitForEvent"]
Tk_GetNumMainWindows = _tk["Tk_GetNumMainWindows"]
class Tcl_Time(ctypes.Structure):
_fields_ = [
("sec", ctypes.c_long),
("usec", ctypes.c_int),
]
@classmethod
def fromseconds(cls, seconds):
usec = int(math.ceil(seconds * 1_000_000))
sec, usec = divmod(usec, 1_000_000)
assert 0 <= sec <= 2_147_483_647
assert 0 <= usec < 1_000_000
return cls(sec=sec, usec=usec)
class ATk(tkinter.Tk):
@coroutine
def mainloop_async(self, *, _tcl8_MaxBlockTime=None):
# https://www.tcl-lang.org/man/tcl8.6/TclLib/Notifier.htm#:~:text=event%20sources%20that%20need%20to%20poll%20for%20events,force%20the%20external%20event%20loop%20to%20call%20Tcl
if _tcl8_MaxBlockTime is None:
# WARNING: INFINITE BLOCK TIME HANGS NON-TK EVENTS WHENEVER USER IS IDLE
timeout_ = ctypes.c_void_p(None)
else:
# WARNING: NONZERO BLOCK TIME STUTTERS NON-TK EVENTS WHENEVER USER IS IDLE
# WARNING: ZERO BLOCK TIME WASTES A LOT OF CPU WITH AGGRESSIVE BUSY-POLL
timeout = Tcl_Time.fromseconds(_tcl8_MaxBlockTime)
timeout_ = ctypes.pointer(timeout)
# https://github.com/python/cpython/blob/v3.14.0rc2/Modules/_tkinter.c#L2866-L2877
# https://github.com/tcltk/tk/blob/core-8-6-14/generic/tkEvent.c#L2108
# https://github.com/tcltk/tcl/blob/core-8-6-14/generic/tclNotify.c#L946
self_update = self.update
result = Tcl_WaitForEvent(ctypes.pointer(Tcl_Time.fromseconds(0)))
while result != -1 and Tk_GetNumMainWindows() > 0:
if result > 0:
self_update()
yield
result = Tcl_WaitForEvent(timeout_)
def mainloop(self, coro=None):
if coro is None:
super().mainloop()
return
raise NotImplementedError("buddy coroutine support not implemented yet. If using Python 3.11+, consider manually combining ATk.mainloop_async with asyncio.TaskGroup")
async def main():
# demo h/t https://github.com/python/cpython/blob/v3.14.0rc1/Lib/tkinter/__init__.py#L4972
import tkinter.ttk as ttk
root = ATk()
text = "This is Tcl/Tk %s" % root.globalgetvar('tk_patchLevel')
text += "\nThis should be a cedilla: \u00e7"
label = ttk.Label(root, text=text)
label.pack()
test = ttk.Button(root, text="Click me!",
command=lambda root=root: root.test.configure(
text="[%s]" % root.test['text']))
test.pack()
root.test = test
quit = ttk.Button(root, text="QUIT", command=root.destroy)
quit.pack()
root.iconify()
root.update()
root.deiconify()
await root.mainloop_async() # <- WOW!
if __name__ == '__main__':
asyncio.run(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment