"""DJ HUD main application — HUDApp class and GUI orchestration."""

import json
import sys
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
from dataclasses import asdict
from typing import List, Optional, Tuple

from djhud import __version__, APP_NAME
from djhud.models import (
    CaptureRegion, SnapHelper,
    DEFAULT_CONFIG_FILE, DEFAULT_FPS, DEFAULT_CANVAS_W, DEFAULT_CANVAS_H,
    DEFAULT_REGION_H, PREVIEW_BG, PREVIEW_OUTSIDE_BG, PREVIEW_PADDING,
)
from djhud.platform import (
    PLATFORM, IS_WINDOWS, IS_MAC, IS_LINUX,
    WindowInfo, list_visible_windows, get_window_rect,
    grab_monitor_rect, capture_window, get_webcam_backends,
    check_macos_screen_permission, request_macos_screen_permission,
)
from djhud.diagnostics import SystemDiagnostics, probe_import
from djhud.widgets import (
    ScrollableFrame, RegionSelector, SourceRegionEditor,
    HUDOutputWindow, DiagnosticsDialog,
)

# ─── Probe optional dependencies ────────────────────────────────────────────

try:
    import mss as mss_mod
except ImportError:
    mss_mod = probe_import("mss")

try:
    from PIL import Image, ImageGrab, ImageTk, ImageDraw
except ImportError:
    Image = ImageGrab = ImageTk = ImageDraw = None
    probe_import("PIL")

try:
    import cv2
    try:
        if hasattr(cv2, "utils") and hasattr(cv2.utils, "logging"):
            cv2.utils.logging.setLogLevel(cv2.utils.logging.LOG_LEVEL_ERROR)
        elif hasattr(cv2, "setLogLevel"):
            cv2.setLogLevel(2)
    except Exception:
        pass
except ImportError:
    cv2 = None
    probe_import("cv2")

try:
    import pyvirtualcam
except ImportError:
    pyvirtualcam = None
    probe_import("pyvirtualcam")

try:
    import numpy as np
except ImportError:
    np = None
    probe_import("numpy")


class HUDApp:
    def __init__(self, root: tk.Tk):
        self.root = root
        self.root.title(f"{APP_NAME} v{__version__}")
        # Adapt window size to screen
        screen_w = root.winfo_screenwidth()
        screen_h = root.winfo_screenheight()
        if screen_w <= 1280:
            # Very small screen (old laptop, small external display)
            win_w = min(screen_w - 20, 1260)
            win_h = min(screen_h - 80, 760)
            self.left_panel_width = 340
        elif screen_w <= 1440:
            # Smaller screen (MacBook Air, etc.) — compact layout
            win_w = min(screen_w - 20, 1400)
            win_h = min(screen_h - 80, 860)
            self.left_panel_width = 380
        else:
            # Larger screen — full layout
            win_w = min(screen_w - 40, 1700)
            win_h = min(screen_h - 60, 980)
            self.left_panel_width = 480
        self.root.geometry(f"{win_w}x{win_h}")
        self.root.minsize(900, 600)

        if mss_mod is not None:
            self.sct = mss_mod.mss()
        else:
            self.sct = None
        self.monitors = self.get_real_monitors()
        self.windows: List[WindowInfo] = []
        self.webcam_devices: List[Tuple[int, str]] = []
        self.webcam_frame = None
        self.webcam_cap = None
        self.webcam_device_id = None
        self.webcam_error_message = ""
        self.webcam_read_fail_count = 0

        # ── Tk variables ─────────────────────────────────────────────────
        self.source_mode = tk.StringVar(value="monitor")
        self.selected_webcam_index = tk.IntVar(value=0)
        self.webcam_resolution_var = tk.StringVar(value="Default")
        self.webcam_fps_var = tk.IntVar(value=30)
        self.webcam_flip_h_var = tk.BooleanVar(value=False)
        self.webcam_flip_v_var = tk.BooleanVar(value=False)
        self.webcam_rotate_var = tk.StringVar(value="0")
        self.webcam_freeze_var = tk.BooleanVar(value=False)
        self.webcam_brightness_var = tk.IntVar(value=0)
        self.webcam_contrast_var = tk.IntVar(value=100)
        self.webcam_frozen_frame = None
        self.target_fps = tk.IntVar(value=DEFAULT_FPS)
        self.canvas_w = tk.IntVar(value=DEFAULT_CANVAS_W)
        self.canvas_h = tk.IntVar(value=DEFAULT_CANVAS_H)
        self.show_output_window = tk.BooleanVar(value=True)
        self.fullscreen_output = tk.BooleanVar(value=False)
        self.snap_to_grid = tk.BooleanVar(value=True)
        self.snap_source_to_grid = tk.BooleanVar(value=True)
        self.grid_size = tk.IntVar(value=10)
        self.nudge_step = tk.IntVar(value=1)

        self.regions: List[CaptureRegion] = []
        self.output_window: Optional[HUDOutputWindow] = None
        self.selected_region_index: Optional[int] = None

        self.region_name_var = tk.StringVar()
        self.region_locked_var = tk.BooleanVar(value=False)
        self.preset_name_var = tk.StringVar()
        self.dj_app_name_var = tk.StringVar()
        self.region_enabled_var = tk.BooleanVar(value=True)
        self.region_out_x_var = tk.IntVar(value=0)
        self.region_out_y_var = tk.IntVar(value=0)
        self.region_target_h_var = tk.IntVar(value=DEFAULT_REGION_H)
        self.region_target_w_var = tk.IntVar(value=int(DEFAULT_REGION_H))
        self.region_lock_aspect_var = tk.BooleanVar(value=True)
        self.region_edit_as_crop_var = tk.BooleanVar(value=False)

        self.pyvirtualcam_cam = None
        self.last_vcam_shape = None
        self.virtualcam_running = False
        self.virtualcam_test_pattern = tk.BooleanVar(value=False)
        self.virtualcam_backend_name = tk.StringVar(value="pyvirtualcam (idle)")

        # VDO.Ninja streaming
        self.vdoninja_server = None
        self.vdoninja_stream_id_var = tk.StringVar(value="")
        self.vdoninja_status_var = tk.StringVar(value="Not running")

        self.preview_photo = None
        self.preview_origin_x = PREVIEW_PADDING
        self.preview_origin_y = PREVIEW_PADDING

        self.drag_region_index: Optional[int] = None
        self.drag_start_mouse = (0, 0)
        self.drag_start_region = (0, 0)
        self.drag_mode: Optional[str] = None
        self.drag_start_geometry = None

        self.build_ui()
        self.refresh_windows()
        self.refresh_webcams(startup=True)
        self.refresh_source_info()
        self.update_region_list()
        self.root.after(50, self.render_loop)
        self.root.bind("<Delete>", self.on_root_delete_key)
        if IS_MAC:
            self.root.bind("<BackSpace>", self.on_root_delete_key)
        for seq in ("<Left>", "<Right>", "<Up>", "<Down>",
                     "<Shift-Left>", "<Shift-Right>", "<Shift-Up>", "<Shift-Down>"):
            self.root.bind_all(seq, self.on_root_arrow_key, add="+")

        # macOS: check screen capture permission on startup
        if IS_MAC:
            self.root.after(300, self._check_macos_screen_permission_startup)

    def get_real_monitors(self):
        if self.sct is None:
            return [{"left": 0, "top": 0, "width": 1920, "height": 1080}]
        return self.sct.monitors[1:]

    # ══════════════════════════════════════════════════════════════════════════
    # UI BUILD
    # ══════════════════════════════════════════════════════════════════════════

    def build_ui(self):
        lpw = self.left_panel_width
        left_holder = ttk.Frame(self.root, padding=6)
        left_holder.grid(row=0, column=0, sticky="nsew")
        left_holder.rowconfigure(0, weight=1)
        left_holder.columnconfigure(0, weight=1)
        self.left_scrollable = ScrollableFrame(left_holder, target_width=lpw)
        self.left_scrollable.grid(row=0, column=0, sticky="nsew")
        left = self.left_scrollable.inner

        right = ttk.Frame(self.root, padding=10)
        right.grid(row=0, column=1, sticky="nsew")
        self.root.columnconfigure(0, weight=0, minsize=lpw + 20)
        self.root.columnconfigure(1, weight=1)
        self.root.rowconfigure(0, weight=1)
        right.columnconfigure(0, weight=1)
        right.rowconfigure(1, weight=1)
        combo_w = max(30, lpw // 9)

        # ── Source ────────────────────────────────────────────────────────
        ttk.Label(left, text="Source", font=("Helvetica", 10, "bold")).pack(anchor="w")
        source_mode_row = ttk.Frame(left)
        source_mode_row.pack(fill="x", pady=(4, 6))
        for label, val in [("Monitor", "monitor"), ("Window", "window"), ("Webcam", "webcam")]:
            ttk.Radiobutton(source_mode_row, text=label, variable=self.source_mode, value=val,
                             command=self.on_source_mode_changed).pack(side="left", padx=(0, 10))

        ttk.Label(left, text="Source monitor").pack(anchor="w")
        self.monitor_combo = ttk.Combobox(left, state="readonly",
            values=[self.monitor_label(i, m) for i, m in enumerate(self.monitors)], width=combo_w)
        self.monitor_combo.current(0)
        self.monitor_combo.pack(fill="x", pady=(2, 6))
        self.monitor_combo.bind("<<ComboboxSelected>>", self.on_monitor_changed)

        ttk.Label(left, text="Source window").pack(anchor="w")
        self.window_combo = ttk.Combobox(left, state="readonly", width=combo_w)
        self.window_combo.pack(fill="x", pady=(2, 4))
        self.window_combo.bind("<<ComboboxSelected>>", self.on_window_changed)
        ttk.Button(left, text="Refresh windows", command=self.refresh_windows).pack(fill="x", pady=(0, 8))

        ttk.Label(left, text="Source webcam").pack(anchor="w")
        self.webcam_combo = ttk.Combobox(left, state="readonly", width=combo_w)
        self.webcam_combo.pack(fill="x", pady=(2, 4))
        self.webcam_combo.bind("<<ComboboxSelected>>", self.on_webcam_changed)

        wcbr = ttk.Frame(left); wcbr.pack(fill="x", pady=(0, 4))
        ttk.Button(wcbr, text="Refresh webcams", command=self.refresh_webcams).pack(side="left", fill="x", expand=True, padx=(0, 4))
        ttk.Button(wcbr, text="Reconnect webcam", command=self.reconnect_webcam).pack(side="left", fill="x", expand=True)

        wcsr = ttk.Frame(left); wcsr.pack(fill="x", pady=(0, 4))
        ttk.Label(wcsr, text="Webcam res").pack(side="left")
        self.webcam_resolution_combo = ttk.Combobox(wcsr, state="readonly", width=12,
            values=["Default", "640x480", "1280x720", "1920x1080"])
        self.webcam_resolution_combo.pack(side="left", padx=(6, 8))
        self.webcam_resolution_combo.set("Default")
        self.webcam_resolution_combo.bind("<<ComboboxSelected>>", self.on_webcam_settings_changed)
        ttk.Label(wcsr, text="FPS").pack(side="left")
        ttk.Spinbox(wcsr, from_=1, to=60, textvariable=self.webcam_fps_var, width=5).pack(side="left", padx=(6, 8))
        ttk.Button(wcsr, text="Apply webcam settings", command=self.apply_webcam_settings).pack(side="left", fill="x", expand=True)

        wcp1 = ttk.Frame(left); wcp1.pack(fill="x", pady=(0, 4))
        ttk.Checkbutton(wcp1, text="Flip H", variable=self.webcam_flip_h_var, command=self.refresh_source_info).pack(side="left")
        ttk.Checkbutton(wcp1, text="Flip V", variable=self.webcam_flip_v_var, command=self.refresh_source_info).pack(side="left", padx=(8, 0))
        ttk.Label(wcp1, text="Rotate").pack(side="left", padx=(10, 0))
        self.webcam_rotate_combo = ttk.Combobox(wcp1, state="readonly", width=5, values=["0", "90", "180", "270"], textvariable=self.webcam_rotate_var)
        self.webcam_rotate_combo.pack(side="left", padx=(6, 8))
        self.webcam_rotate_combo.bind("<<ComboboxSelected>>", self.on_webcam_settings_changed)
        ttk.Button(wcp1, text="Capture snapshot", command=self.capture_webcam_snapshot).pack(side="left", fill="x", expand=True)

        wcp2 = ttk.Frame(left); wcp2.pack(fill="x", pady=(0, 8))
        ttk.Checkbutton(wcp2, text="Use frozen snapshot", variable=self.webcam_freeze_var, command=self.on_webcam_freeze_changed).pack(side="left")
        ttk.Label(wcp2, text="Bri").pack(side="left", padx=(8, 0))
        ttk.Spinbox(wcp2, from_=-100, to=100, textvariable=self.webcam_brightness_var, width=5).pack(side="left", padx=(4, 8))
        ttk.Label(wcp2, text="Con").pack(side="left")
        ttk.Spinbox(wcp2, from_=0, to=300, textvariable=self.webcam_contrast_var, width=5).pack(side="left", padx=(4, 8))
        ttk.Button(wcp2, text="Reset image", command=self.reset_webcam_image_adjustments).pack(side="left", fill="x", expand=True)

        self.source_info_label = ttk.Label(left, text="", justify="left")
        self.source_info_label.pack(anchor="w", pady=(0, 10))

        # ── Regions ───────────────────────────────────────────────────────
        ttk.Separator(left, orient="horizontal").pack(fill="x", pady=8)
        ttk.Label(left, text="Regions", font=("Helvetica", 10, "bold")).pack(anchor="w")
        list_h = 12 if self.left_panel_width <= 340 else (14 if self.left_panel_width <= 380 else 16)
        self.region_list = tk.Listbox(left, width=68, height=list_h, exportselection=False)
        self.region_list.pack(fill="both", expand=False, pady=(4, 6))
        self.region_list.bind("<<ListboxSelect>>", self.on_region_selected)

        rb = ttk.Frame(left); rb.pack(fill="x", pady=(0, 8))
        rb.columnconfigure(0, weight=1); rb.columnconfigure(1, weight=1)
        ttk.Button(rb, text="Source region editor", command=self.add_region).grid(row=0, column=0, sticky="ew", padx=(0, 4), pady=2)
        ttk.Button(rb, text="Duplicate region", command=self.duplicate_region).grid(row=0, column=1, sticky="ew", pady=2)
        ttk.Button(rb, text="Open source editor", command=self.edit_source_regions).grid(row=1, column=0, sticky="ew", padx=(0, 4), pady=2)
        ttk.Button(rb, text="Delete region", command=self.delete_region).grid(row=1, column=1, sticky="ew", pady=2)
        ttk.Button(rb, text="Move layer up", command=self.move_up).grid(row=2, column=0, sticky="ew", padx=(0, 4), pady=2)
        ttk.Button(rb, text="Move layer down", command=self.move_down).grid(row=2, column=1, sticky="ew", pady=2)

        # ── Region editor ─────────────────────────────────────────────────
        ttk.Separator(left, orient="horizontal").pack(fill="x", pady=8)
        ttk.Label(left, text="Selected region", font=("Helvetica", 10, "bold")).pack(anchor="w")
        rf = ttk.Frame(left); rf.pack(fill="x", pady=(4, 0))
        ttk.Label(rf, text="Name").grid(row=0, column=0, sticky="w")
        ttk.Entry(rf, textvariable=self.region_name_var, width=30).grid(row=0, column=1, sticky="ew", padx=(6, 0), columnspan=3)
        ttk.Label(rf, text="X").grid(row=1, column=0, sticky="w", pady=(6, 0))
        ttk.Spinbox(rf, from_=-5000, to=5000, textvariable=self.region_out_x_var, width=8).grid(row=1, column=1, sticky="w", padx=(6, 0), pady=(6, 0))
        ttk.Label(rf, text="Y").grid(row=1, column=2, sticky="w", padx=(12, 0), pady=(6, 0))
        ttk.Spinbox(rf, from_=-5000, to=5000, textvariable=self.region_out_y_var, width=8).grid(row=1, column=3, sticky="w", padx=(6, 0), pady=(6, 0))
        ttk.Label(rf, text="Height").grid(row=2, column=0, sticky="w", pady=(6, 0))
        ttk.Spinbox(rf, from_=10, to=4000, textvariable=self.region_target_h_var, width=8).grid(row=2, column=1, sticky="w", padx=(6, 0), pady=(6, 0))
        ttk.Label(rf, text="Width").grid(row=2, column=2, sticky="w", padx=(12, 0), pady=(6, 0))
        ttk.Spinbox(rf, from_=10, to=4000, textvariable=self.region_target_w_var, width=8).grid(row=2, column=3, sticky="w", padx=(6, 0), pady=(6, 0))
        ttk.Checkbutton(rf, text="Enabled", variable=self.region_enabled_var).grid(row=3, column=0, sticky="w", pady=(6, 0))
        ttk.Checkbutton(rf, text="Locked", variable=self.region_locked_var).grid(row=3, column=1, sticky="w", padx=(6, 0), pady=(6, 0))
        self.region_lock_aspect_checkbutton = ttk.Checkbutton(rf, text="Lock aspect ratio", variable=self.region_lock_aspect_var, command=self.on_region_lock_aspect_toggle)
        self.region_lock_aspect_checkbutton.grid(row=3, column=2, sticky="w", padx=(12, 0), pady=(6, 0), columnspan=2)
        ttk.Checkbutton(rf, text="Crop mode (edit source region)", variable=self.region_edit_as_crop_var, command=self.on_region_edit_mode_toggle).grid(row=4, column=0, sticky="w", pady=(6, 0), columnspan=4)

        ar = ttk.Frame(left); ar.pack(fill="x", pady=(6, 8))
        ttk.Button(ar, text="Apply region changes", command=self.apply_region_edits).pack(side="left", fill="x", expand=True, padx=(0, 4))
        ttk.Button(ar, text="Reset scale", command=self.reset_region_scale).pack(side="left", fill="x", expand=True)

        ttk.Label(left, text="Tip: Source region editor creates new regions on empty areas and edits existing regions directly. Crop mode adjusts source without scaling output.", justify="left", wraplength=max(300, lpw - 60)).pack(anchor="w", pady=(4, 2))

        nr = ttk.Frame(left); nr.pack(fill="x", pady=(0, 8))
        ttk.Label(nr, text="Nudge step").pack(side="left")
        ttk.Spinbox(nr, from_=1, to=500, textvariable=self.nudge_step, width=6).pack(side="left", padx=(6, 0))

        # ── HUD output ────────────────────────────────────────────────────
        ttk.Separator(left, orient="horizontal").pack(fill="x", pady=8)
        ttk.Label(left, text="HUD output", font=("Helvetica", 10, "bold")).pack(anchor="w")
        hf = ttk.Frame(left); hf.pack(fill="x", pady=(4, 0))
        ttk.Label(hf, text="Canvas W").grid(row=0, column=0, sticky="w")
        ttk.Spinbox(hf, from_=100, to=8000, textvariable=self.canvas_w, width=8).grid(row=0, column=1, sticky="w", padx=(6, 0))
        ttk.Label(hf, text="Canvas H").grid(row=0, column=2, sticky="w", padx=(12, 0))
        ttk.Spinbox(hf, from_=50, to=8000, textvariable=self.canvas_h, width=8).grid(row=0, column=3, sticky="w", padx=(6, 0))
        ttk.Label(hf, text="FPS").grid(row=1, column=0, sticky="w", pady=(6, 0))
        ttk.Spinbox(hf, from_=1, to=60, textvariable=self.target_fps, width=8).grid(row=1, column=1, sticky="w", padx=(6, 0), pady=(6, 0))

        ttk.Label(left, text="Output monitor").pack(anchor="w", pady=(6, 0))
        self.output_monitor_combo = ttk.Combobox(left, state="readonly",
            values=[self.monitor_label(i, m) for i, m in enumerate(self.monitors)], width=combo_w)
        self.output_monitor_combo.current(0)
        self.output_monitor_combo.pack(fill="x", pady=(2, 0))
        self.output_monitor_combo.bind("<<ComboboxSelected>>", self.on_output_monitor_changed)

        gr = ttk.Frame(left); gr.pack(fill="x", pady=(8, 0))
        ttk.Checkbutton(gr, text="Snap to grid", variable=self.snap_to_grid, command=self.on_output_snap_toggle).pack(side="left")
        ttk.Label(gr, text="Grid").pack(side="left", padx=(12, 0))
        ttk.Spinbox(gr, from_=1, to=500, textvariable=self.grid_size, width=6).pack(side="left", padx=(6, 0))

        ttk.Checkbutton(left, text="Show HUD output window", variable=self.show_output_window, command=self.update_output_window_state).pack(anchor="w", pady=(8, 2))
        ttk.Checkbutton(left, text="Fullscreen output window", variable=self.fullscreen_output, command=self.apply_output_window_geometry).pack(anchor="w", pady=(0, 6))

        vr = ttk.Frame(left); vr.pack(fill="x", pady=(4, 6))
        self.vcam_button = ttk.Button(vr, text="Start virtual cam", command=self.toggle_virtual_cam)
        self.vcam_button.pack(side="left", fill="x", expand=True)
        ttk.Button(vr, text="Test pattern", command=self.toggle_virtualcam_test_pattern).pack(side="left", padx=(6, 0))
        ttk.Label(left, textvariable=self.virtualcam_backend_name, justify="left").pack(anchor="w", pady=(0, 6))

        # ── VDO.Ninja ─────────────────────────────────────────────────
        ttk.Separator(left, orient="horizontal").pack(fill="x", pady=8)
        ttk.Label(left, text="VDO.Ninja streaming", font=("Helvetica", 10, "bold")).pack(anchor="w")
        vn = ttk.Frame(left); vn.pack(fill="x", pady=(4, 0))
        ttk.Label(vn, text="Stream ID").grid(row=0, column=0, sticky="w")
        ttk.Entry(vn, textvariable=self.vdoninja_stream_id_var, width=22).grid(row=0, column=1, sticky="ew", padx=(6, 0))
        vn.columnconfigure(1, weight=1)
        vnr = ttk.Frame(left); vnr.pack(fill="x", pady=(6, 4))
        self.vdoninja_button = ttk.Button(vnr, text="Start VDO.Ninja", command=self.toggle_vdoninja)
        self.vdoninja_button.pack(side="left", fill="x", expand=True, padx=(0, 4))
        ttk.Button(vnr, text="Open browser", command=self.open_vdoninja_browser).pack(side="left")
        ttk.Label(left, textvariable=self.vdoninja_status_var, justify="left", wraplength=max(300, lpw - 60)).pack(anchor="w", pady=(0, 2))
        ttk.Label(left, text="Tip: Start the server, open the browser page, enter a Stream ID and click Start. Viewers open https://vdo.ninja/?view=YOUR_ID", justify="left", wraplength=max(300, lpw - 60), foreground="gray").pack(anchor="w", pady=(0, 6))

        # ── Preset ────────────────────────────────────────────────────────
        ttk.Separator(left, orient="horizontal").pack(fill="x", pady=8)
        ttk.Label(left, text="Preset metadata", font=("Helvetica", 10, "bold")).pack(anchor="w")
        pm = ttk.Frame(left); pm.pack(fill="x", pady=(4, 6))
        ttk.Label(pm, text="Preset name").grid(row=0, column=0, sticky="w")
        ttk.Entry(pm, textvariable=self.preset_name_var, width=28).grid(row=0, column=1, sticky="ew", padx=(6, 0))
        ttk.Label(pm, text="DJ app").grid(row=1, column=0, sticky="w", pady=(6, 0))
        ttk.Entry(pm, textvariable=self.dj_app_name_var, width=28).grid(row=1, column=1, sticky="ew", padx=(6, 0), pady=(6, 0))
        pm.columnconfigure(1, weight=1)

        io = ttk.Frame(left); io.pack(fill="x", pady=(4, 0))
        ttk.Button(io, text="Save preset", command=self.save_layout).pack(side="left", fill="x", expand=True, padx=(0, 4))
        ttk.Button(io, text="Load preset", command=self.load_layout).pack(side="left", fill="x", expand=True)

        # ── System ────────────────────────────────────────────────────────
        ttk.Separator(left, orient="horizontal").pack(fill="x", pady=8)
        ttk.Label(left, text="System", font=("Helvetica", 10, "bold")).pack(anchor="w")
        sr = ttk.Frame(left); sr.pack(fill="x", pady=(4, 6))
        ttk.Button(sr, text="Run system diagnostics", command=self.run_diagnostics).pack(side="left", fill="x", expand=True, padx=(0, 4))
        ttk.Button(sr, text="About", command=self.show_about).pack(side="left")

        # ── Preview ───────────────────────────────────────────────────────
        ttk.Label(right, text="HUD Preview", font=("Helvetica", 11, "bold")).grid(row=0, column=0, sticky="w")
        ph = ttk.Frame(right); ph.grid(row=1, column=0, sticky="nsew", pady=(8, 0))
        ph.columnconfigure(0, weight=1); ph.rowconfigure(0, weight=1)
        self.preview_canvas = tk.Canvas(ph, bg=PREVIEW_BG, highlightthickness=1, relief="solid", takefocus=1)
        self.preview_hbar = ttk.Scrollbar(ph, orient="horizontal", command=self.preview_canvas.xview)
        self.preview_vbar = ttk.Scrollbar(ph, orient="vertical", command=self.preview_canvas.yview)
        self.preview_canvas.configure(xscrollcommand=self.preview_hbar.set, yscrollcommand=self.preview_vbar.set)
        self.preview_canvas.grid(row=0, column=0, sticky="nsew")
        self.preview_vbar.grid(row=0, column=1, sticky="ns")
        self.preview_hbar.grid(row=1, column=0, sticky="ew")
        self.preview_canvas.bind("<Button-1>", self.on_preview_click)
        self.preview_canvas.bind("<Double-Button-1>", self.on_preview_double_click)
        self.preview_canvas.bind("<B1-Motion>", self.on_preview_drag)
        self.preview_canvas.bind("<ButtonRelease-1>", self.on_preview_release)

        self.status_label = ttk.Label(right, text="Ready.")
        self.status_label.grid(row=2, column=0, sticky="ew", pady=(8, 0))

        self.on_source_mode_changed()
        self.update_output_window_state()
        self.update_vcam_button_text()

    # ══════════════════════════════════════════════════════════════════════════
    # DIAGNOSTICS / ABOUT
    # ══════════════════════════════════════════════════════════════════════════

    def run_diagnostics(self):
        diag = SystemDiagnostics()
        results = diag.run_all()
        DiagnosticsDialog(self.root, results, diag)

    def show_about(self):
        messagebox.showinfo("About DJ HUD",
            f"{APP_NAME} v{__version__}\n\n"
            f"Platform: {PLATFORM}\nPython: {sys.version.split()[0]}\n\n"
            "Cross-platform screen/webcam region capture\n"
            "and virtual camera output for DJs.\n\n"
            "pip install djhud\nhttps://github.com/djhud/djhud")

    def _check_macos_screen_permission_startup(self):
        """Check macOS screen capture permission at startup and guide the user."""
        result = check_macos_screen_permission()
        if result is True:
            self.status_label.configure(text="Screen capture permission: granted")
            return
        if result is False:
            req = request_macos_screen_permission()
            if req is True:
                self.status_label.configure(text="Screen capture permission: granted")
                return
            messagebox.showwarning(
                "Screen Capture Permission Required",
                "DJ HUD needs screen capture permission to work.\n\n"
                "To grant permission:\n"
                "1. Open System Settings\n"
                "2. Go to Privacy & Security → Screen Recording\n"
                "3. Enable the app you used to launch DJ HUD\n"
                "   (e.g. Terminal, iTerm2, or your Python IDE)\n"
                "4. Restart DJ HUD after granting permission\n\n"
                "Without this permission, screen captures will\n"
                "appear blank or show only the desktop wallpaper."
            )
            self.status_label.configure(
                text="⚠ Screen capture permission denied — see System Settings → Privacy & Security → Screen Recording"
            )
        else:
            self.status_label.configure(text="Ready (install pyobjc-framework-Quartz for permission detection)")

    # ══════════════════════════════════════════════════════════════════════════
    # MONITOR / WINDOW / WEBCAM
    # ══════════════════════════════════════════════════════════════════════════

    def monitor_label(self, idx, mon):
        return f'Monitor {idx + 1}: {mon["width"]}x{mon["height"]} @ ({mon["left"]},{mon["top"]})'

    def refresh_windows(self):
        self.windows = list_visible_windows()
        values = [f'{w.title}  [{w.width}x{w.height} @ {w.left},{w.top}]' for w in self.windows]
        self.window_combo["values"] = values
        if values:
            c = self.window_combo.current()
            self.window_combo.current(min(max(0, c), len(values) - 1))
        self.refresh_source_info()

    def _try_open_webcam(self, device):
        if cv2 is None:
            return None
        backends = get_webcam_backends()
        resolution = self.parse_webcam_resolution()
        fps = max(1, int(self.webcam_fps_var.get()))
        for backend in backends:
            try:
                cap = cv2.VideoCapture(device, backend) if backend != 0 else cv2.VideoCapture(device)
            except Exception:
                cap = None
            if cap is None:
                continue
            try:
                if resolution:
                    cap.set(cv2.CAP_PROP_FRAME_WIDTH, resolution[0])
                    cap.set(cv2.CAP_PROP_FRAME_HEIGHT, resolution[1])
                cap.set(cv2.CAP_PROP_FPS, fps)
                if cap.isOpened():
                    ok, frame = cap.read()
                    if ok and frame is not None:
                        self.webcam_error_message = ""
                        return cap
            except Exception:
                pass
            try:
                cap.release()
            except Exception:
                pass
        return None

    def probe_webcam_devices(self, max_devices=4):
        if cv2 is None:
            return []
        # On macOS, only probe device 0 (built-in camera) to avoid crashes
        if IS_MAC:
            max_devices = min(max_devices, 1)
        found = []
        for idx in range(max_devices):
            try:
                cap = self._try_open_webcam(idx)
            except Exception:
                cap = None
            if cap is None:
                continue
            try:
                ok, frame = cap.read()
                if ok and frame is not None:
                    h, w = frame.shape[:2]
                    found.append((idx, f"Camera {idx} [{w}x{h}]"))
            except Exception:
                pass
            finally:
                try: cap.release()
                except Exception: pass
        return found

    def refresh_webcams(self, startup=False):
        self.release_webcam()
        # On macOS, skip webcam probing at startup — OpenCV can crash hard
        # when accessing cameras before permissions are fully granted.
        # Users can click "Refresh webcams" once they're ready.
        if startup and IS_MAC:
            self.webcam_devices = []
            self.webcam_combo["values"] = ["(click Refresh webcams to detect)"]
            self.webcam_combo.set("(click Refresh webcams to detect)")
            self.selected_webcam_index.set(0)
            self.refresh_source_info()
            return
        self.webcam_devices = self.probe_webcam_devices()
        values = [label for _, label in self.webcam_devices]
        self.webcam_combo["values"] = values
        if values:
            c = self.webcam_combo.current()
            if c < 0: c = min(self.selected_webcam_index.get(), len(values) - 1)
            self.webcam_combo.current(min(c, len(values) - 1))
            self.selected_webcam_index.set(min(c, len(values) - 1))
            if not startup and self.source_mode.get() == "webcam":
                self.ensure_webcam_open()
        else:
            self.webcam_combo.set("")
            self.selected_webcam_index.set(0)
        self.refresh_source_info()

    def on_webcam_changed(self, event=None):
        self.selected_webcam_index.set(self.webcam_combo.current())
        self.reconnect_webcam()

    def reconnect_webcam(self):
        if not self.webcam_freeze_var.get():
            self.webcam_frozen_frame = None
        self.release_webcam()
        self.ensure_webcam_open()
        if self.source_mode.get() == "webcam" and self.webcam_cap is None:
            self.status_label.configure(text=self.webcam_error_message or "Webcam could not be opened.")
        self.refresh_source_info()

    def get_selected_webcam_device(self):
        idx = self.webcam_combo.current()
        if idx < 0 or idx >= len(self.webcam_devices): return None
        return self.webcam_devices[idx][0]

    def parse_webcam_resolution(self):
        value = self.webcam_resolution_combo.get().strip() if hasattr(self, "webcam_resolution_combo") else self.webcam_resolution_var.get().strip()
        if not value or value.lower() == "default": return None
        try:
            w, h = value.lower().split("x"); return int(w), int(h)
        except Exception: return None

    def on_webcam_settings_changed(self, event=None):
        try: self.webcam_resolution_var.set(self.webcam_resolution_combo.get())
        except Exception: pass
        self.refresh_source_info()

    def capture_webcam_snapshot(self):
        frame = None
        if self.webcam_cap is not None:
            cf = bool(self.webcam_freeze_var.get())
            if cf: self.webcam_freeze_var.set(False)
            frame = self.read_webcam_frame()
            if cf: self.webcam_freeze_var.set(True)
        if frame is None: frame = self.webcam_frame
        if frame is not None:
            self.webcam_frozen_frame = frame.copy()
            self.webcam_freeze_var.set(True)
            self.webcam_error_message = ""
        else:
            self.webcam_error_message = "No webcam frame available for snapshot."
        self.refresh_source_info()

    def on_webcam_freeze_changed(self):
        if self.webcam_freeze_var.get() and self.webcam_frozen_frame is None:
            self.capture_webcam_snapshot()
        self.refresh_source_info()

    def reset_webcam_image_adjustments(self):
        self.webcam_flip_h_var.set(False); self.webcam_flip_v_var.set(False)
        self.webcam_rotate_var.set("0"); self.webcam_brightness_var.set(0); self.webcam_contrast_var.set(100)
        self.refresh_source_info()

    def _process_webcam_frame(self, frame):
        if cv2 is None or frame is None: return frame
        try:
            if self.webcam_flip_h_var.get() and self.webcam_flip_v_var.get(): frame = cv2.flip(frame, -1)
            elif self.webcam_flip_h_var.get(): frame = cv2.flip(frame, 1)
            elif self.webcam_flip_v_var.get(): frame = cv2.flip(frame, 0)
            rot = self.webcam_rotate_var.get().strip()
            if rot == "90": frame = cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE)
            elif rot == "180": frame = cv2.rotate(frame, cv2.ROTATE_180)
            elif rot == "270": frame = cv2.rotate(frame, cv2.ROTATE_90_COUNTERCLOCKWISE)
            alpha = max(0.0, float(self.webcam_contrast_var.get()) / 100.0)
            beta = int(self.webcam_brightness_var.get())
            if alpha != 1.0 or beta != 0: frame = cv2.convertScaleAbs(frame, alpha=alpha, beta=beta)
        except Exception: pass
        return frame

    def apply_webcam_settings(self):
        self.webcam_error_message = ""; self.reconnect_webcam()

    def ensure_webcam_open(self):
        if cv2 is None: self.webcam_error_message = "OpenCV not available."; return
        device = self.get_selected_webcam_device()
        if device is None: self.webcam_error_message = "No webcam selected."; return
        if self.webcam_cap is not None and getattr(self, "webcam_device_id", None) == device: return
        self.release_webcam()
        cap = self._try_open_webcam(device)
        if cap is None or not cap.isOpened():
            if cap: cap.release()
            self.webcam_cap = None; self.webcam_device_id = None
            self.webcam_error_message = f"Webcam {device} could not be opened."; return
        self.webcam_cap = cap; self.webcam_device_id = device
        self.webcam_read_fail_count = 0; self.webcam_error_message = ""
        self.read_webcam_frame()

    def release_webcam(self):
        if self.webcam_cap is not None:
            try: self.webcam_cap.release()
            except Exception: pass
        self.webcam_cap = None; self.webcam_device_id = None; self.webcam_read_fail_count = 0

    def read_webcam_frame(self):
        if self.webcam_freeze_var.get() and self.webcam_frozen_frame is not None:
            return self.webcam_frozen_frame
        if cv2 is None: return self.webcam_frame
        self.ensure_webcam_open()
        if self.webcam_cap is None:
            return self.webcam_frozen_frame if self.webcam_freeze_var.get() else self.webcam_frame
        ok, frame = self.webcam_cap.read()
        if not ok or frame is None:
            self.webcam_read_fail_count += 1
            if self.webcam_read_fail_count >= 3:
                self.webcam_error_message = "Webcam connection lost. Reconnecting..."
                device = self.webcam_device_id; self.release_webcam()
                if device is not None: self.ensure_webcam_open()
            return self.webcam_frozen_frame if self.webcam_freeze_var.get() else self.webcam_frame
        self.webcam_read_fail_count = 0; self.webcam_error_message = ""
        frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
        frame = self._process_webcam_frame(frame)
        if Image is not None: self.webcam_frame = Image.fromarray(frame)
        return self.webcam_frozen_frame if (self.webcam_freeze_var.get() and self.webcam_frozen_frame) else self.webcam_frame

    # ══════════════════════════════════════════════════════════════════════════
    # SOURCE MODE
    # ══════════════════════════════════════════════════════════════════════════

    def on_source_mode_changed(self):
        mode = self.source_mode.get()
        self.monitor_combo.configure(state="readonly" if mode == "monitor" else "disabled")
        self.window_combo.configure(state="readonly" if mode == "window" else "disabled")
        self.webcam_combo.configure(state="readonly" if mode == "webcam" else "disabled")
        if mode == "webcam":
            if not getattr(self, "webcam_devices", []): self.refresh_webcams(startup=False)
            self.ensure_webcam_open()
        self.refresh_source_info()

    def on_monitor_changed(self, event=None): self.refresh_source_info()
    def on_window_changed(self, event=None): self.refresh_source_info()

    def get_current_window_info(self):
        idx = self.window_combo.current()
        if idx < 0 or idx >= len(self.windows): return None
        return self.windows[idx]

    def get_current_source_rect(self):
        mode = self.source_mode.get()
        if mode == "monitor":
            idx = self.monitor_combo.current()
            if idx < 0 or idx >= len(self.monitors): return None
            m = self.monitors[idx]
            return {"left": m["left"], "top": m["top"], "width": m["width"], "height": m["height"]}
        if mode == "window":
            win = self.get_current_window_info()
            if win is None: return None
            if IS_WINDOWS:
                rect = get_window_rect(win.hwnd)
                if rect is None: return None
                l, t, r, b = rect
                return {"left": l, "top": t, "width": max(1, r - l), "height": max(1, b - t)}
            return {"left": win.left, "top": win.top, "width": win.width, "height": win.height}
        frame = self.read_webcam_frame()
        if frame is None: return None
        w, h = frame.size
        return {"left": 80, "top": 80, "width": w, "height": h}

    def refresh_source_info(self):
        rect = self.get_current_source_rect()
        if rect is None:
            self.source_info_label.configure(text="No valid source found."); return
        mode = self.source_mode.get()
        if mode == "monitor":
            text = f'Mode: Monitor\nPosition: {rect["left"]}, {rect["top"]}\nResolution: {rect["width"]} x {rect["height"]}'
        elif mode == "window":
            win = self.get_current_window_info()
            text = f'Mode: Window\nTitle: {win.title if win else "?"}\nPosition: {rect["left"]}, {rect["top"]}\nSize: {rect["width"]} x {rect["height"]}'
        else:
            dev = self.get_selected_webcam_device()
            label = "None" if dev is None else f"Camera {dev}"
            tres = self.webcam_resolution_combo.get() if hasattr(self, "webcam_resolution_combo") else self.webcam_resolution_var.get()
            err = f'\nStatus: {self.webcam_error_message}' if self.webcam_error_message else ""
            text = f'Mode: Webcam\nDevice: {label}\nLive: {rect["width"]} x {rect["height"]}\nRequested: {tres} @ {self.webcam_fps_var.get()} fps{err}'
        self.source_info_label.configure(text=text)

    # ══════════════════════════════════════════════════════════════════════════
    # REGION MANAGEMENT
    # ══════════════════════════════════════════════════════════════════════════

    def get_selected_region_index(self):
        if self.selected_region_index is not None and 0 <= self.selected_region_index < len(self.regions):
            return self.selected_region_index
        return None

    def clear_selection(self): self.select_region_by_index(None)

    def select_region_by_index(self, idx):
        self.region_list.selection_clear(0, tk.END)
        self.selected_region_index = None
        if idx is None or not (0 <= idx < len(self.regions)):
            self.clear_region_editor(); return
        self.selected_region_index = idx
        self.region_list.selection_set(idx); self.region_list.activate(idx); self.region_list.see(idx)
        self.load_region_into_editor(idx)

    def add_region(self):
        source_rect = self.get_current_source_rect()
        if source_rect is None:
            messagebox.showerror("No source", "Select a valid source first."); return
        def on_done(local_rect):
            sx, sy, sw, sh = local_rect
            idx = len(self.regions) + 1
            dx, dy = 10, 10
            if self.regions:
                last = self.regions[-1]; dx = last.out_x + last.estimated_width() + 10; dy = last.out_y
            r = CaptureRegion(name=f"Region {idx}", src_x=sx, src_y=sy, src_w=sw, src_h=sh,
                              out_x=dx, out_y=dy, target_h=min(DEFAULT_REGION_H, max(20, sh)),
                              enabled=True, raw_out_x=dx, raw_out_y=dy)
            self.regions.append(r); self.update_region_list(select_idx=len(self.regions) - 1)
        # On Mac, always capture a screenshot to use as background in the editor.
        # This avoids focus-loss issues (overlay disappears on alt-tab) and
        # prevents capturing the HUD window itself.
        if IS_MAC or self.source_mode.get() == "webcam":
            bg = self.capture_current_source_frame(for_editor=True)
        else:
            bg = None
        RegionSelector(self.root, source_rect, on_done, continuous=True, existing_regions=self.regions,
                       nudge_step=int(self.nudge_step.get()), snap_to_grid=bool(self.snap_source_to_grid.get()),
                       grid_size=int(self.grid_size.get()), background_image=bg)

    def duplicate_region(self):
        idx = self.get_selected_region_index()
        if idx is None: return
        s = self.regions[idx]
        d = CaptureRegion(name=f"{s.name} copy", src_x=s.src_x, src_y=s.src_y, src_w=s.src_w, src_h=s.src_h,
                          out_x=s.out_x + 20, out_y=s.out_y + 20, target_h=s.target_h, enabled=s.enabled, locked=False,
                          raw_out_x=(s.raw_out_x or s.out_x) + 20, raw_out_y=(s.raw_out_y or s.out_y) + 20)
        self.regions.insert(idx + 1, d); self.update_region_list(select_idx=idx + 1)

    def delete_region(self):
        idx = self.get_selected_region_index()
        if idx is None: return
        del self.regions[idx]
        self.update_region_list(select_idx=min(idx, len(self.regions) - 1) if self.regions else None)

    def move_up(self):
        idx = self.get_selected_region_index()
        if idx is None or idx == 0: return
        self.regions[idx - 1], self.regions[idx] = self.regions[idx], self.regions[idx - 1]
        self.update_region_list(select_idx=idx - 1)

    def move_down(self):
        idx = self.get_selected_region_index()
        if idx is None or idx >= len(self.regions) - 1: return
        self.regions[idx + 1], self.regions[idx] = self.regions[idx], self.regions[idx + 1]
        self.update_region_list(select_idx=idx + 1)

    def update_region_list(self, select_idx=None):
        if select_idx is None: select_idx = self.selected_region_index
        self.region_list.delete(0, tk.END)
        for r in self.regions:
            self.sync_region_display_position(r)
            st = "ON " if r.enabled else "OFF"
            self.region_list.insert(tk.END, f"{st} | {r.name} | src({r.src_x},{r.src_y},{r.src_w},{r.src_h}) -> out({r.out_x},{r.out_y},h={r.target_h},w~{r.estimated_width()})")
        self.select_region_by_index(select_idx)

    def on_region_selected(self, event=None):
        sel = self.region_list.curselection()
        if sel: self.select_region_by_index(sel[0])

    def clear_region_editor(self):
        self.region_name_var.set(""); self.region_enabled_var.set(True); self.region_locked_var.set(False)
        self.region_lock_aspect_var.set(True); self.region_edit_as_crop_var.set(False)
        self.region_out_x_var.set(0); self.region_out_y_var.set(0)
        self.region_target_h_var.set(DEFAULT_REGION_H); self.region_target_w_var.set(DEFAULT_REGION_H)

    def load_region_into_editor(self, idx):
        r = self.regions[idx]
        self.region_name_var.set(r.name); self.region_enabled_var.set(r.enabled); self.region_locked_var.set(r.locked)
        self.region_edit_as_crop_var.set(bool(getattr(r, "edit_as_crop", False)))
        self.region_lock_aspect_var.set(bool(getattr(r, "lock_aspect", not bool(getattr(r, "edit_as_crop", False)))))
        self.update_lock_aspect_widget_state(); self.sync_region_display_position(r)
        self.region_out_x_var.set(r.out_x); self.region_out_y_var.set(r.out_y)
        if self.region_edit_as_crop_var.get():
            self.region_target_h_var.set(r.src_h); self.region_target_w_var.set(r.src_w)
        else:
            self.region_target_h_var.set(r.target_h); self.region_target_w_var.set(r.estimated_width())

    def update_lock_aspect_widget_state(self):
        try: self.region_lock_aspect_checkbutton.configure(state="normal" if self.region_edit_as_crop_var.get() else "disabled")
        except Exception: pass

    def on_region_edit_mode_toggle(self):
        idx = self.get_selected_region_index()
        if idx is None: return
        r = self.regions[idx]; ec = bool(self.region_edit_as_crop_var.get()); r.edit_as_crop = ec
        if ec:
            self.region_target_h_var.set(r.src_h); self.region_target_w_var.set(r.src_w)
            self.region_lock_aspect_var.set(False); r.lock_aspect = False
        else:
            self.region_target_h_var.set(r.target_h); self.region_target_w_var.set(r.estimated_width())
            self.region_lock_aspect_var.set(True); r.lock_aspect = True
        self.update_lock_aspect_widget_state()

    def on_region_lock_aspect_toggle(self):
        idx = self.get_selected_region_index()
        if idx is None: return
        r = self.regions[idx]
        if not self.region_edit_as_crop_var.get():
            self.region_lock_aspect_var.set(True); r.lock_aspect = True; self.update_lock_aspect_widget_state(); return
        r.lock_aspect = bool(self.region_lock_aspect_var.get())

    def apply_region_edits(self):
        idx = self.get_selected_region_index()
        if idx is None: return
        r = self.regions[idx]
        r.name = self.region_name_var.get().strip() or r.name
        r.enabled = bool(self.region_enabled_var.get()); r.locked = bool(self.region_locked_var.get())
        r.edit_as_crop = bool(self.region_edit_as_crop_var.get()); r.lock_aspect = bool(self.region_lock_aspect_var.get())
        nx, ny = int(self.region_out_x_var.get()), int(self.region_out_y_var.get())
        dh, dw = max(10, int(self.region_target_h_var.get())), max(10, int(self.region_target_w_var.get()))
        if r.edit_as_crop:
            osw, osh = max(1, r.src_w), max(1, r.src_h)
            la = bool(self.region_lock_aspect_var.get())
            if la:
                ratio = osh / max(1, osw)
                if dw != osw: r.src_w = dw; r.src_h = max(1, int(round(dw * ratio)))
                else: r.src_h = dh; r.src_w = max(1, int(round(dh / ratio))) if ratio else r.src_w
            else: r.src_w = dw; r.src_h = dh
            r.target_h = max(10, r.src_h)
        else:
            cw = r.estimated_width(); ratio = r.src_h / max(1, r.src_w)
            if dw != cw: r.target_h = max(10, int(round(dw * ratio)))
            else: r.target_h = dh
        r.raw_out_x = nx; r.raw_out_y = ny
        self.sync_region_display_position(r); self.update_region_list(select_idx=idx)

    def reset_region_scale(self):
        idx = self.get_selected_region_index()
        if idx is None: return
        self.regions[idx].target_h = max(10, self.regions[idx].src_h); self.update_region_list(select_idx=idx)

    def edit_source_regions(self):
        sr = self.get_current_source_rect()
        if sr is None or not self.regions: return
        def on_done(ur): self.regions = ur; self.update_region_list(select_idx=self.get_selected_region_index())
        if IS_MAC or self.source_mode.get() == "webcam":
            bg = self.capture_current_source_frame(for_editor=True)
        else:
            bg = None
        SourceRegionEditor(self.root, sr, self.regions, on_done, nudge_step=int(self.nudge_step.get()),
                           snap_to_grid=bool(self.snap_source_to_grid.get()), grid_size=int(self.grid_size.get()), background_image=bg)

    def apply_snap(self, x, y):
        g = max(1, int(self.grid_size.get())); return round(x / g) * g, round(y / g) * g

    def on_output_snap_toggle(self):
        for r in self.regions:
            if not self.snap_to_grid.get(): r.raw_out_x = r.out_x; r.raw_out_y = r.out_y
            self.sync_region_display_position(r)
        self.update_region_list(select_idx=self.get_selected_region_index())

    def sync_region_display_position(self, r):
        if r.raw_out_x is None: r.raw_out_x = r.out_x
        if r.raw_out_y is None: r.raw_out_y = r.out_y
        if self.snap_to_grid.get(): r.out_x, r.out_y = self.apply_snap(r.raw_out_x, r.raw_out_y)
        else: r.out_x = int(round(r.raw_out_x)); r.out_y = int(round(r.raw_out_y))

    def set_region_raw_position(self, r, rx, ry):
        r.raw_out_x = rx; r.raw_out_y = ry; self.sync_region_display_position(r)

    # ══════════════════════════════════════════════════════════════════════════
    # CAPTURE / COMPOSE
    # ══════════════════════════════════════════════════════════════════════════

    def _grab_rect_mss(self, rect):
        """Fast screen grab via mss — used in the render loop.

        mss reads directly from the screen framebuffer, so we don't need to
        hide/show the output window.  This eliminates the violent flicker that
        happened when withdraw/deiconify ran every frame.
        """
        if self.sct is None or Image is None:
            return None
        try:
            monitor = {"left": rect["left"], "top": rect["top"],
                       "width": rect["width"], "height": rect["height"]}
            sct_img = self.sct.grab(monitor)
            return Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRX")
        except Exception:
            return None

    def _grab_rect_imagegrab(self, rect):
        """Screen grab via Pillow ImageGrab — used for one-shot captures
        (region editors) where we hide the output window first."""
        return grab_monitor_rect(rect)

    def capture_current_source_frame(self, for_editor=False):
        """Capture the current source.

        for_editor=False  (render loop) — use mss, never hide output window.
        for_editor=True   (region editor) — hide output window, use ImageGrab.
        """
        mode = self.source_mode.get()
        if mode == "monitor":
            rect = self.get_current_source_rect()
            if rect is None:
                return None
            if for_editor:
                self._hide_output_for_capture()
                result = self._grab_rect_imagegrab(rect)
                self._restore_output_after_capture()
                return result
            return self._grab_rect_mss(rect) or self._grab_rect_imagegrab(rect)
        if mode == "window":
            win = self.get_current_window_info()
            if win is None:
                return None
            # Try platform-specific window capture first (no screen-grab needed)
            img = capture_window(win.hwnd)
            if img:
                return img
            # Fallback to screen grab of the window's rect
            rect = self.get_current_source_rect()
            if rect is None:
                return None
            if for_editor:
                self._hide_output_for_capture()
                result = self._grab_rect_imagegrab(rect)
                self._restore_output_after_capture()
                return result
            return self._grab_rect_mss(rect) or self._grab_rect_imagegrab(rect)
        return self.read_webcam_frame()

    def _hide_output_for_capture(self):
        """Temporarily hide the output window so it doesn't appear in screen grabs."""
        try:
            if self.output_window and self.output_window.winfo_exists():
                self.output_window.withdraw()
                self.output_window.update_idletasks()
        except Exception:
            pass

    def _restore_output_after_capture(self):
        """Restore the output window after capture."""
        try:
            if self.output_window and self.output_window.winfo_exists() and self.show_output_window.get():
                self.output_window.deiconify()
        except Exception:
            pass

    def compose_canvas(self):
        w, h = max(1, int(self.canvas_w.get())), max(1, int(self.canvas_h.get()))
        canvas = Image.new("RGB", (w, h), (0, 0, 0))
        src = self.capture_current_source_frame()
        if src is None: return canvas
        sw, sh = src.size
        for r in self.regions:
            if not r.enabled: continue
            cl, ct = max(0, r.src_x), max(0, r.src_y)
            cr, cb = min(sw, r.src_x + r.src_w), min(sh, r.src_y + r.src_h)
            if cr <= cl or cb <= ct: continue
            part = src.crop((cl, ct, cr, cb))
            ach = cb - ct
            if ach <= 0 or r.target_h <= 0: continue
            scale = r.target_h / ach
            nw = max(1, int((cr - cl) * scale))
            part = part.resize((nw, r.target_h), Image.Resampling.LANCZOS)
            self._safe_paste(canvas, part, r.out_x, r.out_y)
        return canvas

    def _safe_paste(self, base, overlay, x, y):
        bw, bh = base.size; ow, oh = overlay.size
        dl, dt = max(0, x), max(0, y)
        dr, db = min(bw, x + ow), min(bh, y + oh)
        if dr <= dl or db <= dt: return
        sl, st = dl - x, dt - y
        base.paste(overlay.crop((sl, st, sl + (dr - dl), st + (db - dt))), (dl, dt))

    # ══════════════════════════════════════════════════════════════════════════
    # PREVIEW
    # ══════════════════════════════════════════════════════════════════════════

    def compute_preview_workspace(self):
        cw, ch = max(1, int(self.canvas_w.get())), max(1, int(self.canvas_h.get()))
        mnx, mny, mxx, mxy = 0, 0, cw, ch
        for r in self.regions:
            mnx = min(mnx, r.out_x); mny = min(mny, r.out_y)
            mxx = max(mxx, r.out_x + r.estimated_width()); mxy = max(mxy, r.out_y + r.target_h)
        return mnx, mny, mxx, mxy

    def make_annotated_preview(self, canvas_img):
        cw, ch = canvas_img.size
        mnx, mny, mxx, mxy = self.compute_preview_workspace()
        wsw = mxx - mnx + PREVIEW_PADDING * 2; wsh = mxy - mny + PREVIEW_PADDING * 2
        ox = -mnx + PREVIEW_PADDING; oy = -mny + PREVIEW_PADDING
        self.preview_origin_x = ox; self.preview_origin_y = oy
        ws = Image.new("RGB", (wsw, wsh), PREVIEW_OUTSIDE_BG)
        ws.paste(canvas_img, (ox, oy))
        draw = ImageDraw.Draw(ws)
        draw.rectangle([ox, oy, ox + cw - 1, oy + ch - 1], outline=(140, 140, 140), width=2)
        # Build list of region pixel areas to exclude from grid
        region_boxes = []
        for r in self.regions:
            if r.enabled:
                region_boxes.append((ox + r.out_x, oy + r.out_y,
                                     ox + r.out_x + r.estimated_width(),
                                     oy + r.out_y + r.target_h))
        # Draw grid lines only in empty areas (skip segments that overlap regions)
        if self.snap_to_grid.get():
            g = max(1, int(self.grid_size.get())); gc = (50, 50, 50)
            for gx in range(0, cw + 1, g):
                lx = ox + gx
                # Draw vertical line in segments, skipping region interiors
                seg_start = oy
                for rb in sorted(region_boxes, key=lambda b: b[1]):
                    if lx < rb[0] or lx > rb[2]:
                        continue  # line doesn't cross this region horizontally
                    if seg_start < rb[1]:
                        draw.line((lx, seg_start, lx, rb[1]), fill=gc, width=1)
                    seg_start = max(seg_start, rb[3])
                if seg_start < oy + ch:
                    draw.line((lx, seg_start, lx, oy + ch), fill=gc, width=1)
            for gy in range(0, ch + 1, g):
                ly = oy + gy
                seg_start = ox
                for rb in sorted(region_boxes, key=lambda b: b[0]):
                    if ly < rb[1] or ly > rb[3]:
                        continue
                    if seg_start < rb[0]:
                        draw.line((seg_start, ly, rb[0], ly), fill=gc, width=1)
                    seg_start = max(seg_start, rb[2])
                if seg_start < ox + cw:
                    draw.line((seg_start, ly, ox + cw, ly), fill=gc, width=1)
        si = self.get_selected_region_index()
        for i, r in enumerate(self.regions):
            x1, y1 = ox + r.out_x, oy + r.out_y
            x2, y2 = x1 + r.estimated_width(), y1 + r.target_h
            ol = (255, 180, 0) if si == i else ((0, 180, 255) if r.enabled else (120, 120, 120))
            w = 3 if si == i else 2
            draw.rectangle([x1, y1, x2, y2], outline=ol, width=w)
            draw.text((x1 + 4, y1 + 2), f"R{i+1}", fill=(255, 255, 255))
            if si == i:
                hs = 2
                for px, py in [(x1, y1), ((x1+x2)//2, y1), (x2, y1), (x1, (y1+y2)//2), (x2, (y1+y2)//2), (x1, y2), ((x1+x2)//2, y2), (x2, y2)]:
                    draw.rectangle([px-hs, py-hs, px+hs-1, py+hs-1], outline=(111, 199, 255), fill=(111, 199, 255))
        return ws

    def update_preview(self, img):
        if ImageTk is None: return
        self.preview_photo = ImageTk.PhotoImage(img)
        self.preview_canvas.delete("all")
        self.preview_canvas.create_image(0, 0, anchor="nw", image=self.preview_photo)
        self.preview_canvas.configure(scrollregion=(0, 0, img.width, img.height))

    def preview_to_actual_canvas_coords(self, event):
        return int(self.preview_canvas.canvasx(event.x)) - self.preview_origin_x, int(self.preview_canvas.canvasy(event.y)) - self.preview_origin_y

    def get_region_bounds(self, r):
        return r.out_x, r.out_y, r.out_x + r.estimated_width(), r.out_y + r.target_h

    def get_resize_handle_hit(self, r, ax, ay):
        x1, y1, x2, y2 = self.get_region_bounds(r); hs = 4
        for name, (hx1, hy1, hx2, hy2) in {"nw": (x1-hs, y1-hs, x1+hs, y1+hs), "ne": (x2-hs, y1-hs, x2+hs, y1+hs),
            "sw": (x1-hs, y2-hs, x1+hs, y2+hs), "se": (x2-hs, y2-hs, x2+hs, y2+hs),
            "n": (x1+hs, y1-hs, x2-hs, y1+hs), "s": (x1+hs, y2-hs, x2-hs, y2+hs),
            "w": (x1-hs, y1+hs, x1+hs, y2-hs), "e": (x2-hs, y1+hs, x2+hs, y2-hs)}.items():
            if hx1 <= ax <= hx2 and hy1 <= ay <= hy2: return name
        return None

    def region_hit_test(self, ax, ay):
        for i in reversed(range(len(self.regions))):
            x1, y1, x2, y2 = self.get_region_bounds(self.regions[i])
            if x1 <= ax <= x2 and y1 <= ay <= y2: return i
        return None

    def on_preview_click(self, event):
        try: self.preview_canvas.focus_set()
        except Exception: pass
        ax, ay = self.preview_to_actual_canvas_coords(event)
        idx = self.region_hit_test(ax, ay)
        if idx is None:
            self.clear_selection(); self.drag_region_index = None; self.drag_mode = None; self.drag_start_geometry = None; return "break"
        self.select_region_by_index(idx); self.drag_region_index = idx
        self.drag_start_mouse = (ax, ay)
        r = self.regions[idx]
        self.drag_start_geometry = (r.raw_out_x or r.out_x, r.raw_out_y or r.out_y, r.target_h, r.src_w, r.src_h)
        if r.locked: self.drag_mode = None; return
        h = self.get_resize_handle_hit(r, ax, ay)
        self.drag_mode = h if h else "move"

    def on_preview_double_click(self, event): return self.on_preview_click(event)

    def on_preview_drag(self, event):
        if self.drag_region_index is None or self.drag_mode is None: return
        ax, ay = self.preview_to_actual_canvas_coords(event)
        dx, dy = ax - self.drag_start_mouse[0], ay - self.drag_start_mouse[1]
        r = self.regions[self.drag_region_index]
        sx, sy, sh, ssw, ssh = self.drag_start_geometry
        if self.drag_mode == "move":
            self.set_region_raw_position(r, sx + dx, sy + dy)
        elif getattr(r, "edit_as_crop", False):
            la = bool(getattr(r, "lock_aspect", False))
            nw, nh = ssw, ssh
            if "n" in self.drag_mode: nh = max(5, ssh - dy)
            elif "s" in self.drag_mode: nh = max(5, ssh + dy)
            if "e" in self.drag_mode: nw = max(5, ssw + dx)
            elif "w" in self.drag_mode: nw = max(5, ssw - dx)
            if la:
                ratio = ssh / max(1, ssw)
                if ("e" in self.drag_mode or "w" in self.drag_mode) and "n" not in self.drag_mode and "s" not in self.drag_mode:
                    nh = max(5, int(round(nw * ratio)))
                elif ("n" in self.drag_mode or "s" in self.drag_mode) and "e" not in self.drag_mode and "w" not in self.drag_mode:
                    nw = max(5, int(round(nh / ratio))) if ratio else nw
                else: nh = max(5, int(round(nw * ratio)))
            r.src_w, r.src_h = nw, nh; r.target_h = max(10, r.src_h)
            rx, ry = sx, sy
            if "w" in self.drag_mode: rx = sx + (ssw - r.src_w)
            if "n" in self.drag_mode: ry = sy + (ssh - r.src_h)
            r.raw_out_x, r.raw_out_y = rx, ry; self.sync_region_display_position(r)
        else:
            nh = sh
            if "n" in self.drag_mode: nh = max(10, sh - dy)
            elif "s" in self.drag_mode: nh = max(10, sh + dy)
            elif "e" in self.drag_mode or "w" in self.drag_mode:
                sw2 = r.estimated_width() if sh > 0 else 1
                dw = dx if "e" in self.drag_mode else -dx
                tw = max(10, sw2 + dw); ratio = r.src_h / max(1, r.src_w); nh = max(10, int(tw * ratio))
            r.target_h = nh
            rx = r.raw_out_x or r.out_x; ry = r.raw_out_y or r.out_y
            if "n" in self.drag_mode: ry = sy + (sh - r.target_h)
            if "w" in self.drag_mode:
                sw2 = int(r.src_w * (sh / max(1, r.src_h))); rx = sx + (sw2 - r.estimated_width())
            r.raw_out_x, r.raw_out_y = rx, ry
            if self.snap_to_grid.get():
                gs = max(1, int(self.grid_size.get())); r.target_h = max(10, round(r.target_h / gs) * gs)
            self.sync_region_display_position(r)
        self.update_region_list(select_idx=self.drag_region_index)

    def on_preview_release(self, event):
        self.drag_region_index = None; self.drag_mode = None; self.drag_start_geometry = None

    # ══════════════════════════════════════════════════════════════════════════
    # OUTPUT WINDOW / VIRTUAL CAM
    # ══════════════════════════════════════════════════════════════════════════

    def update_output_window_state(self):
        if self.show_output_window.get():
            if self.output_window is None or not self.output_window.winfo_exists():
                self.output_window = HUDOutputWindow(self.root)
            self.apply_output_window_geometry()
        else:
            if self.output_window and self.output_window.winfo_exists(): self.output_window.destroy()
            self.output_window = None

    def on_output_monitor_changed(self, event=None): self.apply_output_window_geometry()

    def apply_output_window_geometry(self):
        if not self.output_window or not self.output_window.winfo_exists(): return
        idx = self.output_monitor_combo.current()
        if idx < 0 or idx >= len(self.monitors): return
        m = self.monitors[idx]
        if self.fullscreen_output.get():
            self.output_window.overrideredirect(True)
            self.output_window.geometry(f'{m["width"]}x{m["height"]}+{m["left"]}+{m["top"]}')
        else:
            self.output_window.overrideredirect(False)
            self.output_window.geometry(f'{max(100, int(self.canvas_w.get()))}x{max(60, int(self.canvas_h.get()))}+{m["left"]}+{m["top"]}')

    def toggle_virtual_cam(self):
        if self.virtualcam_running: self.stop_virtualcam()
        else: self.start_virtualcam()

    def start_virtualcam(self):
        if pyvirtualcam is None:
            messagebox.showerror("Virtual cam", "pyvirtualcam not available.\npip install djhud[virtualcam]"); return
        self.virtualcam_running = True; self.virtualcam_backend_name.set("pyvirtualcam starting..."); self.update_vcam_button_text()

    def stop_virtualcam(self):
        self.virtualcam_running = False
        if self.pyvirtualcam_cam: self.pyvirtualcam_cam.close(); self.pyvirtualcam_cam = None
        self.last_vcam_shape = None; self.virtualcam_backend_name.set("pyvirtualcam (idle)"); self.update_vcam_button_text()

    def update_vcam_button_text(self):
        self.vcam_button.configure(text="Stop virtual cam" if self.virtualcam_running else "Start virtual cam")

    def toggle_virtualcam_test_pattern(self):
        self.virtualcam_test_pattern.set(not self.virtualcam_test_pattern.get())
        if self.virtualcam_test_pattern.get(): self.virtualcam_backend_name.set("pyvirtualcam test pattern armed")
        elif self.virtualcam_running: self.virtualcam_backend_name.set("pyvirtualcam active")
        else: self.virtualcam_backend_name.set("pyvirtualcam (idle)")

    def _build_test_image(self, w, h):
        img = Image.new("RGB", (w, h), (16, 16, 16)); draw = ImageDraw.Draw(img)
        step = max(40, w // 8); colors = [(220, 40, 40), (220, 140, 40), (220, 220, 40), (40, 180, 40), (40, 160, 220), (120, 80, 220)]
        x, i = 0, 0
        while x < w: draw.rectangle([x, 0, min(w, x + step), h], fill=colors[i % len(colors)]); x += step; i += 1
        draw.rectangle([0, 0, w - 1, h - 1], outline=(255, 255, 255), width=3)
        t = f"DJ HUD TEST  {w}x{h}"; draw.rectangle([20, h//2-40, min(w-20, 20+len(t)*14), h//2+20], fill=(0, 0, 0))
        draw.text((30, h//2-20), t, fill=(255, 255, 255)); return img

    def update_virtualcam(self, img):
        if not self.virtualcam_running or pyvirtualcam is None or np is None: return
        fps = max(1, int(self.target_fps.get()))
        si = self._build_test_image(img.width, img.height) if self.virtualcam_test_pattern.get() else img
        shape = (si.width, si.height, fps)
        try:
            if self.pyvirtualcam_cam is None or self.last_vcam_shape != shape:
                if self.pyvirtualcam_cam: self.pyvirtualcam_cam.close()
                self.pyvirtualcam_cam = pyvirtualcam.Camera(width=si.width, height=si.height, fps=fps)
                self.last_vcam_shape = shape
                bt = "pyvirtualcam active"
                for a in ("device", "backend"):
                    try:
                        v = getattr(self.pyvirtualcam_cam, a)
                        if v: bt = f"pyvirtualcam active: {v}"; break
                    except Exception: pass
                self.virtualcam_backend_name.set(bt)
            frame = np.asarray(si, dtype=np.uint8)
            if frame.ndim != 3 or frame.shape[2] != 3: frame = np.array(si.convert("RGB"), dtype=np.uint8)
            self.pyvirtualcam_cam.send(np.ascontiguousarray(frame))
            self.pyvirtualcam_cam.sleep_until_next_frame()
        except Exception as e:
            self.virtualcam_running = False
            if self.pyvirtualcam_cam:
                try: self.pyvirtualcam_cam.close()
                except Exception: pass
            self.pyvirtualcam_cam = None; self.last_vcam_shape = None
            self.virtualcam_backend_name.set(f"pyvirtualcam error: {e}")
            self.status_label.configure(text=f"Virtual cam error: {e}"); self.update_vcam_button_text()

    # ══════════════════════════════════════════════════════════════════════════
    # VDO.NINJA
    # ══════════════════════════════════════════════════════════════════════════

    def toggle_vdoninja(self):
        if self.vdoninja_server and self.vdoninja_server.is_running:
            self.stop_vdoninja()
        else:
            self.start_vdoninja()

    def start_vdoninja(self):
        from djhud.vdoninja import VDONinjaServer
        if self.vdoninja_server and self.vdoninja_server.is_running:
            return
        stream_id = self.vdoninja_stream_id_var.get().strip()
        fps = max(1, int(self.target_fps.get()))
        try:
            self.vdoninja_server = VDONinjaServer(
                port=7088, stream_id=stream_id, target_fps=fps,
            )
            self.vdoninja_server.start()
            self.vdoninja_status_var.set(
                f"Server running at {self.vdoninja_server.url}\n"
                f"Open the browser page to start streaming."
            )
            self.vdoninja_button.configure(text="Stop VDO.Ninja")
        except Exception as e:
            self.vdoninja_status_var.set(f"Failed to start: {e}")

    def stop_vdoninja(self):
        if self.vdoninja_server:
            self.vdoninja_server.stop()
            self.vdoninja_server = None
        self.vdoninja_status_var.set("Not running")
        self.vdoninja_button.configure(text="Start VDO.Ninja")

    def open_vdoninja_browser(self):
        if self.vdoninja_server and self.vdoninja_server.is_running:
            self.vdoninja_server.open_browser()
        else:
            self.vdoninja_status_var.set("Start the server first.")

    def push_vdoninja_frame(self, canvas_img):
        """Push a frame to the VDO.Ninja server if running."""
        if self.vdoninja_server and self.vdoninja_server.is_running:
            clients = self.vdoninja_server.client_count
            if clients > 0:
                self.vdoninja_server.push_frame(canvas_img)
                self.vdoninja_status_var.set(
                    f"Server running at {self.vdoninja_server.url}\n"
                    f"Browser connected ({clients} client{'s' if clients != 1 else ''}) — pushing frames"
                )
            # Don't update status every frame when no clients — too noisy

    # ══════════════════════════════════════════════════════════════════════════
    # PRESETS
    # ══════════════════════════════════════════════════════════════════════════

    def save_layout(self):
        wt = ""; win = self.get_current_window_info()
        if win: wt = win.title
        data = {
            "version": __version__, "preset_name": self.preset_name_var.get().strip(),
            "dj_app_name": self.dj_app_name_var.get().strip(),
            "virtualcam_test_pattern": bool(self.virtualcam_test_pattern.get()),
            "source_mode": self.source_mode.get(), "selected_monitor_index": self.monitor_combo.current(),
            "selected_window_title": wt, "selected_webcam_index": self.webcam_combo.current(),
            "selected_webcam_device_id": self.get_selected_webcam_device(),
            "webcam_resolution": self.webcam_resolution_combo.get(), "webcam_fps": int(self.webcam_fps_var.get()),
            "webcam_flip_h": bool(self.webcam_flip_h_var.get()), "webcam_flip_v": bool(self.webcam_flip_v_var.get()),
            "webcam_rotate": self.webcam_rotate_var.get(), "webcam_brightness": int(self.webcam_brightness_var.get()),
            "webcam_contrast": int(self.webcam_contrast_var.get()), "canvas_w": int(self.canvas_w.get()),
            "canvas_h": int(self.canvas_h.get()), "target_fps": int(self.target_fps.get()),
            "output_monitor_index": self.output_monitor_combo.current(),
            "fullscreen_output": bool(self.fullscreen_output.get()), "show_output_window": bool(self.show_output_window.get()),
            "snap_to_grid": bool(self.snap_to_grid.get()), "snap_source_to_grid": bool(self.snap_source_to_grid.get()),
            "grid_size": int(self.grid_size.get()), "nudge_step": int(self.nudge_step.get()),
            "regions": [asdict(r) for r in self.regions],
        }
        path = filedialog.asksaveasfilename(title="Save preset", defaultextension=".json",
            filetypes=[("JSON files", "*.json")], initialfile=DEFAULT_CONFIG_FILE)
        if not path: return
        with open(path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2)
        self.status_label.configure(text=f"Preset saved: {path}")

    def load_layout(self):
        path = filedialog.askopenfilename(title="Load preset", filetypes=[("JSON files", "*.json")])
        if not path: return
        with open(path, "r", encoding="utf-8") as f: data = json.load(f)
        self.preset_name_var.set(data.get("preset_name", "")); self.dj_app_name_var.set(data.get("dj_app_name", ""))
        self.virtualcam_test_pattern.set(bool(data.get("virtualcam_test_pattern", False)))
        self.source_mode.set(data.get("source_mode", "monitor")); self.on_source_mode_changed()
        self.monitor_combo.current(min(max(0, data.get("selected_monitor_index", 0)), len(self.monitors) - 1))
        self.refresh_windows(); self.refresh_webcams()
        wt = data.get("selected_window_title", "")
        if wt:
            for i, w in enumerate(self.windows):
                if w.title == wt: self.window_combo.current(i); break
        self.webcam_resolution_var.set(data.get("webcam_resolution", "Default"))
        try: self.webcam_resolution_combo.set(self.webcam_resolution_var.get())
        except Exception: pass
        self.webcam_fps_var.set(int(data.get("webcam_fps", 30)))
        self.webcam_flip_h_var.set(bool(data.get("webcam_flip_h", False))); self.webcam_flip_v_var.set(bool(data.get("webcam_flip_v", False)))
        self.webcam_rotate_var.set(str(data.get("webcam_rotate", "0")))
        try: self.webcam_rotate_combo.set(self.webcam_rotate_var.get())
        except Exception: pass
        self.webcam_brightness_var.set(int(data.get("webcam_brightness", 0))); self.webcam_contrast_var.set(int(data.get("webcam_contrast", 100)))
        self.webcam_freeze_var.set(False); self.webcam_frozen_frame = None
        wd = data.get("selected_webcam_device_id"); wi = data.get("selected_webcam_index", 0)
        if self.webcam_devices:
            ci = None
            if wd is not None:
                for i, (did, _) in enumerate(self.webcam_devices):
                    if did == wd: ci = i; break
            if ci is None: ci = min(max(0, wi), len(self.webcam_devices) - 1)
            self.webcam_combo.current(ci)
            if self.source_mode.get() == "webcam": self.ensure_webcam_open()
        self.canvas_w.set(data.get("canvas_w", DEFAULT_CANVAS_W)); self.canvas_h.set(data.get("canvas_h", DEFAULT_CANVAS_H))
        self.target_fps.set(data.get("target_fps", DEFAULT_FPS))
        self.output_monitor_combo.current(min(max(0, data.get("output_monitor_index", 0)), len(self.monitors) - 1))
        self.fullscreen_output.set(bool(data.get("fullscreen_output", False)))
        self.show_output_window.set(bool(data.get("show_output_window", True)))
        self.snap_to_grid.set(bool(data.get("snap_to_grid", True))); self.snap_source_to_grid.set(bool(data.get("snap_source_to_grid", True)))
        self.grid_size.set(int(data.get("grid_size", 10))); self.nudge_step.set(int(data.get("nudge_step", 1)))
        self.regions = [CaptureRegion(**r) for r in data.get("regions", [])]
        self.update_region_list(select_idx=0 if self.regions else None)
        self.refresh_source_info(); self.update_output_window_state(); self.apply_output_window_geometry()
        self.status_label.configure(text=f"Preset loaded: {path}")

    # ══════════════════════════════════════════════════════════════════════════
    # KEYBOARD
    # ══════════════════════════════════════════════════════════════════════════

    def on_root_delete_key(self, event=None):
        fw = self.root.focus_get()
        if isinstance(fw, (tk.Entry, ttk.Entry, tk.Spinbox)): return
        if self.get_selected_region_index() is not None: self.delete_region()

    def _focus_is_text_input(self):
        fw = self.root.focus_get()
        try: cls = fw.winfo_class() if fw else ""
        except Exception: cls = ""
        return cls in {"Entry", "TEntry", "Spinbox", "TCombobox", "Text"}

    def _focus_allows_preview_nudge(self):
        fw = self.root.focus_get()
        if fw is None or fw == self.preview_canvas: return True
        try: cls = fw.winfo_class()
        except Exception: cls = ""
        return cls not in {"TCombobox", "Listbox"}

    def on_root_arrow_key(self, event):
        if self._focus_is_text_input() or not self._focus_allows_preview_nudge(): return
        idx = self.get_selected_region_index()
        if idx is None: return
        step = max(1, int(self.nudge_step.get()))
        if event.state & 0x0001: step = 10
        dx = dy = 0
        if event.keysym == "Left": dx = -step
        elif event.keysym == "Right": dx = step
        elif event.keysym == "Up": dy = -step
        elif event.keysym == "Down": dy = step
        else: return
        r = self.regions[idx]
        if r.locked: return "break"
        r.raw_out_x = (r.raw_out_x or r.out_x) + dx; r.raw_out_y = (r.raw_out_y or r.out_y) + dy
        self.sync_region_display_position(r); self.update_region_list(select_idx=idx); return "break"

    # ══════════════════════════════════════════════════════════════════════════
    # MAIN LOOP / SHUTDOWN
    # ══════════════════════════════════════════════════════════════════════════

    def render_loop(self):
        fps = max(1, int(self.target_fps.get()))
        try:
            canvas = self.compose_canvas()
            self.update_preview(self.make_annotated_preview(canvas))
            if self.output_window and self.output_window.winfo_exists(): self.output_window.set_image(canvas)
            self.update_virtualcam(canvas)
            self.push_vdoninja_frame(canvas)
            rc = sum(1 for r in self.regions if r.enabled)
            vc = "ON" if self.virtualcam_running else "OFF"
            vn = " | VDO: ON" if (self.vdoninja_server and self.vdoninja_server.is_running and self.vdoninja_server.client_count > 0) else ""
            self.status_label.configure(text=f"Source: {self.source_mode.get()} | Regions: {rc} | Canvas: {canvas.width}x{canvas.height} | FPS: {fps} | VCam: {vc}{vn}")
        except Exception as e:
            self.status_label.configure(text=f"Error: {e}")
        self.root.after(max(1, int(1000 / fps)), self.render_loop)

    def shutdown(self):
        if self.pyvirtualcam_cam: self.pyvirtualcam_cam.close(); self.pyvirtualcam_cam = None
        if self.vdoninja_server: self.stop_vdoninja()
        if self.output_window and self.output_window.winfo_exists(): self.output_window.destroy()
        self.release_webcam(); self.root.destroy()


def run_app():
    """Create and run the DJ HUD application."""
    from djhud.diagnostics import get_import_errors
    errors = get_import_errors()
    if errors:
        print(f"DJ HUD v{__version__} — Missing dependencies:")
        for mod, err in errors.items():
            print(f"  {mod}: {err}")
        print("\nInstall with: pip install djhud[all]")
        print("Starting anyway...\n")

    root = tk.Tk()
    app = HUDApp(root)
    root.protocol("WM_DELETE_WINDOW", app.shutdown)
    root.mainloop()
