DEV Community

Dhruv Chaudhary
Dhruv Chaudhary

Posted on

Smashing GUI Freezes: Threading Global Hotkeys in Python CustomTkinter

Summer Bug Smash: Smash Stories 🐛🛹

The Bug That Froze Time (and My UI) 🐛🥶
For this DEV Community #bugsmash challenge, I'm diving into a frustrating issue I encountered while building ClipKinetic, a Windows system tray clipboard manager I've been developing using Python and CustomTkinter.

The core feature of ClipKinetic is simple: press a global hotkey, and a sleek GUI pops up with your clipboard history. To handle the hotkeys, I used the popular keyboard library. However, as soon as I wired the hotkey listener to the CustomTkinter interface, disaster struck.

The Symptoms:

The app would launch perfectly.

Pressing the hotkey successfully triggered the UI to appear.

But then... the entire CustomTkinter window would completely freeze. No buttons worked, the window couldn't be dragged, and I had to force-quit the application.

The Investigation 🔍
I started by looking at how the keyboard.add_hotkey() function interacts with CustomTkinter's mainloop().

GUI frameworks in Python (like Tkinter/CustomTkinter) run on a single main thread. They constantly loop to listen for mouse clicks, hovers, and keyboard events.

Here is what my initial, buggy approach looked like:

Python
import customtkinter as ctk
import keyboard

class ClipKineticApp(ctk.CTk):
def init(self):
super().init()
self.title("ClipKinetic")
# BAD: This blocks the main GUI thread!
keyboard.add_hotkey('ctrl+shift+v', self.show_window)
keyboard.wait()

def show_window(self):
    self.deiconify()
Enter fullscreen mode Exit fullscreen mode

if name == "main":
app = ClipKineticApp()
app.mainloop()
The keyboard.wait() function is a blocking call. Because it was running on the same thread as the CustomTkinter mainloop(), it was essentially hijacking the thread. The GUI couldn't update or register clicks because the thread was perpetually waiting for the next keystroke.

The Smash 🔨
To fix this, I needed to decouple the global hotkey listener from the main GUI thread. The solution was implementing Python's threading module to run the listener in the background, allowing the UI to breathe.

Here is the corrected, thread-safe implementation:

Python
import customtkinter as ctk
import keyboard
import threading

class ClipKineticApp(ctk.CTk):
def init(self):
super().init()
self.title("ClipKinetic")

    # Start the hotkey listener in a separate daemon thread
    listener_thread = threading.Thread(target=self.start_hotkey_listener, daemon=True)
    listener_thread.start()

def start_hotkey_listener(self):
    # This now runs independently of the mainloop
    keyboard.add_hotkey('ctrl+shift+v', self.trigger_ui_update)
    keyboard.wait()

def trigger_ui_update(self):
    # Use .after() to safely schedule the UI update back on the main thread
    self.after(0, self.show_window)

def show_window(self):
    self.deiconify()
    self.focus_force()
Enter fullscreen mode Exit fullscreen mode

if name == "main":
app = ClipKineticApp()
app.mainloop()
Key Fixes:

Daemon Threading: By wrapping the listener in a daemon=True thread, it runs in the background and automatically shuts down when the main app closes.

Thread-Safe UI Updates: You should never update Tkinter widgets directly from a secondary thread. Using self.after(0, ...) safely queues the show_window command to be executed by the main GUI thread as soon as it is free.

Top comments (0)