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.