How to Fix Tkinter wait_window Freezes and Stuck Buttons
Why Does the Button Stay Pressed and the UI Freeze?
When building desktop applications with Python's tkinter, implementing modal dialogs (like an IP entry prompt) is a common task. However, you might run into an issue where clicking a button to open a modal dialog causes the button to stay visually "pressed" (sunken) and freezes your UI. This behavior is caused by two main architectural issues in your Tkinter code:
- 1. Binding to
<Button-1>instead of using thecommandparameter: When you bind directly to the mouse-down event (<Button-1>), your handler executes immediately when the mouse button is pressed down. Because the handler blocks or starts a nested event loop before the mouse button is released, the widget never receives or processes the corresponding<ButtonRelease-1>event. As a result, the button stays stuck in its pressed state. - 2. Calling
wait_window()inside the__init__constructor: Callingself.wait_window()inside theConnectViewconstructor is a major anti-pattern. Becausewait_window()blocks execution until the window is destroyed, the constructor cannot return. This means the calling lineself._connect_view = ConnectView(self._view)is suspended mid-initialization, preventing the controller from properly referencing the instance until after it has already been closed.
The Solution: Best Practices for Tkinter Modals
To resolve these issues, we need to apply two straightforward refactoring steps:
- Use the standard button
commandattribute instead of event binding. - Move the
wait_window()call out of the dialog's constructor and into the controller that manages the dialog's lifecycle.
Step 1: Update the View to Use command
In main_view.py, configure your buttons using the command option. This ensures the button click completes its natural visual lifecycle (down and up) before triggering your controller's methods.
# In main_view.py
def set_controller(self, controller):
self._controller = controller
# Use config(command=...) instead of binding <Button-1>
self._send_message_button.config(command=self._controller.handle_button_send)
self._connect_button.config(command=self._controller.handle_button_connect)
Step 2: Clean up the Dialog Constructor
Remove self.wait_window() from the end of the ConnectView.__init__ method in connect_view.py. The constructor should only be responsible for building the UI and setting up the initial state.
# In connect_view.py
class ConnectView(tk.Toplevel):
def __init__(self, parent):
tk.Toplevel.__init__(self, parent)
# ... [your UI setup code] ...
self.protocol("WM_DELETE_WINDOW", self.cancel)
self.grab_set()
self.focus_set()
# REMOVED: self.wait_window() - Do not block the constructor!
Step 3: Manage the Modal Lifecycle in the Controller
Now, handle the blocking wait operation inside your controller. By instantiating the view first and then calling wait_window on it, the button event handler completes its initialization phase cleanly, and the modal behavior works exactly as expected.
# In main_controller.py
class MainController:
# ... [your __init__ and run methods] ...
def handle_button_send(self):
print("Sending message")
def handle_button_connect(self):
# 1. Create the dialog instance (constructor returns immediately)
connect_view = ConnectView(self._view)
# 2. Block execution here until the dialog is closed/destroyed
self._view.wait_window(connect_view)
# 3. Retrieve the value safely after the window is destroyed
user_ip = connect_view.get_ip()
print("IP: " + user_ip)
Summary of Benefits
By separating the construction of your UI from its execution state, you gain several advantages:
- Responsive UI: Buttons no longer get stuck in a "sunken" state because their event lifecycle is allowed to complete naturally.
- Cleaner Architecture: Your
__init__methods remain pure constructors, returning instances immediately without unexpected side effects. - Better Control: The calling controller has explicit control over when to display the dialog and when to read its data, making your application easier to debug and scale.