#!/usr/bin/env python3
"""Live parameter panel for ShredBlob (windowed mode)."""

import json
import os
import socket
import sys
import threading
import time
import tkinter as tk
from tkinter import ttk, filedialog

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
PRESET_FILENAME = "shredblob_tune.json"
DEFAULT_PRESET_PATH = os.path.join(SCRIPT_DIR, PRESET_FILENAME)


def default_preset_search_paths() -> list[str]:
    """Match shredblob_live.cpp defaultPresetSearchPaths() search order."""
    paths = [
        os.path.join(os.getcwd(), PRESET_FILENAME),
        os.path.join(SCRIPT_DIR, PRESET_FILENAME),
    ]
    home = os.path.expanduser("~")
    paths.append(os.path.join(home, ".config", "shredblob", PRESET_FILENAME))
    xdg = os.environ.get("XDG_CONFIG_HOME")
    if xdg:
        paths.append(os.path.join(xdg, "shredblob", PRESET_FILENAME))
    seen: set[str] = set()
    unique: list[str] = []
    for path in paths:
        norm = os.path.normpath(path)
        if norm not in seen:
            seen.add(norm)
            unique.append(norm)
    return unique


def find_default_preset_path() -> str | None:
    for path in default_preset_search_paths():
        if os.path.isfile(path):
            return path
    return None


def load_preset_file(path: str) -> dict:
    with open(path, encoding="utf-8") as f:
        data = json.load(f)
    if not isinstance(data, dict):
        raise json.JSONDecodeError("preset root must be an object", "", 0)
    return data


DEFAULTS = {
    "bpm": 92.0,
    "time_scale": 1.0,
    "narrative_duration": 45.0,
    "network_start": 11.0,
    "freed_bounce_duration": 7.0,
    "tunnel_duration": 13.0,
    "launch_duration": 10.0,
    "finale_white_duration": 3.2,
    "bg_color": [0.14, 0.08, 0.06],
    "color_fire": [1.0, 0.32, 0.06],
    "color_amber": [0.98, 0.68, 0.10],
    "color_chrome": [0.28, 0.52, 1.0],
    "tunnel_warm": [0.55, 0.28, 0.12],
    "tunnel_hot": [0.95, 0.55, 0.18],
    "tunnel_cool": [0.45, 0.72, 1.05],
    "impact_color": [1.05, 0.72, 0.38],
    "link_warm": [1.0, 0.72, 0.38],
    "star_warm": [1.0, 0.88, 0.72],
    "link_brightness": 1.0,
    "learn_rate": 1.0,
    "music_drive_gain": 1.0,
    "beat_sensitivity": 1.0,
    "tunnel_speed_gain": 1.0,
    "launch_thrust_gain": 1.0,
    "star_swirl_gain": 1.0,
    "camera_follow_min": 18.0,
    "camera_follow_max": 24.0,
    "blob_scale": 1.35,
    "tunnel_behind_mul": 1.0,
    "tunnel_ahead_mul": 1.0,
    "dendrite_line_width": 1.0,
    "fov_tunnel": 52.0,
    "fov_launch": 62.0,
    "cube_half_extent": 9.5,
}

VEC3_KEYS = {
    "bg_color", "color_fire", "color_amber", "color_chrome",
    "tunnel_warm", "tunnel_hot", "tunnel_cool",
    "impact_color", "link_warm", "star_warm",
}

SLIDER_SPECS = {
    "bpm": (60, 160, 92),
    "time_scale": (0.25, 3.0, 1.0),
    "narrative_duration": (20, 90, 45),
    "network_start": (4, 20, 11),
    "freed_bounce_duration": (2, 20, 7),
    "tunnel_duration": (5, 30, 13),
    "launch_duration": (4, 25, 10),
    "finale_white_duration": (1, 8, 3.2),
    "link_brightness": (0.2, 3.0, 1.0),
    "learn_rate": (0.2, 4.0, 1.0),
    "music_drive_gain": (0.2, 3.0, 1.0),
    "beat_sensitivity": (0.2, 3.0, 1.0),
    "tunnel_speed_gain": (0.3, 3.0, 1.0),
    "launch_thrust_gain": (0.3, 3.0, 1.0),
    "star_swirl_gain": (0.0, 4.0, 1.0),
    "camera_follow_min": (8.0, 35.0, 18.0),
    "camera_follow_max": (10.0, 45.0, 24.0),
    "blob_scale": (0.5, 3.0, 1.35),
    "tunnel_behind_mul": (0.5, 2.5, 1.0),
    "tunnel_ahead_mul": (0.5, 2.5, 1.0),
    "dendrite_line_width": (0.5, 4.0, 1.0),
    "fov_tunnel": (35, 90, 52),
    "fov_launch": (35, 100, 62),
    "cube_half_extent": (5.0, 20.0, 9.5),
}

# Named color themes — only vec3 color keys (scalars unchanged).
COLOR_PRESETS = {
    "Warm Fire (default)": {
        "bg_color": [0.14, 0.08, 0.06],
        "color_fire": [1.0, 0.32, 0.06],
        "color_amber": [0.98, 0.68, 0.10],
        "color_chrome": [0.28, 0.52, 1.0],
        "tunnel_warm": [0.55, 0.28, 0.12],
        "tunnel_hot": [0.95, 0.55, 0.18],
        "tunnel_cool": [0.45, 0.72, 1.05],
        "impact_color": [1.05, 0.72, 0.38],
        "link_warm": [1.0, 0.72, 0.38],
        "star_warm": [1.0, 0.88, 0.72],
    },
    "Cool Chrome": {
        "bg_color": [0.06, 0.08, 0.14],
        "color_fire": [0.35, 0.55, 1.0],
        "color_amber": [0.45, 0.78, 1.0],
        "color_chrome": [0.55, 0.82, 1.15],
        "tunnel_warm": [0.18, 0.32, 0.55],
        "tunnel_hot": [0.35, 0.62, 1.0],
        "tunnel_cool": [0.55, 0.88, 1.25],
        "impact_color": [0.65, 0.85, 1.15],
        "link_warm": [0.55, 0.78, 1.0],
        "star_warm": [0.82, 0.92, 1.0],
    },
    "Deep Space": {
        "bg_color": [0.02, 0.02, 0.04],
        "color_fire": [0.85, 0.42, 0.65],
        "color_amber": [0.72, 0.55, 0.95],
        "color_chrome": [0.38, 0.48, 0.95],
        "tunnel_warm": [0.28, 0.12, 0.38],
        "tunnel_hot": [0.62, 0.28, 0.72],
        "tunnel_cool": [0.32, 0.55, 0.95],
        "impact_color": [0.85, 0.55, 0.95],
        "link_warm": [0.78, 0.55, 0.92],
        "star_warm": [0.92, 0.82, 1.0],
    },
    "Sunset Ember": {
        "bg_color": [0.16, 0.06, 0.04],
        "color_fire": [1.0, 0.22, 0.05],
        "color_amber": [1.0, 0.55, 0.08],
        "color_chrome": [0.95, 0.45, 0.18],
        "tunnel_warm": [0.62, 0.18, 0.08],
        "tunnel_hot": [1.0, 0.42, 0.10],
        "tunnel_cool": [0.85, 0.35, 0.15],
        "impact_color": [1.15, 0.55, 0.18],
        "link_warm": [1.0, 0.62, 0.22],
        "star_warm": [1.0, 0.78, 0.55],
    },
    "Matrix Signal": {
        "bg_color": [0.03, 0.06, 0.04],
        "color_fire": [0.25, 0.95, 0.45],
        "color_amber": [0.35, 0.88, 0.52],
        "color_chrome": [0.18, 0.72, 0.55],
        "tunnel_warm": [0.12, 0.42, 0.22],
        "tunnel_hot": [0.28, 0.92, 0.48],
        "tunnel_cool": [0.22, 0.78, 0.62],
        "impact_color": [0.45, 1.0, 0.62],
        "link_warm": [0.35, 0.92, 0.52],
        "star_warm": [0.72, 1.0, 0.82],
    },
    "Pale Ash": {
        "bg_color": [0.12, 0.11, 0.10],
        "color_fire": [0.72, 0.68, 0.62],
        "color_amber": [0.82, 0.75, 0.65],
        "color_chrome": [0.55, 0.58, 0.62],
        "tunnel_warm": [0.42, 0.38, 0.32],
        "tunnel_hot": [0.72, 0.62, 0.52],
        "tunnel_cool": [0.58, 0.62, 0.68],
        "impact_color": [0.88, 0.82, 0.72],
        "link_warm": [0.82, 0.75, 0.62],
        "star_warm": [0.92, 0.88, 0.82],
    },
}

# Phase id matches C++ jumpToPhase() indices.
PHASES = [
    {
        "id": 0,
        "name": "Global",
        "jump": "Solo",
        "scalars": ["bpm", "time_scale", "narrative_duration", "blob_scale"],
        "colors": ["bg_color"],
    },
    {
        "id": 1,
        "name": "Dendrites",
        "jump": "Dendrites",
        "scalars": [],
        "colors": [],
    },
    {
        "id": 2,
        "name": "Network",
        "jump": "Network",
        "scalars": [
            "network_start", "learn_rate", "link_brightness",
            "music_drive_gain", "beat_sensitivity", "dendrite_line_width",
        ],
        "colors": ["color_fire", "color_amber", "color_chrome", "link_warm"],
    },
    {
        "id": 3,
        "name": "Struggle",
        "jump": "Struggle",
        "scalars": [],
        "colors": [],
    },
    {
        "id": 4,
        "name": "Breaking",
        "jump": "Breaking",
        "scalars": [],
        "colors": [],
    },
    {
        "id": 5,
        "name": "Cube",
        "jump": "Cube",
        "scalars": ["freed_bounce_duration", "cube_half_extent"],
        "colors": ["impact_color"],
    },
    {
        "id": 6,
        "name": "Tunnel",
        "jump": "Tunnel",
        "scalars": [
            "tunnel_duration", "tunnel_speed_gain",
            "tunnel_behind_mul", "tunnel_ahead_mul",
            "camera_follow_min", "camera_follow_max", "fov_tunnel",
        ],
        "colors": ["tunnel_warm", "tunnel_hot", "tunnel_cool"],
    },
    {
        "id": 7,
        "name": "Launch",
        "jump": "Launch",
        "scalars": [
            "launch_duration", "launch_thrust_gain", "star_swirl_gain",
            "fov_launch",
        ],
        "colors": ["star_warm"],
    },
    {
        "id": 8,
        "name": "Finale",
        "jump": "Finale",
        "scalars": ["finale_white_duration"],
        "colors": [],
    },
]

def params_to_wire(params: dict) -> bytes:
    lines = []
    for key, val in sorted(params.items()):
        if isinstance(val, (list, tuple)):
            lines.append(f"{key}={' '.join(str(float(x)) for x in val)}")
        else:
            lines.append(f"{key}={float(val)}")
    lines.append(".")
    return ("\n".join(lines) + "\n").encode("utf-8")


def parse_wire(text: str) -> dict:
    out = {}
    for line in text.splitlines():
        if not line or line == ".":
            continue
        if "=" not in line:
            continue
        key, val = line.split("=", 1)
        parts = val.split()
        if len(parts) >= 3:
            out[key] = [float(parts[0]), float(parts[1]), float(parts[2])]
        else:
            out[key] = float(parts[0])
    return out


class ShredBlobControls(tk.Tk):
    def __init__(self, socket_path: str):
        super().__init__()
        self.title("ShredBlob Live Controls")
        self.geometry("420x640")
        try:
            self.geometry(f"420x640+860+40")
        except tk.TclError:
            pass
        self.minsize(380, 460)

        self.socket_path = socket_path
        self.sock = None
        self.preset_path = find_default_preset_path() or DEFAULT_PRESET_PATH
        self.params = dict(DEFAULTS)
        preset = find_default_preset_path()
        if preset:
            try:
                self.params.update(load_preset_file(preset))
                self.preset_path = preset
            except (OSError, json.JSONDecodeError):
                pass
        self.vars = {}
        self._send_pending = False
        self._suppress_send = False
        self._connecting = False
        self._active_phase = 0
        self.phase_notebook = None
        self.phase_tab_index = {}
        self.status = tk.StringVar(
            value="Connecting…"
        )

        self._build_ui()
        if preset:
            self.status.set(f"Loaded preset: {preset}")
        self.after(200, self._connect_loop)
        self.protocol("WM_DELETE_WINDOW", self._on_close)

    def _scrollable_frame(self, parent):
        outer = ttk.Frame(parent)
        outer.pack(fill=tk.BOTH, expand=True)
        canvas = tk.Canvas(outer, highlightthickness=0, borderwidth=0)
        scroll = ttk.Scrollbar(outer, orient=tk.VERTICAL, command=canvas.yview)
        inner = ttk.Frame(canvas, padding=4)
        inner.bind("<Configure>", lambda _e: canvas.configure(scrollregion=canvas.bbox("all")))
        win = canvas.create_window((0, 0), window=inner, anchor="nw")
        canvas.configure(yscrollcommand=scroll.set)
        canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        scroll.pack(side=tk.RIGHT, fill=tk.Y)

        def _on_canvas_configure(event):
            canvas.itemconfigure(win, width=event.width)

        canvas.bind("<Configure>", _on_canvas_configure)

        def _wheel(event):
            canvas.yview_scroll(int(-event.delta / 120), "units")

        canvas.bind("<Enter>", lambda _e: canvas.bind_all("<MouseWheel>", _wheel))
        canvas.bind("<Leave>", lambda _e: canvas.unbind_all("<MouseWheel>"))
        return inner

    def _notebook_tab(self, nb, title, phase_id):
        tab = ttk.Frame(nb)
        nb.add(tab, text=title)
        self.phase_tab_index[phase_id] = nb.index(tab)
        return self._scrollable_frame(tab)

    def _build_ui(self):
        top = ttk.Frame(self, padding=(6, 6, 6, 2))
        top.pack(fill=tk.X)
        ttk.Label(top, textvariable=self.status, wraplength=390, font=("", 9)).pack(fill=tk.X)

        file_row = ttk.LabelFrame(self, text="Presets", padding=(6, 4))
        file_row.pack(fill=tk.X, padx=6, pady=(4, 2))
        ttk.Button(file_row, text="Save preset", command=self.save_params).pack(
            side=tk.LEFT, padx=2)
        ttk.Button(file_row, text="Load preset…", command=self.load_params).pack(
            side=tk.LEFT, padx=2)
        ttk.Button(file_row, text="Reset params", command=self.reset_defaults).pack(
            side=tk.LEFT, padx=2)
        ttk.Button(file_row, text="Restart demo", command=self.restart_demo).pack(
            side=tk.LEFT, padx=2)

        theme_row = ttk.LabelFrame(self, text="Color themes", padding=(6, 4))
        theme_row.pack(fill=tk.X, padx=6, pady=(2, 4))
        self.theme_var = tk.StringVar(value="Warm Fire (default)")
        theme_combo = ttk.Combobox(
            theme_row,
            textvariable=self.theme_var,
            values=list(COLOR_PRESETS.keys()),
            state="readonly",
            width=22,
        )
        theme_combo.pack(side=tk.LEFT, padx=2)
        ttk.Button(theme_row, text="Apply theme", command=self.apply_color_preset).pack(
            side=tk.LEFT, padx=4)
        ttk.Label(
            theme_row,
            text="Colors only — timing & motion sliders stay as-is.",
            font=("", 8),
        ).pack(side=tk.LEFT, padx=4)

        phase_row = ttk.LabelFrame(self, text="Jump to phase", padding=(6, 4))
        phase_row.pack(fill=tk.X, padx=6, pady=2)
        grid = ttk.Frame(phase_row)
        grid.pack(fill=tk.X)
        for i, phase in enumerate(PHASES):
            ttk.Button(
                grid,
                text=phase["jump"],
                width=9,
                command=lambda p=phase["id"]: self.jump_to_phase(p),
            ).grid(row=i // 3, column=i % 3, padx=2, pady=2, sticky="ew")
        for c in range(3):
            grid.columnconfigure(c, weight=1)

        self.phase_notebook = ttk.Notebook(self)
        self.phase_notebook.pack(fill=tk.BOTH, expand=True, padx=6, pady=4)

        for phase in PHASES:
            frame = self._notebook_tab(self.phase_notebook, phase["name"], phase["id"])
            if not phase["scalars"] and not phase["colors"]:
                ttk.Label(
                    frame,
                    text=f"No exclusive params — use Global / related tabs.\n"
                         f"Click “{phase['jump']}” above to jump the demo here.",
                    wraplength=360,
                    font=("", 9),
                ).pack(anchor=tk.W, pady=8)
            for key in phase["scalars"]:
                self._add_slider(frame, key)
            for key in phase["colors"]:
                self._add_color_group(frame, key)

        self.phase_notebook.bind("<<NotebookTabChanged>>", self._on_tab_changed)
        self.phase_notebook.select(self.phase_tab_index[0])

    def _on_tab_changed(self, _event=None):
        try:
            idx = self.phase_notebook.index(self.phase_notebook.select())
        except tk.TclError:
            return
        for phase_id, tab_idx in self.phase_tab_index.items():
            if tab_idx == idx:
                self._active_phase = phase_id
                break

    def _label(self, key: str) -> str:
        return key.replace("_", " ").title()

    def _add_slider(self, parent, key: str):
        lo, hi, default = SLIDER_SPECS[key]
        frame = ttk.Frame(parent)
        frame.pack(fill=tk.X, pady=1)
        val = tk.DoubleVar(value=self.params.get(key, default))
        self.vars[key] = val
        ttk.Label(frame, text=self._label(key), width=16, font=("", 8)).pack(side=tk.LEFT)
        scale = ttk.Scale(
            frame, from_=lo, to=hi, variable=val, orient=tk.HORIZONTAL,
            command=lambda _v, k=key: self._on_change(k),
        )
        scale.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=2)
        lbl = ttk.Label(frame, text=f"{val.get():.2f}", width=6, font=("", 8))
        lbl.pack(side=tk.RIGHT)

        def update_lbl(*_):
            lbl.config(text=f"{val.get():.2f}")

        val.trace_add("write", update_lbl)

    def _add_color_group(self, parent, key: str):
        frame = ttk.Frame(parent)
        frame.pack(fill=tk.X, pady=1)
        ttk.Label(frame, text=self._label(key)[:13], width=13, font=("", 8)).pack(side=tk.LEFT)
        rgb = self.params.get(key, DEFAULTS[key])
        for ch in "rgb":
            v = tk.DoubleVar(value=rgb["rgb".index(ch)])
            self.vars[f"{key}_{ch}"] = v
            sub = ttk.Frame(frame)
            sub.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=1)
            ttk.Label(sub, text=ch.upper(), width=1, font=("", 8)).pack(side=tk.LEFT)
            ttk.Scale(
                sub, from_=0.0, to=1.5, variable=v, orient=tk.HORIZONTAL,
                command=lambda _x, k=key: self._on_change(k),
            ).pack(side=tk.LEFT, fill=tk.X, expand=True)

    def _collect_params(self) -> dict:
        p = {}
        for key in SLIDER_SPECS:
            if key in self.vars:
                p[key] = float(self.vars[key].get())
        for key in VEC3_KEYS:
            p[key] = [
                float(self.vars[f"{key}_r"].get()),
                float(self.vars[f"{key}_g"].get()),
                float(self.vars[f"{key}_b"].get()),
            ]
        return p

    def _apply_params_to_ui(self, params: dict):
        self._suppress_send = True
        try:
            self.params = dict(DEFAULTS)
            self.params.update(params)
            for key in SLIDER_SPECS:
                if key in self.params and key in self.vars:
                    self.vars[key].set(self.params[key])
            for key in VEC3_KEYS:
                if key in self.params:
                    rgb = self.params[key]
                    for i, ch in enumerate("rgb"):
                        self.vars[f"{key}_{ch}"].set(rgb[i])
        finally:
            self._suppress_send = False

    def _on_change(self, _key=None):
        self.params = self._collect_params()
        if not self._suppress_send:
            self._schedule_send()

    def _schedule_send(self):
        if not self._send_pending:
            self._send_pending = True
            self.after(60, self._flush_send)

    def _flush_send(self):
        self._send_pending = False
        self._send_params(self._collect_params())

    def jump_to_phase(self, phase_id: int):
        self._active_phase = phase_id
        if phase_id in self.phase_tab_index:
            self.phase_notebook.select(self.phase_tab_index[phase_id])
        name = PHASES[phase_id]["name"]
        payload = self._collect_params()
        payload["jump_phase"] = float(phase_id)
        self._send_params(payload)
        self.status.set(f"Jump → {name} (tab synced)")

    def _connect_loop(self):
        if self.sock:
            return
        if self._connecting:
            self.after(500, self._connect_loop)
            return
        threading.Thread(target=self._connect_thread, daemon=True).start()
        self.after(500, self._connect_loop)

    def _connect_thread(self):
        self._connecting = True
        path = self.socket_path
        for _ in range(40):
            try:
                s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
                s.connect(path)
                s.setblocking(True)
                buf = b""
                while True:
                    chunk = s.recv(4096)
                    if not chunk:
                        break
                    buf += chunk
                    if b"\n.\n" in buf:
                        break
                initial = parse_wire(buf.decode("utf-8", errors="replace"))
                self.after(0, lambda: self._on_connected(s, initial))
                return
            except OSError:
                time.sleep(0.25)
        self.after(0, lambda: self.status.set(f"Could not connect to {path}"))
        self._connecting = False

    def _on_connected(self, sock, initial: dict):
        self.sock = sock
        self._connecting = False
        if initial:
            self._apply_params_to_ui(initial)
        self.status.set("Connected — synced with demo")
        threading.Thread(target=self._recv_loop, daemon=True).start()

    def _recv_loop(self):
        buf = b""
        try:
            while self.sock:
                chunk = self.sock.recv(4096)
                if not chunk:
                    break
                buf += chunk
                while b"\n.\n" in buf:
                    msg, buf = buf.split(b"\n.\n", 1)
                    data = parse_wire(msg.decode("utf-8", errors="replace"))
                    if data:
                        self.after(0, lambda d=data: self._apply_params_to_ui(d))
        except OSError:
            pass
        self.after(0, self._on_disconnect)

    def _on_disconnect(self):
        if self.sock:
            try:
                self.sock.close()
            except OSError:
                pass
        self.sock = None
        self.status.set("Disconnected — retrying…")
        self.after(800, self._connect_loop)

    def _send_params(self, params: dict):
        if not self.sock:
            return
        try:
            self.sock.sendall(params_to_wire(params))
        except OSError:
            self._on_disconnect()

    def save_params(self):
        try:
            with open(self.preset_path, "w", encoding="utf-8") as f:
                json.dump(self._collect_params(), f, indent=2)
            self.status.set(f"Saved preset: {self.preset_path}")
            self._send_params(self._collect_params())
        except OSError as exc:
            self.status.set(f"Save failed: {exc}")

    def load_params(self):
        path = filedialog.askopenfilename(
            initialdir=SCRIPT_DIR,
            filetypes=[("JSON", "*.json"), ("All", "*.*")],
        )
        if not path:
            return
        try:
            with open(path, encoding="utf-8") as f:
                data = json.load(f)
        except (OSError, json.JSONDecodeError) as exc:
            self.status.set(f"Load failed: {exc}")
            return
        self._apply_params_to_ui(data)
        self._send_params(self._collect_params())
        self.status.set(f"Loaded preset: {path}")

    def apply_color_preset(self):
        name = self.theme_var.get()
        preset = COLOR_PRESETS.get(name)
        if not preset:
            return
        for key, rgb in preset.items():
            if key not in VEC3_KEYS:
                continue
            for i, ch in enumerate("rgb"):
                self.vars[f"{key}_{ch}"].set(rgb[i])
        self._on_change()
        self.status.set(f"Applied color theme: {name}")

    def reset_defaults(self):
        self._apply_params_to_ui(dict(DEFAULTS))
        self._send_params(self._collect_params())
        self.status.set("Parameters reset to defaults (demo unchanged)")

    def restart_demo(self):
        payload = self._collect_params()
        payload["reset_demo"] = 1.0
        self._send_params(payload)
        self.status.set("Demo restarted from solo")

    def _on_close(self):
        if self.sock:
            try:
                self.sock.close()
            except OSError:
                pass
        self.destroy()


def main():
    path = os.environ.get("SHREDBLOB_SOCKET")
    if len(sys.argv) > 1:
        path = sys.argv[1]
    if not path:
        print("Usage: shredblob_controls.py <unix-socket-path>", file=sys.stderr)
        sys.exit(1)
    app = ShredBlobControls(path)
    app.mainloop()


if __name__ == "__main__":
    main()
