Why I Started Using This Tool
When a script runs FFT analysis or renders a stack of subplots, it just freezes, no feedback, no way to stop it. I needed something that tells the user "I'm working" and lets them cancel without killing the terminal. So I extracted a self-contained waiting dialog I can drop into any project.
What It Does
It's a Tkinter window that pops up with a message and a cancel button while your processing function runs on a background thread. When the work finishes, it closes itself. If the user hits cancel, it sets a flag your processing code can check to stop early.
Non-blocking β runs your heavy function on a thread so the UI stays responsive
Cancel flag β your processing loop checks dialog.cancelled to bail out gracefully
Reusable β one import, one call, works in any script
My Honest Pros & Cons
β
What I Love
Zero boilerplate at the call site β wrap any function in one line
Cancel is cooperative, not a hard kill, so you can clean up state before stopping
No external dependencies β pure stdlib tkinter + threading
β What Could Be Better
No progress percentage unless you add a ttk.Progressbar (easy extension)
Matplotlib and Tkinter can clash if both try to use the main thread β keep your plt.show() calls outside the threaded function
Pricing: Is It Worth It?
Free β standard library only. Ships with every CPython install on Windows, macOS, and most Linux distros.
My take: Zero cost, and it makes any data script feel like a real application.
Final Verdict
If you write Python scripts that block for seconds or minutes β FFT pipelines, batch file processors, heavy subplot renders β this gives users feedback and an escape hatch. Skip it only if you're running fully headless with no GUI at all.
import tkinter as tk
from tkinter import ttk
import threading
class WaitDialog:
"""
Modal 'Processingβ¦' dialog with a cancel button.
Usage
-----
with WaitDialog("Running FFT analysisβ¦") as dlg:
my_heavy_function(dlg) # check dlg.cancelled inside
Or manually:
dlg = WaitDialog("Loading dataβ¦")
dlg.open()
# β¦ do work, checking dlg.cancelled β¦
dlg.close()
"""
def __init__(self, message: str = "Processingβ¦", title: str = "Please wait"):
self.message = message
self.title = title
self.cancelled = False
self._root = None
def open(self):
"""Show the dialog (non-blocking β call from main thread)."""
self._root = tk.Tk()
self._root.title(self.title)
self._root.resizable(False, False)
self._root.attributes("-topmost", True)
self._root.protocol("WM_DELETE_WINDOW", self._on_cancel)
frame = tk.Frame(self._root, padx=24, pady=20)
frame.pack()
tk.Label(frame, text=self.message, font=("Helvetica", 11)).pack(pady=(0, 12))
self._bar = ttk.Progressbar(frame, mode="indeterminate", length=260)
self._bar.pack(pady=(0, 14))
self._bar.start(12)
tk.Button(
frame, text="Cancel", width=10, command=self._on_cancel
).pack()
self._centre()
self._root.update()
def close(self):
"""Destroy the dialog from any thread."""
if self._root:
self._root.after(0, self._root.destroy)
self._root = None
def keep_alive(self):
"""
Call this periodically from your main thread if you are
driving the work loop there instead of a background thread.
Keeps the Tk event loop ticking so the window stays responsive.
"""
if self._root:
self._root.update()
def _on_cancel(self):
self.cancelled = True
self.close()
def _centre(self):
self._root.update_idletasks()
w, h = self._root.winfo_width(), self._root.winfo_height()
sw = self._root.winfo_screenwidth()
sh = self._root.winfo_screenheight()
self._root.geometry(f"{w}x{h}+{(sw-w)//2}+{(sh-h)//2}")
# ββ context-manager support βββββββββββββββββββββββββββββββ
def __enter__(self):
self.open()
return self
def __exit__(self, *_):
self.close()
# βββ convenience wrapper ββββββββββββββββββββββββββββββββββ
def run_with_dialog(fn, message: str = "Processingβ¦", title: str = "Please wait"):
"""
Run fn(dialog) on a background thread while showing a wait dialog.
fn receives the WaitDialog instance so it can check fn.cancelled.
Returns the function's return value, or None if cancelled.
"""
dlg = WaitDialog(message, title)
result = [None]
def worker():
try:
result[0] = fn(dlg)
finally:
dlg.close()
dlg.open()
t = threading.Thread(target=worker, daemon=True)
t.start()
while t.is_alive() and dlg._root:
try:
dlg._root.update()
except tk.TclError:
break
t.join()
return None if dlg.cancelled else result[0]
usage_example.py β FFT + multi-subplot
from wait_dialog import run_with_dialog
import numpy as np
import matplotlib.pyplot as plt
import time
def heavy_analysis(dlg):
"""Simulate slow data processing. Check dlg.cancelled to stop early."""
fs = 44100
results = []
for freq in [50, 120, 440, 1000]:
if dlg.cancelled: # β check the flag each iteration
return None
t = np.linspace(0, 1, fs, endpoint=False)
signal = np.sin(2 * np.pi * freq * t)
freqs = np.fft.rfftfreq(len(t), 1 / fs)
mag = np.abs(np.fft.rfft(signal))
results.append((freq, t, signal, freqs, mag))
time.sleep(0.8) # stand-in for real heavy work
return results
def main():
data = run_with_dialog(
heavy_analysis,
message="Running FFT on all channelsβ¦",
title="FFT Analysis",
)
if data is None:
print("Cancelled.")
return
# plot results β safe to use matplotlib here (main thread)
fig, axes = plt.subplots(len(data), 2, figsize=(12, 3 * len(data)))
for i, (freq, t, sig, freqs, mag) in enumerate(data):
axes[i, 0].plot(t[:300], sig[:300])
axes[i, 0].set_title(f"Time domain β {freq} Hz")
axes[i, 1].plot(freqs[:2000], mag[:2000])
axes[i, 1].set_title(f"FFT β {freq} Hz")
plt.tight_layout()
plt.show()
if __name__ == "__main__":
main()
Drop wait_dialog.py into any project and import it β no changes needed. The key pattern is that run_with_dialog handles all the threading so your processing function just runs normally, checking dlg.cancelled at the top of each loop iteration to bail out cleanly if the user hits cancel. Matplotlib plotting stays on the main thread after the dialog closes, which avoids the Tk/Matplotlib thread conflict.