Transparency

Source code

The complete source of the current TrueAxis Competitive build, published for transparency. This is the same code the release is built from. Use the Copy button to copy it all at once.

Back to site
Support open source

Help fund ongoing development

TrueAxis stays free and its source remains public. Support helps with testing, hardware compatibility work and future updates.

Support on Ko-fi

#!/usr/bin/env python3
import sys
import math
import time
import threading
import pygame
import json
import os
import hashlib
import re
import shutil
import uuid
import urllib.error
import urllib.request
import ctypes
try:
    import vgamepad as vg
    VIGEM_AVAILABLE = True
except Exception:
    vg = None
    VIGEM_AVAILABLE = False
import tempfile
import subprocess
from pathlib import Path
from urllib.parse import urljoin, urlparse
from PySide6.QtCore import QObject, Signal, Slot, Property, QTimer, Qt, QEvent, QThread, QSettings, QRectF, QPointF
from PySide6.QtGui import QGuiApplication, QIcon, QPixmap, QPainter, QColor, QFont, QAction, QPen, QBrush, QLinearGradient
from PySide6.QtQml import QQmlApplicationEngine
from PySide6.QtWidgets import QApplication, QSystemTrayIcon, QMenu, QStyle

# HID support for hardware wheel control
try:
    import hid as hidapi
    HID_AVAILABLE = True
except ImportError:
    hidapi = None
    HID_AVAILABLE = False

# Logitech wheel USB product IDs used for hardware wheel control. These are
# intentionally split by protocol below; G29-style and G920/G923-Xbox wheels do
# not use the same steering range command.
LOGITECH_VID = 0x046d
LOGITECH_WHEEL_PIDS = {
    0xc202: "WingMan Formula (Yellow)",
    0xc20e: "WingMan Formula GP",
    0xc293: "WingMan Formula Force GP",
    0xc294: "Driving Force / Formula EX",
    0xc295: "Momo Force",
    0xc298: "Driving Force Pro",
    0xc299: "G25",
    0xc29a: "Driving Force GT",
    0xc29b: "G27",
    0xc29c: "Speed Force Wireless",
    0xca03: "MOMO Racing USB",
    0xca04: "Racing Wheel USB",
    0xc24f: "G29",
    0xc260: "G29 (PS4 native)",
    0xc262: "G920",
    0xc266: "G923 (PS4/PC)",
    0xc267: "G923 (PS4/PC pre-switch)",
    0xc26d: "G923 (Xbox/PC)",
    0xc26e: "G923 (Xbox/PC alt)",
    0xc268: "Logitech Pro Racing Wheel (PlayStation/PC)",
    0xc272: "Logitech Pro Racing Wheel (Xbox/PC)",
}

LOGITECH_LG4FF_RANGE_PIDS = {0xc299, 0xc29a, 0xc29b, 0xc24f, 0xc260, 0xc266}
LOGITECH_DFP_RANGE_PIDS = {0xc298}
LOGITECH_G923_PS_MODE_PIDS = {0xc267}
LOGITECH_HIDPP_RANGE_PIDS = {0xc262, 0xc26d, 0xc26e}
LOGITECH_CLASSIC_AUTOCENTER_PIDS = LOGITECH_LG4FF_RANGE_PIDS | LOGITECH_DFP_RANGE_PIDS
LOGITECH_HIDPP_FORCE_FEEDBACK_PAGE = 0x8123
LOGITECH_HIDPP_ROOT_FEATURE_INDEX = 0x00
LOGITECH_HIDPP_ROOT_GET_FEATURE = 0x00
LOGITECH_HIDPP_GET_APERTURE = 0x51
LOGITECH_HIDPP_SET_APERTURE = 0x61
LOGITECH_HIDPP_SW_ID = 0x01
LOGITECH_LAST_RANGE_STATUS = ""
LOGITECH_LAST_RANGE_APPLIED = None
MOZA_VID = 0x346e
MOZA_LAST_RANGE_STATUS = ""
MOZA_LAST_RANGE_APPLIED = None
MOZA_SDK_ERROR_NAMES = {
    0: "NORMAL",
    1: "NOINSTALLSDK",
    2: "NODEVICES",
    3: "OUTOFRANGE",
    4: "PARAMETERERR",
    5: "COLLECTIONCYCLEDATALOSS",
    6: "CREATEFFECTERR",
    7: "ENCODINGFAILED",
    8: "FFBERR",
    9: "FIRMWARETOOOLD",
    10: "PITHOUSENOTREADY",
}
_MOZA_SDK_CACHE = {
    "loaded": False,
    "dll": None,
    "dir": "",
    "error": "",
    "checked_at": 0.0,
}
KNOWN_WHEEL_VENDOR_IDS = {
    0x046d: "logitech",
    0x044f: "thrustmaster",
    0x0eb7: "fanatec",
    MOZA_VID: "moza",
}
KNOWN_WHEEL_PRODUCTS = {
    (0x046d, 0xc202): "Logitech WingMan Formula Yellow",
    (0x046d, 0xc20e): "Logitech WingMan Formula GP",
    (0x046d, 0xc293): "Logitech WingMan Formula Force GP",
    (0x046d, 0xc294): "Logitech Driving Force / Formula EX",
    (0x046d, 0xc295): "Logitech Momo Force",
    (0x046d, 0xc298): "Logitech Driving Force Pro",
    (0x046d, 0xc299): "Logitech G25",
    (0x046d, 0xc29a): "Logitech Driving Force GT",
    (0x046d, 0xc29b): "Logitech G27",
    (0x046d, 0xc29c): "Logitech Speed Force Wireless",
    (0x046d, 0xca03): "Logitech MOMO Racing USB",
    (0x046d, 0xca04): "Logitech Racing Wheel USB",
    (0x046d, 0xc24f): "Logitech G29",
    (0x046d, 0xc260): "Logitech G29 PS4/initial",
    (0x046d, 0xc261): "Logitech G920 initial/Xbox",
    (0x046d, 0xc262): "Logitech G920",
    (0x046d, 0xc266): "Logitech G923 PS4/PC",
    (0x046d, 0xc267): "Logitech G923 PS pre-switch",
    (0x046d, 0xc26d): "Logitech G923 Xbox initial",
    (0x046d, 0xc26e): "Logitech G923 Xbox/PC",
    (0x046d, 0xc268): "Logitech Pro Racing Wheel PlayStation/PC",
    (0x046d, 0xc269): "Logitech Pro Racing Wheel PS mode",
    (0x046d, 0xc272): "Logitech Pro Racing Wheel Xbox/PC",
    (0x044f, 0xb65e): "Thrustmaster T500RS",
    (0x044f, 0xb666): "Thrustmaster TX bootloader / initial",
    (0x044f, 0xb66d): "Thrustmaster T300RS GT",
    (0x044f, 0xb66e): "Thrustmaster T300RS",
    (0x044f, 0xb66f): "Thrustmaster Ferrari F1 Advanced T300",
    (0x044f, 0xb669): "Thrustmaster TX 458 Italia",
    (0x044f, 0xb677): "Thrustmaster T150",
    (0x044f, 0xb67f): "Thrustmaster TMX",
    (0x044f, 0xb684): "Thrustmaster T-GT",
    (0x044f, 0xb689): "Thrustmaster TS-PC",
    (0x044f, 0xb692): "Thrustmaster TS-XW",
    (0x044f, 0xb696): "Thrustmaster T248",
    (0x0eb7, 0x0001): "Fanatec ClubSport Wheel Base V2",
    (0x0eb7, 0x0004): "Fanatec ClubSport Wheel Base V2.5",
    (0x0eb7, 0x0005): "Fanatec CSL Elite Wheel Base PS4",
    (0x0eb7, 0x0006): "Fanatec Podium Wheel Base DD1",
    (0x0eb7, 0x0007): "Fanatec Podium Wheel Base DD2",
    (0x0eb7, 0x0011): "Fanatec CSR Elite / Forza Motorsport Wheel Base",
    (0x0eb7, 0x0020): "Fanatec CSL DD / DD Pro / ClubSport DD",
    (0x0eb7, 0x0197): "Fanatec Porsche 911 Turbo S / GT3 RS V2",
    (0x0eb7, 0x0e03): "Fanatec CSL Elite Wheel Base",
    (MOZA_VID, 0x0000): "MOZA R16 / R21",
    (MOZA_VID, 0x0002): "MOZA R9",
    (MOZA_VID, 0x0004): "MOZA R5",
    (MOZA_VID, 0x0005): "MOZA R3",
    (MOZA_VID, 0x0006): "MOZA R12",
    (0x11ff, 0x3245): "PXN V10",
    (0x11ff, 0x1212): "PXN V12",
    (0x11ff, 0x1112): "PXN V12 Lite",
    (0x11ff, 0x1211): "PXN V12 Lite SE",
    (0x36e6, 0x400f): "PXN VD6",
    (0x0f0d, 0x00a4): "HORI Racing Wheel Apex",
    (0x0f0d, 0x0156): "HORI Racing Wheel Apex",
    (0x0f0d, 0x017a): "HORI Truck Control System",
    (0x0483, 0x0522): "Simagic M10 / Alpha",
    (0x3670, 0x0500): "Simagic EVO Sport",
    (0x3670, 0x0501): "Simagic EVO",
    (0x3670, 0x0502): "Simagic EVO Pro",
    (0x16d0, 0x0d5a): "Simucube 1",
    (0x16d0, 0x0d61): "Simucube 2 Sport",
    (0x16d0, 0x0d60): "Simucube 2 Pro",
    (0x16d0, 0x0d5f): "Simucube 2 Ultimate",
    (0x2433, 0xf300): "Asetek Invicta",
    (0x2433, 0xf301): "Asetek Forte",
    (0x2433, 0xf303): "Asetek La Prima",
    (0x2433, 0xf306): "Asetek Tony Kanaan",
    (0x0483, 0xa355): "VRS DirectForce Pro",
    (0x1209, 0xffb0): "OpenFFBoard",
    (0x1fc9, 0x804c): "SimXperience AccuForce Pro",
    (0x3416, 0x0301): "Cammus C5",
    (0x3416, 0x0302): "Cammus C12",
}
KNOWN_WHEEL_PRODUCT_VENDORS = {
    0x11ff: "pxn",
    0x36e6: "pxn",
    0x0483: "directdrive",
    0x3670: "directdrive",
    0x16d0: "directdrive",
    0x2433: "directdrive",
    0x1209: "directdrive",
    0x1fc9: "directdrive",
    0x3416: "directdrive",
}
WHEEL_HID_NAME_TOKENS = (
    "wheel", "racing", "steering", "g29", "g920", "g923", "g27", "g25",
    "driving force", "thrustmaster", "t300", "t150", "t248", "t500",
    "t818", "t-gt", "tmx", "moza", "fanatec", "csl", "clubsport",
    "podium", "simucube", "simagic", "asetek", "vrs", "cammus", "pxn",
    "hori racing", "hori wheel", "racing wheel apex", "momo"
)
GAMEPAD_HID_NAME_TOKENS = (
    "xbox", "controller", "gamepad", "dualsense", "dualshock",
    "8bitdo", "stadia", "wireless controller", "game sir", "gamesir",
    "horipad"
)
HID_HINT_STOP_TOKENS = {
    "usb", "hid", "device", "controller", "gamepad", "wireless", "advanced",
    "optical", "mouse", "keyboard", "audio", "interface"
}

def _hid_value(value):
    if value is None:
        return None
    if isinstance(value, bytes):
        try:
            return value.decode("utf-8", errors="replace")
        except Exception:
            return repr(value)
    return value

def _safe_hex(value):
    try:
        return f"0x{int(value):04x}"
    except Exception:
        return ""

def _hid_path_hash(path_text):
    if not path_text:
        return ""
    try:
        return hashlib.sha256(str(path_text).encode("utf-8", errors="ignore")).hexdigest()[:16]
    except Exception:
        return ""

def _hid_report_summary(device):
    """Return useful HID metadata without exposing raw device paths or serials."""
    usage_page = device.get("usage_page")
    usage = device.get("usage")
    vendor_id = device.get("vendor_id")
    product_id = device.get("product_id")
    vendor = _hid_vendor_label(device)
    support_kind = device.get("support_kind") or _range_support_kind_for_vendor(vendor, product_id)
    return {
        "name": str(device.get("name") or device.get("product_string") or ""),
        "manufacturer": str(device.get("manufacturer_string") or ""),
        "productString": str(device.get("product_string") or ""),
        "vendor": _safe_hex(vendor_id),
        "product": _safe_hex(product_id),
        "brand": vendor,
        "knownModel": KNOWN_WHEEL_PRODUCTS.get((vendor_id, product_id), ""),
        "interface": device.get("interface_number"),
        "usagePage": _safe_hex(usage_page),
        "usageId": _safe_hex(usage),
        "usage": f"{_safe_hex(usage_page)}:{_safe_hex(usage)}" if usage_page is not None or usage is not None else "",
        "release": _safe_hex(device.get("release_number")),
        "protocol": str(device.get("protocol") or "unknown"),
        "supportKind": support_kind,
        "pathHash": _hid_path_hash(device.get("path_text")),
        "matchReason": str(device.get("match_reason") or ""),
    }

def _hid_text(device):
    return " ".join(str(_hid_value(device.get(key)) or "") for key in (
        "name", "manufacturer_string", "product_string", "serial_number"
    )).lower()

def _hid_vendor_label(device):
    vendor_id = device.get("vendor_id")
    label = KNOWN_WHEEL_VENDOR_IDS.get(vendor_id)
    if label:
        return label
    label = KNOWN_WHEEL_PRODUCT_VENDORS.get(vendor_id)
    if label:
        return label
    text_value = _hid_text(device)
    if "thrustmaster" in text_value or "guillemot" in text_value:
        return "thrustmaster"
    if "fanatec" in text_value or "endor" in text_value:
        return "fanatec"
    if "moza" in text_value:
        return "moza"
    if "pxn" in text_value:
        return "pxn"
    if "hori" in text_value:
        return "hori"
    if any(token in text_value for token in ("simucube", "simagic", "asetek", "vrs", "cammus")):
        return "directdrive"
    return ""

def _range_support_kind_for_vendor(vendor, product_id=None):
    if vendor == "logitech":
        if product_id in LOGITECH_LG4FF_RANGE_PIDS | LOGITECH_DFP_RANGE_PIDS | LOGITECH_G923_PS_MODE_PIDS | LOGITECH_HIDPP_RANGE_PIDS:
            return "real_command_public"
        return "diagnostic_only"
    if vendor == "moza":
        return "sdk_available"
    if vendor in ("thrustmaster", "fanatec", "pxn", "hori", "directdrive"):
        return "vendor_app_only"
    return "diagnostic_only"

def _diagnostic_protocol_for_hid(device):
    vendor = _hid_vendor_label(device)
    if vendor == "logitech":
        product_id = device.get("product_id")
        if product_id in LOGITECH_LG4FF_RANGE_PIDS:
            return "lg4ff"
        if product_id in LOGITECH_DFP_RANGE_PIDS:
            return "dfp"
        if product_id in LOGITECH_G923_PS_MODE_PIDS:
            return "g923_ps_mode"
        if product_id in LOGITECH_HIDPP_RANGE_PIDS:
            return "hidpp_g920"
        return "unsupported"
    if vendor in ("thrustmaster", "fanatec", "moza", "pxn", "hori", "directdrive"):
        return f"{vendor}_diagnostic"
    return "wheel_diagnostic"

def _is_wheel_hid_candidate(device, target_hint=""):
    vendor_id = device.get("vendor_id")
    product_id = device.get("product_id")
    text_value = _hid_text(device)
    hint = (target_hint or "").lower()
    if (vendor_id, product_id) in KNOWN_WHEEL_PRODUCTS:
        return True, "known_vid_pid"
    if vendor_id in KNOWN_WHEEL_VENDOR_IDS:
        return True, KNOWN_WHEEL_VENDOR_IDS[vendor_id]
    if vendor_id in KNOWN_WHEEL_PRODUCT_VENDORS:
        return True, KNOWN_WHEEL_PRODUCT_VENDORS[vendor_id]
    if any(token in text_value for token in WHEEL_HID_NAME_TOKENS):
        return True, "name_match"
    if any(token in text_value for token in GAMEPAD_HID_NAME_TOKENS):
        return False, "gamepad"
    if hint and any(token in hint for token in WHEEL_HID_NAME_TOKENS):
        hint_tokens = [
            token for token in re.split(r"[^a-z0-9]+", hint)
            if len(token) >= 3 and token not in HID_HINT_STOP_TOKENS
        ]
        if any(token and token in text_value for token in hint_tokens):
            return True, "selected_device_match"
    return False, ""

def _wheel_hid_candidates(target_hint=""):
    """Enumerate sanitized wheel-like HID endpoints for diagnostics across vendors."""
    if not HID_AVAILABLE:
        return []
    try:
        devices = hidapi.enumerate()
    except Exception as e:
        print(f"[WHEEL HID] Error enumerating all HID devices: {e}")
        return []

    candidates = []
    for d in devices:
        matched, reason = _is_wheel_hid_candidate(d, target_hint)
        if not matched:
            continue
        item = dict(d)
        product_id = d.get("product_id")
        item["product_id"] = product_id
        item["vendor_id"] = d.get("vendor_id")
        vendor_id = d.get("vendor_id")
        item["name"] = (
            KNOWN_WHEEL_PRODUCTS.get((vendor_id, product_id))
            or LOGITECH_WHEEL_PIDS.get(product_id)
            or _hid_value(d.get("product_string"))
            or _hid_vendor_label(d)
            or "Unknown HID device"
        )
        item["protocol"] = _diagnostic_protocol_for_hid(item)
        item["support_kind"] = _range_support_kind_for_vendor(_hid_vendor_label(item), product_id)
        item["path_text"] = _hid_value(d.get("path"))
        item["match_reason"] = reason
        candidates.append(item)

    candidates.sort(key=lambda d: (
        _logitech_endpoint_priority(d),
        0 if d.get("vendor_id") in KNOWN_WHEEL_VENDOR_IDS else 1,
        str(d.get("manufacturer_string") or ""),
        str(d.get("product_string") or ""),
        d.get("interface_number", 99) if d.get("interface_number") is not None else 99,
        d.get("usage_page") or 0,
        d.get("usage") or 0,
    ))
    return candidates[:32]

def _moza_error_name(code):
    try:
        return MOZA_SDK_ERROR_NAMES.get(int(code), f"ERROR_{int(code)}")
    except Exception:
        return "UNKNOWN"

def _path_candidates_for_moza_sdk():
    candidates = []

    def add(path_value):
        if not path_value:
            return
        try:
            path = Path(path_value).expanduser()
            if path not in candidates:
                candidates.append(path)
        except Exception:
            pass

    add(os.environ.get("TRUEAXIS_MOZA_SDK_DIR"))
    try:
        add(getattr(sys, "_MEIPASS", ""))
    except Exception:
        pass
    try:
        if getattr(sys, "frozen", False):
            add(Path(sys.executable).resolve().parent)
        else:
            here = Path(__file__).resolve().parent
            add(here)
            add(here / "vendor_sdk" / "moza" / "x64")
    except Exception:
        pass
    try:
        add(Path.cwd() / "vendor_sdk" / "moza" / "x64")
    except Exception:
        pass

    for env_name in ("ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA", "PROGRAMDATA"):
        root = os.environ.get(env_name)
        if not root:
            continue
        for relative in (
            "MOZA Pit House",
            "MOZA Racing",
            "MOZA Racing\\MOZA Pit House",
            "MOZA",
            "MOZA\\MOZA Pit House",
        ):
            add(Path(root) / relative)

    return candidates

def _find_moza_sdk_dir():
    for directory in _path_candidates_for_moza_sdk():
        try:
            if (directory / "MOZA_API_C.dll").exists() and (directory / "MOZA_SDK.dll").exists():
                return directory
        except Exception:
            continue
    return None

def _load_moza_sdk(force_retry=False):
    now = time.time()
    if _MOZA_SDK_CACHE.get("loaded") and _MOZA_SDK_CACHE.get("dll") is not None:
        return _MOZA_SDK_CACHE["dll"]
    if (
        not force_retry
        and _MOZA_SDK_CACHE.get("error")
        and now - float(_MOZA_SDK_CACHE.get("checked_at") or 0.0) < 15.0
    ):
        return None

    _MOZA_SDK_CACHE["checked_at"] = now
    sdk_dir = _find_moza_sdk_dir()
    if not sdk_dir:
        _MOZA_SDK_CACHE["error"] = "MOZA SDK DLLs not found"
        return None

    try:
        if hasattr(os, "add_dll_directory"):
            os.add_dll_directory(str(sdk_dir))
        os.environ["PATH"] = str(sdk_dir) + os.pathsep + os.environ.get("PATH", "")
        dll = ctypes.WinDLL(str(sdk_dir / "MOZA_API_C.dll"))
        dll.installMozaSDK_C.argtypes = []
        dll.installMozaSDK_C.restype = None
        dll.removeMozaSDK_C.argtypes = []
        dll.removeMozaSDK_C.restype = None
        dll.setMotorLimitAngle_C.argtypes = [ctypes.c_int, ctypes.c_int]
        dll.setMotorLimitAngle_C.restype = ctypes.c_int
        dll.getMotorLimitAngle_C.argtypes = [
            ctypes.POINTER(ctypes.c_int),
            ctypes.POINTER(ctypes.c_int),
            ctypes.POINTER(ctypes.c_int),
        ]
        dll.getMotorLimitAngle_C.restype = None
        try:
            dll.installMozaSDK_C()
        except Exception as e:
            print(f"[MOZA SDK] installMozaSDK_C warning: {e}")
        _MOZA_SDK_CACHE.update({
            "loaded": True,
            "dll": dll,
            "dir": str(sdk_dir),
            "error": "",
            "checked_at": now,
        })
        print(f"[MOZA SDK] Loaded from {sdk_dir}")
        return dll
    except Exception as e:
        _MOZA_SDK_CACHE.update({
            "loaded": False,
            "dll": None,
            "dir": str(sdk_dir),
            "error": str(e),
            "checked_at": now,
        })
        print(f"[MOZA SDK] Load failed: {e}")
        return None

def _moza_sdk_ready():
    return _load_moza_sdk() is not None

def _moza_sdk_status():
    if _MOZA_SDK_CACHE.get("loaded"):
        return f"MOZA SDK ready ({_MOZA_SDK_CACHE.get('dir')})"
    if _MOZA_SDK_CACHE.get("error"):
        return _MOZA_SDK_CACHE["error"]
    return "MOZA SDK not checked"

def _get_moza_wheel_range():
    dll = _load_moza_sdk()
    if not dll:
        return None, None, _moza_sdk_status()
    first = ctypes.c_int(0)
    second = ctypes.c_int(0)
    err = ctypes.c_int(0)
    try:
        dll.getMotorLimitAngle_C(ctypes.byref(first), ctypes.byref(second), ctypes.byref(err))
        if err.value != 0:
            return None, None, _moza_error_name(err.value)
        return int(first.value), int(second.value), "NORMAL"
    except Exception as e:
        return None, None, str(e)

def set_moza_wheel_range(range_degrees):
    """Set physical MOZA wheelbase range via the official MOZA C SDK."""
    global MOZA_LAST_RANGE_STATUS, MOZA_LAST_RANGE_APPLIED
    MOZA_LAST_RANGE_APPLIED = None
    dll = _load_moza_sdk(force_retry=True)
    if not dll:
        MOZA_LAST_RANGE_STATUS = f"MOZA SDK unavailable: {_moza_sdk_status()}"
        return False

    target = max(90, min(2000, int(range_degrees)))
    try:
        err = int(dll.setMotorLimitAngle_C(target, target))
        if err != 0:
            MOZA_LAST_RANGE_STATUS = f"MOZA SDK set range failed: {_moza_error_name(err)}"
            return False

        time.sleep(0.05)
        limit_angle, game_angle, read_status = _get_moza_wheel_range()
        MOZA_LAST_RANGE_APPLIED = target
        if limit_angle:
            MOZA_LAST_RANGE_APPLIED = int(limit_angle)
            if abs(int(limit_angle) - target) > 5:
                MOZA_LAST_RANGE_STATUS = (
                    f"MOZA SDK range mismatch: requested {target} degrees, "
                    f"readback {limit_angle}/{game_angle}"
                )
                print(f"[MOZA SDK] {MOZA_LAST_RANGE_STATUS}")
                return False
            MOZA_LAST_RANGE_STATUS = (
                f"MOZA hardware range command accepted: {target} degrees "
                f"(readback {limit_angle}/{game_angle})"
            )
        else:
            MOZA_LAST_RANGE_STATUS = (
                f"MOZA hardware range command accepted: {target} degrees "
                f"(readback unavailable: {read_status})"
            )
        print(f"[MOZA SDK] {MOZA_LAST_RANGE_STATUS}")
        return True
    except Exception as e:
        MOZA_LAST_RANGE_STATUS = f"MOZA SDK range command failed: {e}"
        print(f"[MOZA SDK] {MOZA_LAST_RANGE_STATUS}")
        return False

def _logitech_protocol_for_pid(product_id):
    if product_id in LOGITECH_LG4FF_RANGE_PIDS:
        return "lg4ff"
    if product_id in LOGITECH_DFP_RANGE_PIDS:
        return "dfp"
    if product_id in LOGITECH_G923_PS_MODE_PIDS:
        return "g923_ps_mode"
    if product_id in LOGITECH_HIDPP_RANGE_PIDS:
        return "hidpp_g920"
    return "unsupported"

def _logitech_endpoint_priority(device):
    """Prefer vendor-defined HID control endpoints over gameplay endpoints."""
    protocol = device.get('protocol')
    usage_page = device.get('usage_page') or 0
    interface_number = device.get('interface_number')
    if protocol in ("lg4ff", "dfp"):
        if usage_page >= 0xff00:
            return 0
        if interface_number not in (None, -1, 0):
            return 1
        return 2
    if protocol == "g923_ps_mode":
        return 3
    if protocol == "hidpp_g920":
        return 4
    return 9

def _logitech_hid_devices():
    """Return Logitech wheel HID descriptors sorted by likely control endpoint."""
    if not HID_AVAILABLE:
        return []
    try:
        devices = [
            d for d in hidapi.enumerate(LOGITECH_VID)
            if d.get('product_id') in LOGITECH_WHEEL_PIDS
        ]
    except Exception as e:
        print(f"[WHEEL HID] Error enumerating devices: {e}")
        return []

    normalized = []
    for d in devices:
        product_id = d.get('product_id')
        item = dict(d)
        item['product_id'] = product_id
        item['vendor_id'] = d.get('vendor_id')
        item['name'] = LOGITECH_WHEEL_PIDS.get(product_id, d.get('product_string') or 'Unknown')
        item['protocol'] = _logitech_protocol_for_pid(product_id)
        item['path_text'] = _hid_value(d.get('path'))
        normalized.append(item)

    protocol_rank = {
        "lg4ff": 0,
        "dfp": 1,
        "g923_ps_mode": 2,
        "hidpp_g920": 3,
        "unsupported": 9,
    }
    normalized.sort(key=lambda d: (
        protocol_rank.get(d.get('protocol'), 9),
        _logitech_endpoint_priority(d),
        d.get('interface_number', 99) if d.get('interface_number') is not None else 99,
        d.get('usage_page') or 0,
        d.get('usage') or 0,
    ))
    return normalized

def _open_hid_path(path):
    h = hidapi.device()
    h.open_path(path)
    return h

def _write_hid_report(path, report):
    h = None
    try:
        h = _open_hid_path(path)
        written = h.write(report)
        ok = isinstance(written, int) and written >= len(report)
        return {"ok": ok, "written": written, "error": ""}
    except Exception as e:
        return {"ok": False, "written": 0, "error": str(e)}
    finally:
        if h:
            try:
                h.close()
            except Exception:
                pass

def _logitech_lg4ff_range_reports(product_id, range_degrees):
    range_degrees = max(10, min(1080, int(range_degrees)))
    if product_id in LOGITECH_LG4FF_RANGE_PIDS:
        return [[0x00, 0xf8, 0x81, range_degrees & 0xff, (range_degrees >> 8) & 0xff, 0x00, 0x00, 0x00]]
    if product_id in LOGITECH_DFP_RANGE_PIDS:
        range_degrees = max(40, min(900, range_degrees))
        full_range = 900 if range_degrees > 200 else 200
        reports = [[0x00, 0xf8, 0x03 if range_degrees > 200 else 0x02, 0x00, 0x00, 0x00, 0x00, 0x00]]
        fine = [0x00, 0x81, 0x0b, 0x00, 0x00, 0x00, 0x00, 0x00]
        if range_degrees not in (200, 900):
            start_left = (((full_range - range_degrees + 1) * 2047) // full_range)
            start_right = 0xfff - start_left
            fine[3] = start_left >> 4
            fine[4] = start_right >> 4
            fine[5] = 0xff
            fine[6] = ((start_right & 0xe) << 4) | (start_left & 0xe)
            fine[7] = 0xff
        reports.append(fine)
        return reports
    return []

def _logitech_pid_matches_hint(product_id, hint_text):
    hint = (hint_text or "").lower()
    if not hint:
        return True
    if product_id in {0xc24f, 0xc260}:
        return any(token in hint for token in ("g29", "logitech g29"))
    if product_id == 0xc262:
        return any(token in hint for token in ("g920", "logitech g920"))
    if product_id in {0xc266, 0xc267, 0xc26d, 0xc26e}:
        return "g923" in hint or ("g920" in hint and "g923" in hint)
    if product_id == 0xc29b:
        return "g27" in hint
    if product_id == 0xc299:
        return "g25" in hint
    if product_id == 0xc29c:
        return "driving force gt" in hint or "dfgt" in hint
    if product_id == 0xc298:
        return "driving force pro" in hint
    return True

def _logitech_hint_has_specific_model(hint_text):
    hint = (hint_text or "").lower()
    return any(token in hint for token in (
        "g29", "g920", "g923", "g27", "g25", "driving force gt",
        "driving force pro", "dfgt"
    ))

def _filtered_logitech_hid_devices(target_hint=""):
    devices = _logitech_hid_devices()
    matched = [d for d in devices if _logitech_pid_matches_hint(d.get('product_id'), target_hint)]
    if target_hint and _logitech_hint_has_specific_model(target_hint):
        return matched
    return matched or devices

def _set_logitech_lg4ff_or_dfp_range(range_degrees, target_hint=""):
    attempts = []
    ok_any = False
    for d in _filtered_logitech_hid_devices(target_hint):
        if d.get('protocol') not in ("lg4ff", "dfp"):
            continue
        reports = _logitech_lg4ff_range_reports(d.get('product_id'), range_degrees)
        if not reports:
            continue
        device_ok = True
        for report in reports:
            result = _write_hid_report(d['path'], report)
            result.update({
                "protocol": d.get('protocol'),
                "name": d.get('name'),
                "pid": d.get('product_id'),
                "interface": d.get('interface_number'),
                "path": d.get('path_text'),
                "report": " ".join(f"{b:02x}" for b in report),
            })
            attempts.append(result)
            if not result["ok"]:
                device_ok = False
                break
        if device_ok:
            ok_any = True
            print(f"[WHEEL HID] {d.get('name')} range report accepted via {d.get('protocol')} on interface {d.get('interface_number')}")
            return True, attempts
    return ok_any, attempts

def _switch_g923_ps_mode_to_native(target_hint=""):
    """Switch G923 PS-mode (PID c267) into native c266 mode when present."""
    switched = False
    for d in _filtered_logitech_hid_devices(target_hint):
        if d.get('protocol') != "g923_ps_mode":
            continue
        report = [0x30, 0xf8, 0x09, 0x07, 0x01, 0x01, 0x00, 0x00]
        result = _write_hid_report(d['path'], report)
        print(f"[WHEEL HID] G923 PS-mode switch on interface {d.get('interface_number')}: {result}")
        switched = switched or result["ok"]
    if switched:
        time.sleep(1.0)
    return switched

def _hidpp_make_report(feature_index, command, params=None):
    params = list(params or [])
    report = [0x11, 0xff, feature_index & 0xff, (command | LOGITECH_HIDPP_SW_ID) & 0xff]
    report.extend(params[:16])
    report.extend([0x00] * (20 - len(report)))
    return report

def _hidpp_parse_response(response, feature_index, command):
    if not response or len(response) < 4:
        return None
    data = list(response)
    if data[0] not in (0x10, 0x11, 0x12):
        return None
    sent_func = (command | LOGITECH_HIDPP_SW_ID) & 0xff
    if data[2] == 0xff and len(data) >= 6:
        if data[3] == feature_index and data[4] == sent_func:
            return {"error": data[5], "params": data[4:], "raw": data}
    if data[2] == feature_index and data[3] == sent_func:
        return {"error": None, "params": data[4:], "raw": data}
    return None

def _hidpp_exchange_open(handle, feature_index, command, params=None, timeout_ms=350):
    report = _hidpp_make_report(feature_index, command, params)
    written = handle.write(report)
    if not isinstance(written, int) or written < len(report):
        return {"error": f"short_write:{written}", "params": [], "raw": [], "written": written, "report": report}
    deadline = time.time() + max(0.05, timeout_ms / 1000.0)
    last_response = []
    while time.time() < deadline:
        chunk = handle.read(20, max(20, min(120, timeout_ms)))
        if not chunk:
            continue
        last_response = list(chunk)
        parsed = _hidpp_parse_response(last_response, feature_index, command)
        if parsed is not None:
            parsed["written"] = written
            parsed["report"] = report
            return parsed
    return {"error": "timeout", "params": [], "raw": last_response, "written": written, "report": report}

def _set_logitech_hidpp_range(range_degrees, target_hint=""):
    """Set G920/G923-Xbox aperture through Logitech HID++ 0x8123 when available."""
    range_degrees = max(180, min(900, int(range_degrees)))
    attempts = []
    for d in _filtered_logitech_hid_devices(target_hint):
        if d.get('protocol') != "hidpp_g920":
            continue
        for transport in ("write", "feature"):
            h = None
            try:
                h = _open_hid_path(d['path'])
                h.set_nonblocking(0)
                if transport == "feature":
                    # Some Windows stacks expose HID++ as feature reports only.
                    def send_feature(feature_index, command, params=None):
                        report = _hidpp_make_report(feature_index, command, params)
                        written = h.send_feature_report(report)
                        return {"error": None if written else "send_feature_report failed", "params": [], "raw": [], "written": written, "report": report}
                    exchange = send_feature
                else:
                    exchange = lambda feature_index, command, params=None: _hidpp_exchange_open(h, feature_index, command, params)

                root = exchange(LOGITECH_HIDPP_ROOT_FEATURE_INDEX, LOGITECH_HIDPP_ROOT_GET_FEATURE, [
                    LOGITECH_HIDPP_FORCE_FEEDBACK_PAGE >> 8,
                    LOGITECH_HIDPP_FORCE_FEEDBACK_PAGE & 0xff,
                ])
                feature_index = None
                if root.get("error") is None and root.get("params"):
                    feature_index = root["params"][0]
                elif transport == "feature":
                    # send_feature_report cannot read responses. Leave this path as a logged probe only.
                    attempts.append({
                        "ok": False,
                        "transport": transport,
                        "stage": "root_feature",
                        "protocol": d.get('protocol'),
                        "name": d.get('name'),
                        "pid": d.get('product_id'),
                        "interface": d.get('interface_number'),
                        "path": d.get('path_text'),
                        "error": root.get("error"),
                        "written": root.get("written"),
                    })
                    continue

                if not feature_index:
                    attempts.append({
                        "ok": False,
                        "transport": transport,
                        "stage": "root_feature",
                        "protocol": d.get('protocol'),
                        "name": d.get('name'),
                        "pid": d.get('product_id'),
                        "interface": d.get('interface_number'),
                        "path": d.get('path_text'),
                        "error": root.get("error"),
                        "raw": root.get("raw"),
                    })
                    continue

                params = [(range_degrees >> 8) & 0xff, range_degrees & 0xff]
                set_result = exchange(feature_index, LOGITECH_HIDPP_SET_APERTURE, params)
                time.sleep(0.05)
                get_result = exchange(feature_index, LOGITECH_HIDPP_GET_APERTURE, [])
                readback = None
                if get_result.get("error") is None and len(get_result.get("params", [])) >= 2:
                    readback = (get_result["params"][0] << 8) | get_result["params"][1]
                ok = readback == range_degrees
                attempts.append({
                    "ok": ok,
                    "transport": transport,
                    "stage": "set_get_aperture",
                    "protocol": d.get('protocol'),
                    "name": d.get('name'),
                    "pid": d.get('product_id'),
                    "interface": d.get('interface_number'),
                    "path": d.get('path_text'),
                    "feature_index": feature_index,
                    "target": range_degrees,
                    "readback": readback,
                    "set_error": set_result.get("error"),
                    "get_error": get_result.get("error"),
                    "set_raw": set_result.get("raw"),
                    "get_raw": get_result.get("raw"),
                })
                if ok:
                    print(f"[WHEEL HID] {d.get('name')} HID++ aperture confirmed at {readback} deg")
                    return True, attempts
            except Exception as e:
                attempts.append({
                    "ok": False,
                    "transport": transport,
                    "stage": "exception",
                    "protocol": d.get('protocol'),
                    "name": d.get('name'),
                    "pid": d.get('product_id'),
                    "interface": d.get('interface_number'),
                    "path": d.get('path_text'),
                    "error": str(e),
                })
            finally:
                if h:
                    try:
                        h.close()
                    except Exception:
                        pass
    return False, attempts

def _send_wheel_cmd(cmd_bytes):
    """Send a classic 7-byte Logitech command to lg4ff-compatible wheels."""
    if not HID_AVAILABLE:
        return False
    ok_any = False
    for d in _logitech_hid_devices():
        if d.get('product_id') not in LOGITECH_CLASSIC_AUTOCENTER_PIDS:
            continue
        result = _write_hid_report(d['path'], [0x00] + list(cmd_bytes))
        if result["ok"]:
            ok_any = True
            print(f"[WHEEL HID] Classic command sent to {d.get('name')} interface {d.get('interface_number')}")
        else:
            print(f"[WHEEL HID] Classic command failed on interface {d.get('interface_number')}: {result.get('error')}")
    return ok_any

def set_logitech_wheel_range(range_degrees, target_hint="", allow_mode_switch=False):
    """Set the physical steering range for supported Logitech wheel protocols."""
    global LOGITECH_LAST_RANGE_STATUS, LOGITECH_LAST_RANGE_APPLIED
    LOGITECH_LAST_RANGE_APPLIED = None
    if not HID_AVAILABLE:
        LOGITECH_LAST_RANGE_STATUS = "HID support is not available"
        return False
    range_degrees = max(10, min(1080, int(range_degrees)))

    ok, classic_attempts = _set_logitech_lg4ff_or_dfp_range(range_degrees, target_hint)
    if ok:
        LOGITECH_LAST_RANGE_APPLIED = range_degrees
        LOGITECH_LAST_RANGE_STATUS = f"Classic Logitech hardware range command accepted: {range_degrees} degrees"
        return True

    if allow_mode_switch and any(d.get('protocol') == "g923_ps_mode" for d in _filtered_logitech_hid_devices(target_hint)):
        if _switch_g923_ps_mode_to_native(target_hint):
            ok, classic_attempts = _set_logitech_lg4ff_or_dfp_range(range_degrees, target_hint)
            if ok:
                LOGITECH_LAST_RANGE_APPLIED = range_degrees
                LOGITECH_LAST_RANGE_STATUS = f"G923 PS-mode switched and range command accepted: {range_degrees} degrees"
                return True
    elif any(d.get('protocol') == "g923_ps_mode" for d in _filtered_logitech_hid_devices(target_hint)):
        LOGITECH_LAST_RANGE_STATUS = "G923 is in PS pre-switch mode; run the hardware diagnostic mode-switch test"

    ok, hidpp_attempts = _set_logitech_hidpp_range(range_degrees, target_hint)
    if ok:
        LOGITECH_LAST_RANGE_APPLIED = max(180, min(900, range_degrees))
        LOGITECH_LAST_RANGE_STATUS = f"HID++ aperture confirmed at {LOGITECH_LAST_RANGE_APPLIED} degrees"
        return True

    devices = _filtered_logitech_hid_devices(target_hint)
    protocols = sorted(set(d.get('protocol') for d in devices)) if devices else []
    LOGITECH_LAST_RANGE_STATUS = f"No confirmed Logitech hardware range provider; protocols={protocols}, classic_attempts={len(classic_attempts)}, hidpp_attempts={len(hidpp_attempts)}"
    print(f"[WHEEL HID] {LOGITECH_LAST_RANGE_STATUS}")
    return False

def set_logitech_wheel_autocenter(strength):
    """Set the hardware autocenter spring strength (0.0 = off, 1.0 = max).
    Uses the lg4ff autocenter protocol:
      Off: [0xf5, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
      On:  [0xfe, 0x0d, force_a, force_a, force_b, 0x00, 0x00]
           then [0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
    """
    if not HID_AVAILABLE:
        return False
    strength = max(0.0, min(1.0, float(strength)))

    if strength <= 0.0:
        # Deactivate autocenter
        result = _send_wheel_cmd([0xf5, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])
        if result:
            print("[WHEEL HID] Autocenter OFF")
        return result

    # Convert 0.0-1.0 to 0x0000-0xFFFF magnitude
    magnitude = int(strength * 0xFFFF)

    # Calculate expansion values (from lg4ff kernel driver)
    if magnitude <= 0xaaaa:
        expand_a = 0x0c * magnitude
        expand_b = 0x80 * magnitude
    else:
        expand_a = (0x0c * 0xaaaa) + 0x06 * (magnitude - 0xaaaa)
        expand_b = (0x80 * 0xaaaa) + 0xff * (magnitude - 0xaaaa)

    # Non-MOMO wheels: right-shift expand_a by 1
    expand_a = expand_a >> 1

    force_a = expand_a // 0xaaaa
    force_b = expand_b // 0xaaaa

    # Clamp to valid byte range
    force_a = max(0, min(0xff, force_a))
    force_b = max(0, min(0xff, force_b))

    # Send autocenter parameters
    cmd1 = [0xfe, 0x0d, force_a, force_a, force_b, 0x00, 0x00]
    # Send activation command
    cmd2 = [0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]

    r1 = _send_wheel_cmd(cmd1)
    r2 = _send_wheel_cmd(cmd2)
    if r1 and r2:
        print(f"[WHEEL HID] Autocenter set to {strength:.0%} (force_a={force_a:#x}, force_b={force_b:#x})")
    return r1 and r2

# Current version and update channel for the Competitive build.
CURRENT_VERSION = "4.8.23"
UPDATE_CHANNEL = "competitive"
UPDATE_PLATFORM = "windows-x64"
SITE_BASE_URLS = ("https://trueaxis.nl", "https://trueaccess.nl")
VERSION_CHECK_URL = f"{SITE_BASE_URLS[0]}/version.json"
DOWNLOAD_URL = f"{SITE_BASE_URLS[0]}/versions-competitive.html"
TRUSTED_UPDATE_HOSTS = {"trueaxis.nl", "www.trueaxis.nl", "trueaccess.nl", "www.trueaccess.nl"}
HARDWARE_REPORT_URLS = [f"{base}/api/hardware-range-report" for base in SITE_BASE_URLS]
TELEMETRY_URLS = [f"{base}/api/telemetry" for base in SITE_BASE_URLS]
DEFAULT_INSTALLER_ARGS = ["/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART", "/CLOSEAPPLICATIONS", "/SP-"]
ALLOWED_INSTALLER_ARGS = {"/NOSTART", "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART", "/CLOSEAPPLICATIONS", "/SP-", "/SILENT", "/QUIET"}
VIGEM_INSTALLER_URL = "https://github.com/nefarius/ViGEmBus/releases/download/v1.22.0/ViGEmBus_1.22.0_x64_x86_arm64.exe"
DEFAULT_WHEEL_MAX_RANGE = 1080
MAX_STEERING_RANGE_SETTING = 2520
SOFT_RAMP_AVAILABLE = False
UPDATE_CHECK_INTERVAL_MS = 30_000
TELEMETRY_HEARTBEAT_INTERVAL_MS = 5 * 60_000
HARDWARE_REASSERT_INTERVAL_MS = 8_000
CENTER_SPRING_PULSE_STRENGTH = 0.01
CENTER_SPRING_PULSE_DELAY_SECONDS = 0.08
INPUT_READER_POLL_SECONDS = 0.005
MAPPING_LOOP_SLEEP_SECONDS = 0.002
PREVIEW_EMIT_EPSILON = 0.002

def _version_sort_key(version):
    """Return a comparable key for versions like v4.8, 4.8.1-beta, or 4.8-comp."""
    text = str(version or "").strip().lower().lstrip("v")
    numbers = [int(part) for part in re.findall(r"\d+", text)[:4]]
    numbers += [0] * (4 - len(numbers))
    suffix_weight = 0
    suffix_number = 0
    if "alpha" in text:
        suffix_weight = -3
    elif "beta" in text:
        suffix_weight = -2
    elif "rc" in text:
        suffix_weight = -1
    suffix_match = re.search(r"(?:alpha|beta|rc)[.-]?(\d+)", text)
    if suffix_match:
        suffix_number = int(suffix_match.group(1))
    return tuple(numbers + [suffix_weight, suffix_number])

def _default_trueaxis_install_dir():
    return os.path.join(os.getenv("LOCALAPPDATA", tempfile.gettempdir()), "Programs", "TrueAxis")

def _looks_like_trueaxis_onedir_install(directory):
    if not directory:
        return False
    directory = os.path.abspath(directory)
    return (
        os.path.isfile(os.path.join(directory, "TrueAxis.exe"))
        and os.path.isdir(os.path.join(directory, "_internal"))
    )

# Try to import pynput for keyboard support
try:
    from pynput.keyboard import Controller as KeyboardController, Key
    KEYBOARD_AVAILABLE = True
except ImportError:
    print("Warning: pynput not installed. Keyboard bindings will not be available.")
    print("Install with: pip install pynput")
    KEYBOARD_AVAILABLE = False
    KeyboardController = None
    Key = None

# --- Platform-specific imports for auto-start ---
if sys.platform == 'win32':
    import winreg
elif sys.platform == 'darwin':  # macOS
    import subprocess
    import plistlib
    from pathlib import Path

# --- CONFIG & PROFILES ---
_APP_DIR = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else os.path.dirname(os.path.abspath(__file__))

def _resolve_user_data_dir():
    if sys.platform == 'win32':
        base_dir = os.getenv('APPDATA') or os.getenv('LOCALAPPDATA') or os.path.expanduser("~")
    elif sys.platform == 'darwin':
        base_dir = os.path.join(os.path.expanduser("~"), "Library", "Application Support")
    else:
        base_dir = os.getenv('XDG_CONFIG_HOME') or os.path.join(os.path.expanduser("~"), ".config")
    return os.path.join(base_dir, "TrueAxis")

_DATA_DIR = _resolve_user_data_dir()
os.makedirs(_DATA_DIR, exist_ok=True)

CONFIG_FILE = os.path.join(_DATA_DIR, "trueaxis_config.json")
BUTTON_MAPPING_FILE = os.path.join(_DATA_DIR, "trueaxis_buttons.json")
SETTINGS_FILE = os.path.join(_DATA_DIR, "trueaxis_settings.json")
MACRO_FILE = os.path.join(_DATA_DIR, "trueaxis_macros.json")
UPDATE_STATE_FILE = os.path.join(_DATA_DIR, "update_state.json")
UPDATE_HELPER_LOG_FILE = os.path.join(_DATA_DIR, "update_helper.log")
INSTALL_ID_FILE = os.path.join(_DATA_DIR, "install_id.json")
TELEMETRY_STATE_FILE = os.path.join(_DATA_DIR, "telemetry_state.json")
LEGACY_CONFIG_FILE = os.path.join(_APP_DIR, "trueaxis_config.json")
LEGACY_BUTTON_MAPPING_FILE = os.path.join(_APP_DIR, "trueaxis_buttons.json")
LEGACY_SETTINGS_FILE = os.path.join(_APP_DIR, "trueaxis_settings.json")
LEGACY_MACRO_FILE = os.path.join(_APP_DIR, "trueaxis_macros.json")
LEGACY_MACRO_FILES = [
    LEGACY_MACRO_FILE,
    os.path.join(os.path.expanduser("~"), "Documents", "trueaxis_macros.json"),
    os.path.join(os.path.expanduser("~"), "trueaxis_macros.json"),
]

def _migrate_legacy_file(target, legacy_paths):
    """Copy an old same-folder config file into the stable per-user data folder."""
    if os.path.exists(target):
        return
    for legacy_path in legacy_paths:
        if not legacy_path or os.path.abspath(legacy_path) == os.path.abspath(target):
            continue
        if os.path.exists(legacy_path):
            try:
                shutil.copy2(legacy_path, target)
                return
            except Exception as e:
                print(f"Could not migrate {legacy_path} to {target}: {e}")

_migrate_legacy_file(CONFIG_FILE, [LEGACY_CONFIG_FILE])
_migrate_legacy_file(BUTTON_MAPPING_FILE, [LEGACY_BUTTON_MAPPING_FILE])
_migrate_legacy_file(SETTINGS_FILE, [LEGACY_SETTINGS_FILE])
_migrate_legacy_file(MACRO_FILE, LEGACY_MACRO_FILES)

def _atomic_json_save(filepath, data):
    """Write JSON data atomically: write to temp file, then rename over original.
    Also keeps a .bak copy of the previous file for recovery."""
    bak_path = filepath + ".bak"
    tmp_path = filepath + ".tmp"
    try:
        os.makedirs(os.path.dirname(filepath), exist_ok=True)
        # Write to temp file first
        with open(tmp_path, 'w') as f:
            json.dump(data, f, indent=2)
        # Keep a backup of the current file
        if os.path.exists(filepath):
            try:
                if os.path.exists(bak_path):
                    os.remove(bak_path)
                os.rename(filepath, bak_path)
            except Exception:
                # If backup fails, still try to save
                pass
        # Atomic rename temp -> target
        os.rename(tmp_path, filepath)
    except Exception:
        # Clean up temp file on failure
        if os.path.exists(tmp_path):
            try:
                os.remove(tmp_path)
            except Exception:
                pass
        raise


def _load_json_with_backup(filepath):
    """Load JSON from filepath, falling back to .bak if main file is corrupted/missing."""
    bak_path = filepath + ".bak"
    # Try main file first
    if os.path.exists(filepath):
        try:
            with open(filepath, 'r') as f:
                content = f.read().strip()
                if content:
                    return json.loads(content)
        except (json.JSONDecodeError, ValueError, OSError):
            print(f"Warning: {filepath} is corrupted or unreadable, trying backup...")
    # Try backup
    if os.path.exists(bak_path):
        try:
            with open(bak_path, 'r') as f:
                content = f.read().strip()
                if content:
                    print(f"Recovered settings from {bak_path}")
                    return json.loads(content)
        except (json.JSONDecodeError, ValueError, OSError):
            print(f"Warning: backup {bak_path} is also corrupted or unreadable")
    return None


def _get_or_create_install_id():
    data = _load_json_with_backup(INSTALL_ID_FILE)
    if isinstance(data, dict):
        install_id = str(data.get("install_id") or "").strip()
        if install_id:
            return install_id

    install_id = str(uuid.uuid4())
    try:
        _atomic_json_save(INSTALL_ID_FILE, {
            "install_id": install_id,
            "created_at": int(time.time()),
        })
    except Exception as e:
        print(f"Could not persist install id: {e}")
    return install_id


PROFILES = {
    "Logitech G29 (Standard)": {"axes": [0, 2, 3], "desc": "Recommended first choice for G29"},
    "Logitech G29 (Alt Mode)": {"axes": [0, 1, 2], "desc": "Use if Gas/Brake inputs are swapped"},
    "Logitech G920 / G923": {"axes": [0, 1, 2], "desc": "Standard Xbox-layout wheels"},
    "Logitech Pro Racing Wheel": {"axes": [0, 1, 2], "desc": "Logitech direct drive wheel layout"},
    "Driving Force GT": {"axes": [0, 1, 2], "desc": "Legacy Logitech wheels"},
    "Logitech G27": {"axes": [0, 2, 3], "desc": "Classic G27 axis mapping"},
    "PlayStation 4/5 (Standard)": {"axes": [0, 5, 4], "desc": "Steer: L-Stick, Gas: R2, Brake: L2"},
    "PlayStation 4/5 (Alt Mode)": {"axes": [0, 4, 3], "desc": "Use if triggers are reversed"},
    "PlayStation (DirectInput)": {"axes": [0, 2, 5], "desc": "Raw DirectInput mode"},
    "MOZA (Standard)": {"axes": [0, 1, 2], "desc": "MOZA R-series direct drive wheels"},
    "MOZA (Alt Pedals)": {"axes": [0, 2, 3], "desc": "MOZA layout if pedals appear on separate axes"},
    "Fanatec (Standard)": {"axes": [0, 1, 2], "desc": "Yellow compatibility mode"},
    "Fanatec (Alt 1)": {"axes": [0, 2, 3], "desc": "Alternative axis configuration"},
    "Fanatec (Alt 2)": {"axes": [0, 4, 5], "desc": "Common on CSL DD bases"},
    "Fanatec (Alt 3)": {"axes": [0, 5, 6], "desc": "Higher axis pedal mapping"},
    "Direct Drive Wheel (Standard)": {"axes": [0, 1, 2], "desc": "Simucube, Simagic, Asetek, VRS, Cammus"},
    "Direct Drive Wheel (Alt Pedals)": {"axes": [0, 2, 3], "desc": "Direct-drive layout if pedals are on alt axes"},
    "Thrustmaster T300/T150": {"axes": [0, 1, 2], "desc": "Standard Thrustmaster mapping"},
    "Thrustmaster T-GT / T248": {"axes": [0, 2, 1], "desc": "Newer generation wheels"},
    "Thrustmaster (Combined)": {"axes": [0, 1, 1], "desc": "Combined pedal axis mode"},
    "PXN / Hori Wheel": {"axes": [0, 1, 2], "desc": "Budget DirectInput wheels"},
    "Generic Gamepad (Xbox/XInput)": {"axes": [0, 5, 4], "desc": "Standard Xbox controller layout"},
    "Generic Wheel (DirectInput)": {"axes": [0, 1, 2], "desc": "Universal DirectInput wheels"},
    "Combined Pedals Mode": {"axes": [0, 1, 1], "desc": "Single axis for Gas/Brake"},
}

PROFILE_MAX_STEERING_RANGE = {
    "Logitech G29 (Standard)": 900,
    "Logitech G29 (Alt Mode)": 900,
    "Logitech G920 / G923": 900,
    "Logitech Pro Racing Wheel": 1080,
    "Driving Force GT": 900,
    "Logitech G27": 900,
    "MOZA (Standard)": 2000,
    "MOZA (Alt Pedals)": 2000,
    "Fanatec (Standard)": 1080,
    "Fanatec (Alt 1)": 1080,
    "Fanatec (Alt 2)": 1080,
    "Fanatec (Alt 3)": 1080,
    "Direct Drive Wheel (Standard)": 2520,
    "Direct Drive Wheel (Alt Pedals)": 2520,
    "Thrustmaster T300/T150": 1080,
    "Thrustmaster T-GT / T248": 1080,
    "Thrustmaster (Combined)": 1080,
    "PXN / Hori Wheel": 900,
    "Generic Wheel (DirectInput)": 1080,
    "Combined Pedals Mode": 1080,
}

DEVICE_MAX_STEERING_RANGE_PATTERNS = [
    (["moza", "r3"], 2000),
    (["moza", "r5"], 2000),
    (["moza", "r9"], 2000),
    (["moza", "r12"], 2000),
    (["moza", "r16"], 2000),
    (["moza", "r21"], 2000),
    (["moza"], 2000),
    (["simucube"], 2520),
    (["simagic"], 2520),
    (["alpha mini"], 2520),
    (["alpha"], 2520),
    (["vrs"], 2520),
    (["asetek"], 2520),
    (["la prima"], 2520),
    (["forte"], 2520),
    (["invicta"], 2520),
    (["cammus"], 2520),
    (["fanatec", "podium"], 2520),
    (["fanatec", "dd"], 2520),
    (["fanatec"], 1080),
    (["t818"], 1080),
    (["t-gt"], 1080),
    (["t300"], 1080),
    (["t500"], 1080),
    (["tx racing"], 900),
    (["tmx"], 900),
    (["t150"], 1080),
    (["t248"], 900),
    (["thrustmaster"], 1080),
    (["logitech pro"], 1080),
    (["g923"], 900),
    (["g920"], 900),
    (["g29"], 900),
    (["g27"], 900),
    (["g25"], 900),
    (["driving force gt"], 900),
    (["driving force pro"], 900),
    (["hori"], 270),
    (["pxn"], 900),
]

LOGITECH_HID_PROFILE_NAMES = {
    "Logitech G29 (Standard)",
    "Logitech G29 (Alt Mode)",
    "Logitech G920 / G923",
    "Driving Force GT",
    "Logitech G27",
}

def _keywords_match(text, keywords):
    lowered = (text or "").lower()
    return all(keyword in lowered for keyword in keywords)

def detect_max_steering_range(device_name="", profile_name=""):
    """Best-effort physical rotation estimate for hardware range limits."""
    haystack = f"{device_name or ''} {profile_name or ''}".lower()
    for keywords, max_range in DEVICE_MAX_STEERING_RANGE_PATTERNS:
        if _keywords_match(haystack, keywords):
            return max_range
    if profile_name in PROFILE_MAX_STEERING_RANGE:
        return PROFILE_MAX_STEERING_RANGE[profile_name]
    return DEFAULT_WHEEL_MAX_RANGE

# Device name detection patterns for auto-profile selection
# Each entry is (keywords_list, profile_name)
DEVICE_PROFILE_PATTERNS = [
    # Logitech wheels
    (["g29"], "Logitech G29 (Standard)"),
    (["g920", "g923"], "Logitech G920 / G923"),
    (["logitech pro"], "Logitech Pro Racing Wheel"),
    (["g27"], "Logitech G27"),
    (["g25"], "Logitech G27"),
    (["driving force gt", "dfgt"], "Driving Force GT"),
    # Direct-drive wheels
    (["moza"], "MOZA (Standard)"),
    (["simucube"], "Direct Drive Wheel (Standard)"),
    (["simagic"], "Direct Drive Wheel (Standard)"),
    (["asetek"], "Direct Drive Wheel (Standard)"),
    (["la prima"], "Direct Drive Wheel (Standard)"),
    (["forte"], "Direct Drive Wheel (Standard)"),
    (["invicta"], "Direct Drive Wheel (Standard)"),
    (["vrs"], "Direct Drive Wheel (Standard)"),
    (["cammus"], "Direct Drive Wheel (Standard)"),
    # PlayStation controllers
    (["dualsense", "ps5", "playstation 5"], "PlayStation 4/5 (Standard)"),
    (["dualshock", "ps4", "playstation 4"], "PlayStation 4/5 (Standard)"),
    (["wireless controller"], "PlayStation 4/5 (Standard)"),  # Generic PS controller name
    # Fanatec
    (["fanatec"], "Fanatec (Standard)"),
    (["csl"], "Fanatec (Standard)"),
    (["clubsport"], "Fanatec (Standard)"),
    # Thrustmaster
    (["t300"], "Thrustmaster T300/T150"),
    (["t150"], "Thrustmaster T300/T150"),
    (["tmx"], "Thrustmaster T300/T150"),
    (["tx racing"], "Thrustmaster T300/T150"),
    (["t500"], "Thrustmaster T300/T150"),
    (["t818"], "Thrustmaster T300/T150"),
    (["t-gt"], "Thrustmaster T-GT / T248"),
    (["t248"], "Thrustmaster T-GT / T248"),
    (["thrustmaster"], "Thrustmaster T300/T150"),
    # Budget DirectInput wheels
    (["pxn"], "PXN / Hori Wheel"),
    (["hori racing wheel", "hori wheel", "racing wheel apex"], "PXN / Hori Wheel"),
    (["drivefx", "wheel"], "Generic Wheel (DirectInput)"),
    # Xbox controllers
    (["xbox", "xinput"], "Generic Gamepad (Xbox/XInput)"),
]

# Default button mappings for common devices
# Maps device keywords to a dictionary of {button_index: xbox_button_name}
# 
# IMPORTANT NOTE: Button indices can vary based on:
# - Operating System (Windows/Linux/Mac)
# - Connection method (USB vs Bluetooth)
# - Driver version
# - Controller firmware
# 
# These are APPROXIMATE defaults based on common configurations.
# Users should verify and adjust using the Input Inspector and button configuration dialog.
# The app will remember custom configurations per device.
DEVICE_DEFAULT_BUTTONS = {
    # PlayStation 5 DualSense (Corrected based on actual pygame behavior)
    "dualsense": {
        "0": "X (Square)",      # Button 0 = Square
        "1": "A (Cross)",       # Button 1 = Cross/X
        "2": "B (Circle)",      # Button 2 = Circle
        "3": "Y (Triangle)",    # Button 3 = Triangle
        "4": "LB (L1)",         # Button 4 = L1 bumper
        "5": "RB (R1)",         # Button 5 = R1 bumper
        "6": "LB (L1)",         # Button 6 = L2 trigger press (mapped to LB for compatibility)
        "7": "RB (R1)",         # Button 7 = R2 trigger press (mapped to RB for compatibility)
        "8": "Back (Share)",    # Button 8 = Share/Create button
        "9": "Start (Options)", # Button 9 = Options button
        "10": "LThumb (L3)",    # Button 10 = L3 stick press
        "11": "RThumb (R3)",    # Button 11 = R3 stick press
        "12": "Guide (PS)",     # Button 12 = PS button
        "13": "DPad Left",      # Button 13 = D-pad left (or touchpad on some)
        "14": "DPad Up",        # Button 14 = D-pad up
        "15": "DPad Right",     # Button 15 = D-pad right
        "16": "DPad Down",      # Button 16 = D-pad down
    },
    # PlayStation 4 DualShock 4 (Corrected based on actual pygame behavior)
    "dualshock": {
        "0": "X (Square)",      # Button 0 = Square
        "1": "A (Cross)",       # Button 1 = Cross/X
        "2": "B (Circle)",      # Button 2 = Circle
        "3": "Y (Triangle)",    # Button 3 = Triangle
        "4": "LB (L1)",         # Button 4 = L1 bumper
        "5": "RB (R1)",         # Button 5 = R1 bumper
        "6": "LB (L1)",         # Button 6 = L2 trigger press (mapped to LB for compatibility)
        "7": "RB (R1)",         # Button 7 = R2 trigger press (mapped to RB for compatibility)
        "8": "Back (Share)",    # Button 8 = Share button
        "9": "Start (Options)", # Button 9 = Options button
        "10": "LThumb (L3)",    # Button 10 = L3 stick press
        "11": "RThumb (R3)",    # Button 11 = R3 stick press
        "12": "Guide (PS)",     # Button 12 = PS button
        "13": "DPad Up",        # Button 13 = Touchpad press or D-pad up
        "14": "DPad Left",      # Button 14 = D-pad left
        "15": "DPad Down",      # Button 15 = D-pad down
        "16": "DPad Right",     # Button 16 = D-pad right
    },
    # Generic PlayStation controller name (Corrected)
    "wireless controller": {
        "0": "X (Square)",      # Button 0 = Square
        "1": "A (Cross)",       # Button 1 = Cross/X
        "2": "B (Circle)",      # Button 2 = Circle
        "3": "Y (Triangle)",    # Button 3 = Triangle
        "4": "LB (L1)",         # Button 4 = L1 bumper
        "5": "RB (R1)",         # Button 5 = R1 bumper
        "6": "LB (L1)",         # Button 6 = L2 trigger press (mapped to LB for compatibility)
        "7": "RB (R1)",         # Button 7 = R2 trigger press (mapped to RB for compatibility)
        "8": "Back (Share)",    # Button 8 = Share button
        "9": "Start (Options)", # Button 9 = Options button
        "10": "LThumb (L3)",    # Button 10 = L3 stick press
        "11": "RThumb (R3)",    # Button 11 = R3 stick press
        "12": "Guide (PS)",     # Button 12 = PS button
        "13": "DPad Up",        # Button 13 = Touchpad or D-pad
        "14": "DPad Left",      # Button 14 = D-pad left
        "15": "DPad Down",      # Button 15 = D-pad down
        "16": "DPad Right",     # Button 16 = D-pad right
    },
    # Xbox 360 Controller (Official pygame mapping)
    "xbox 360": {
        "0": "A (Cross)",
        "1": "B (Circle)",
        "2": "X (Square)",
        "3": "Y (Triangle)",
        "4": "LB (L1)",
        "5": "RB (R1)",
        "6": "Back (Share)",
        "7": "Start (Options)",
        "8": "LThumb (L3)",
        "9": "RThumb (R3)",
        "10": "Guide (PS)",     # Guide button (if present)
    },
    # Xbox One / Series Controller
    "xbox": {
        "0": "A (Cross)",
        "1": "B (Circle)",
        "2": "X (Square)",
        "3": "Y (Triangle)",
        "4": "LB (L1)",
        "5": "RB (R1)",
        "6": "Back (Share)",
        "7": "Start (Options)",
        "8": "LThumb (L3)",
        "9": "RThumb (R3)",
    },
    # Logitech G29 (Based on common racing wheel layout)
    "g29": {
        "0": "X (Square)",      # Top left red button
        "1": "B (Circle)",      # Top right red button
        "2": "A (Cross)",       # Right red button  
        "3": "Y (Triangle)",    # Left red button
        "4": "RB (R1)",         # Right shifter paddle
        "5": "LB (L1)",         # Left shifter paddle
        "6": "RThumb (R3)",     # R3
        "7": "LThumb (L3)",     # L3
        "8": "Back (Share)",    # Share
        "9": "Start (Options)", # Options
        "10": "Guide (PS)",     # PS button (center logo)
        "11": "RThumb (R3)",    # Right dial press
        "12": "LThumb (L3)",    # Left dial press
        # Buttons 13-24 are additional G29 buttons on wheel
    },
    # Logitech G920/G923 (Xbox layout)
    "g920": {
        "0": "A (Cross)",
        "1": "B (Circle)",
        "2": "X (Square)",
        "3": "Y (Triangle)",
        "4": "LB (L1)",         # Left shifter paddle
        "5": "RB (R1)",         # Right shifter paddle
        "6": "Back (Share)",    # View button
        "7": "Start (Options)", # Menu button
        "8": "LThumb (L3)",
        "9": "RThumb (R3)",
    },
    "g923": {
        "0": "A (Cross)",
        "1": "B (Circle)",
        "2": "X (Square)",
        "3": "Y (Triangle)",
        "4": "LB (L1)",         # Left shifter paddle
        "5": "RB (R1)",         # Right shifter paddle
        "6": "Back (Share)",
        "7": "Start (Options)",
        "8": "LThumb (L3)",
        "9": "RThumb (R3)",
    },
    # Logitech G27
    "g27": {
        "0": "X (Square)",
        "1": "B (Circle)",
        "2": "A (Cross)",
        "3": "Y (Triangle)",
        "4": "LB (L1)",         # Left shifter paddle
        "5": "RB (R1)",         # Right shifter paddle
        "6": "Back (Share)",
        "7": "Start (Options)",
        "8": "LThumb (L3)",
        "9": "RThumb (R3)",
    },
    # Thrustmaster wheels (Common layout)
    "thrustmaster": {
        "0": "A (Cross)",
        "1": "B (Circle)",
        "2": "X (Square)",
        "3": "Y (Triangle)",
        "4": "LB (L1)",
        "5": "RB (R1)",
        "6": "Back (Share)",
        "7": "Start (Options)",
        "8": "LThumb (L3)",
        "9": "RThumb (R3)",
        "10": "Guide (PS)",
    },
    # Fanatec wheels
    "fanatec": {
        "0": "A (Cross)",
        "1": "B (Circle)",
        "2": "X (Square)",
        "3": "Y (Triangle)",
        "4": "LB (L1)",
        "5": "RB (R1)",
        "6": "Back (Share)",
        "7": "Start (Options)",
        "8": "LThumb (L3)",
        "9": "RThumb (R3)",
    },
}

XBOX_BUTTON_NAMES = [
    "DISABLED",
    "--- XBOX BUTTONS ---",
    "A (Cross)",
    "B (Circle)",
    "X (Square)",
    "Y (Triangle)",
    "LB (L1)",
    "RB (R1)",
    "Back (Share)",
    "Start (Options)",
    "LThumb (L3)",
    "RThumb (R3)",
    "Guide (PS)",
    "DPad Up",
    "DPad Down",
    "DPad Left",
    "DPad Right",
    "--- KEYBOARD KEYS ---",
    "Key: Space",
    "Key: Enter",
    "Key: Shift",
    "Key: Ctrl",
    "Key: Alt",
    "Key: Tab",
    "Key: Esc",
    "Key: 0",
    "Key: 1",
    "Key: 2",
    "Key: 3",
    "Key: 4",
    "Key: 5",
    "Key: 6",
    "Key: 7",
    "Key: 8",
    "Key: 9",
    "Key: A",
    "Key: B",
    "Key: C",
    "Key: D",
    "Key: E",
    "Key: F",
    "Key: G",
    "Key: H",
    "Key: I",
    "Key: J",
    "Key: K",
    "Key: L",
    "Key: M",
    "Key: N",
    "Key: O",
    "Key: P",
    "Key: Q",
    "Key: R",
    "Key: S",
    "Key: T",
    "Key: U",
    "Key: V",
    "Key: W",
    "Key: X",
    "Key: Y",
    "Key: Z",
    "Key: F1",
    "Key: F2",
    "Key: F3",
    "Key: F4",
    "Key: F5",
    "Key: F6",
    "Key: F7",
    "Key: F8",
    "Key: F9",
    "Key: F10",
    "Key: F11",
    "Key: F12",
    "Key: Up Arrow",
    "Key: Down Arrow",
    "Key: Left Arrow",
    "Key: Right Arrow",
]

XBOX_BUTTON_MAP = {
    "DISABLED": None,
    "--- XBOX BUTTONS ---": None,
    "A (Cross)": 0x1000,
    "B (Circle)": 0x2000,
    "X (Square)": 0x4000,
    "Y (Triangle)": 0x8000,
    "LB (L1)": 0x0400,
    "RB (R1)": 0x0800,
    "Back (Share)": 0x0020,
    "Start (Options)": 0x0010,
    "LThumb (L3)": 0x0040,
    "RThumb (R3)": 0x0080,
    "Guide (PS)": 0x0400 | 0x0800,  # Not a real XUSB bit; handled specially below
    "DPad Up": 0x0001,
    "DPad Down": 0x0002,
    "DPad Left": 0x0004,
    "DPad Right": 0x0008,
    "--- KEYBOARD KEYS ---": None,
}

# If vgamepad loaded successfully, replace with its enums for exact correctness
if VIGEM_AVAILABLE:
    XBOX_BUTTON_MAP.update({
        "A (Cross)": vg.XUSB_BUTTON.XUSB_GAMEPAD_A,
        "B (Circle)": vg.XUSB_BUTTON.XUSB_GAMEPAD_B,
        "X (Square)": vg.XUSB_BUTTON.XUSB_GAMEPAD_X,
        "Y (Triangle)": vg.XUSB_BUTTON.XUSB_GAMEPAD_Y,
        "LB (L1)": vg.XUSB_BUTTON.XUSB_GAMEPAD_LEFT_SHOULDER,
        "RB (R1)": vg.XUSB_BUTTON.XUSB_GAMEPAD_RIGHT_SHOULDER,
        "Back (Share)": vg.XUSB_BUTTON.XUSB_GAMEPAD_BACK,
        "Start (Options)": vg.XUSB_BUTTON.XUSB_GAMEPAD_START,
        "LThumb (L3)": vg.XUSB_BUTTON.XUSB_GAMEPAD_LEFT_THUMB,
        "RThumb (R3)": vg.XUSB_BUTTON.XUSB_GAMEPAD_RIGHT_THUMB,
        "Guide (PS)": vg.XUSB_BUTTON.XUSB_GAMEPAD_GUIDE,
        "DPad Up": vg.XUSB_BUTTON.XUSB_GAMEPAD_DPAD_UP,
        "DPad Down": vg.XUSB_BUTTON.XUSB_GAMEPAD_DPAD_DOWN,
        "DPad Left": vg.XUSB_BUTTON.XUSB_GAMEPAD_DPAD_LEFT,
        "DPad Right": vg.XUSB_BUTTON.XUSB_GAMEPAD_DPAD_RIGHT,
    })

# Keyboard key mapping (only if pynput is available)
if KEYBOARD_AVAILABLE:
    KEYBOARD_MAP = {
        "Key: Space": ' ',
        "Key: Enter": Key.enter,
        "Key: Shift": Key.shift,
        "Key: Ctrl": Key.ctrl,
        "Key: Alt": Key.alt,
        "Key: Tab": Key.tab,
        "Key: Esc": Key.esc,
        "Key: 0": '0', "Key: 1": '1', "Key: 2": '2', "Key: 3": '3', "Key: 4": '4',
        "Key: 5": '5', "Key: 6": '6', "Key: 7": '7', "Key: 8": '8', "Key: 9": '9',
        "Key: A": 'a', "Key: B": 'b', "Key: C": 'c', "Key: D": 'd', "Key: E": 'e',
        "Key: F": 'f', "Key: G": 'g', "Key: H": 'h', "Key: I": 'i', "Key: J": 'j',
        "Key: K": 'k', "Key: L": 'l', "Key: M": 'm', "Key: N": 'n', "Key: O": 'o',
        "Key: P": 'p', "Key: Q": 'q', "Key: R": 'r', "Key: S": 's', "Key: T": 't',
        "Key: U": 'u', "Key: V": 'v', "Key: W": 'w', "Key: X": 'x', "Key: Y": 'y',
        "Key: Z": 'z',
        "Key: F1": Key.f1, "Key: F2": Key.f2, "Key: F3": Key.f3, "Key: F4": Key.f4,
        "Key: F5": Key.f5, "Key: F6": Key.f6, "Key: F7": Key.f7, "Key: F8": Key.f8,
        "Key: F9": Key.f9, "Key: F10": Key.f10, "Key: F11": Key.f11, "Key: F12": Key.f12,
        "Key: Up Arrow": Key.up,
        "Key: Down Arrow": Key.down,
        "Key: Left Arrow": Key.left,
        "Key: Right Arrow": Key.right,
    }
else:
    KEYBOARD_MAP = {}

QML_CODE = """
import QtQuick
import QtQuick.Controls.Basic
import QtQuick.Layouts
import QtQuick.Window
import QtQuick.Shapes

ApplicationWindow {
    id: mainWindow
    width: 700
    height: 680
    minimumWidth: 660
    minimumHeight: 630
    visible: true
    title: "TrueAxis v" + backend.currentVersion
    color: colorBg
    
    // Auto-start minimized flag
    property bool autoStartMinimized: false
    
    // Update and ViGEm status
    property bool updateAvailable: backend.updateAvailable
    property bool vigEmInstalled: true  // Assume installed by default
    property string latestVersion: backend.newVersion
    property string updateStatus: ""
    property string vigEmStatus: ""
    property string appStatusText: "Ready"
    property color appStatusColor: colorInactive

    // Transient action confirmations fall back to the baseline state;
    // errors and warnings stay until replaced.
    Timer {
        id: statusRevertTimer
        interval: 3000
        onTriggered: {
            if (updateInProgress) {
                return
            }
            if (backend.running) {
                appStatusText = "Active: Emulating controller"
                appStatusColor = colorSuccess
            } else {
                appStatusText = "Ready"
                appStatusColor = colorInactive
            }
        }
    }
    property bool darkMode: backend.themeMode === "dark"
    property bool updateInProgress: false
    property int inlineUpdateProgress: 0
    property string inlineUpdateStatus: ""
    property bool updatePreview: false
    
    // Connect to backend signals
    Connections {
        target: backend
        function onUpdateAvailableChanged() {
            updateAvailable = backend.updateAvailable
            latestVersion = backend.newVersion
        }
    }
    
    // Backend checks ViGEm after startup so the first paint is not delayed.
    
    // Show ViGEm dialog when status changes to not installed
    Connections {
        target: backend
        function onVigEmStatusChanged(status) {
            vigEmStatus = status
            if (status === "not installed") {
                vigEmInstalled = false
                vigEmDialog.open()
            } else if (status === "installed") {
                vigEmInstalled = true
                vigEmDialog.close()
            }
        }
    }
    
    // Neutral graphite system - pure greys, no colour cast, in both modes.
    readonly property color colorPrimary: darkMode ? "#e6e6e6" : "#2b2b2b"
    readonly property color colorPrimaryHover: darkMode ? "#ffffff" : "#181818"
    readonly property color colorAccent: darkMode ? "#f0f0f0" : "#141414"
    readonly property color colorBg: darkMode ? "#181818" : "#ececec"
    readonly property color colorSurface: darkMode ? "#212121" : "#f4f4f4"
    readonly property color colorSurfaceHover: darkMode ? "#2b2b2b" : "#e2e2e2"
    readonly property color colorCard: darkMode ? "#1f1f1f" : "#fafafa"
    readonly property color colorCardElevated: darkMode ? "#2c2c2c" : "#ffffff"
    readonly property color colorTextPrimary: darkMode ? "#f5f5f5" : "#171717"
    readonly property color colorTextSecondary: darkMode ? "#cfcfcf" : "#343434"
    readonly property color colorTextMuted: darkMode ? "#8f8f8f" : "#6c6c6c"
    readonly property color colorBorder: darkMode ? "#333333" : "#d4d4d4"
    readonly property color colorBorderLight: darkMode ? "#424242" : "#c4c4c4"
    readonly property color colorSuccess: "#22c55e"
    readonly property color colorWarning: "#f59e0b"
    readonly property color colorError: "#ef4444"
    readonly property color colorInactive: darkMode ? "#767676" : "#8a8a8a"
    
    // Silver gradient colors for axis bars
    readonly property color hudSilver1: darkMode ? "#1c1c1c" : "#3f3f3f"
    readonly property color hudSilver2: darkMode ? "#a6a6a6" : "#9c9c9c"
    readonly property color hudSilver3: darkMode ? "#5f5f5f" : "#6e6e6e"
    
    property var silverGradient: [
        {position: 0.0, color: hudSilver1},
        {position: 0.5, color: hudSilver2},
        {position: 1.0, color: hudSilver3}
    ]
    
    // Session timer display
    property string sessionTimeDisplay: "0:00"
    
    Connections {
        target: backend
        function onSessionTimeChanged(time) {
            sessionTimeDisplay = time
        }
    }
    // Handle close event - close application
    onClosing: function(close) {
        console.log("Closing application...")
        // Stop emulation if running
        if (backend.running) {
            backend.stop_mapping()
        }
        // Save settings before quitting
        backend.force_save_settings()
        trayManager.save_settings()
        close.accepted = true
        Qt.quit()
    }
    
    // Handle window state changes
    onVisibilityChanged: function(visibility) {
        if (visibility === Window.Minimized && trayManager.hideToTrayEnabled) {
            hide()
            trayManager.show_tray()
        }
    }
    
    Rectangle {
        anchors.fill: parent
        color: colorBg
    }
    
    // TAB STATE
    property int currentTab: 0

    Column {
        anchors.fill: parent
        spacing: 0

        // HEADER
        Item {
            width: parent.width
            height: 64

            Row {
                anchors.left: parent.left
                anchors.leftMargin: 28
                anchors.verticalCenter: parent.verticalCenter
                spacing: 12

                Text {
                    text: "TrueAxis"
                    font.pixelSize: 21
                    font.weight: Font.DemiBold
                    font.letterSpacing: 0.2
                    color: colorTextPrimary
                    font.family: "Inter"
                    anchors.verticalCenter: parent.verticalCenter
                }
            }

            Row {
                anchors.right: parent.right
                anchors.rightMargin: 28
                anchors.verticalCenter: parent.verticalCenter
                spacing: 10

                Item {
                    width: 32
                    height: 32
                    anchors.verticalCenter: parent.verticalCenter

                    ToolTip.visible: themeMouse.containsMouse
                    ToolTip.delay: 350
                    ToolTip.text: darkMode ? "Switch to light mode" : "Switch to dark mode"

                    Rectangle {
                        anchors.fill: parent
                        radius: 8
                        color: themeMouse.containsMouse ? colorSurfaceHover : "transparent"
                    }

                    Shape {
                        anchors.centerIn: parent
                        width: 24
                        height: 24
                        scale: 0.8
                        preferredRendererType: Shape.CurveRenderer

                        // Sun rays
                        ShapePath {
                            strokeWidth: 2
                            strokeColor: colorTextSecondary
                            fillColor: "transparent"
                            capStyle: ShapePath.RoundCap
                            PathSvg { path: "M12 1 L12 3 M12 21 L12 23 M4.22 4.22 L5.64 5.64 M18.36 18.36 L19.78 19.78 M1 12 L3 12 M21 12 L23 12 M4.22 19.78 L5.64 18.36 M18.36 5.64 L19.78 4.22" }
                        }

                        // Sun core: outline in dark mode, filled in light mode
                        ShapePath {
                            strokeWidth: 2
                            strokeColor: colorTextSecondary
                            fillColor: darkMode ? "transparent" : colorTextSecondary
                            capStyle: ShapePath.RoundCap
                            PathSvg { path: "M17 12 A5 5 0 1 1 7 12 A5 5 0 1 1 17 12 Z" }
                        }
                    }

                    MouseArea {
                        id: themeMouse
                        anchors.fill: parent
                        hoverEnabled: true
                        cursorShape: Qt.PointingHandCursor
                        onClicked: backend.toggle_theme_mode()
                    }
                }

                StyledButton {
                    id: activateBtn
                    text: backend.running ? "Deactivate" : "Activate"
                    width: 112
                    height: 34
                    primary: !backend.running
                    danger: backend.running
                    fontSize: 12
                    hoverEffect: true
                    anchors.verticalCenter: parent.verticalCenter
                    onClicked: backend.toggle_mapping()
                }
            }

            Rectangle {
                width: parent.width
                height: 1
                anchors.bottom: parent.bottom
                color: colorBorder
            }
        }


        Item { width: 1; height: 6 }

        // TAB BAR - underline style
        Item {
            width: parent.width
            height: 44

            Row {
                id: tabRow
                anchors.left: parent.left
                anchors.leftMargin: 28
                anchors.top: parent.top
                anchors.bottom: parent.bottom
                spacing: 26

                Repeater {
                    model: ["Drive", "Wheel", "Setup"]
                    Item {
                        width: tabLabel.implicitWidth
                        height: tabRow.height

                        Text {
                            id: tabLabel
                            anchors.centerIn: parent
                            text: modelData
                            font.pixelSize: 13
                            font.weight: Font.DemiBold
                            font.family: "Inter"
                            color: currentTab === index ? colorTextPrimary : (tabMouse.containsMouse ? colorTextSecondary : colorTextMuted)
                        }

                        Rectangle {
                            width: parent.width
                            height: 2
                            radius: 1
                            anchors.bottom: parent.bottom
                            color: colorTextPrimary
                            opacity: currentTab === index ? 1 : (tabMouse.containsMouse ? 0.25 : 0)
                        }

                        MouseArea {
                            id: tabMouse
                            anchors.fill: parent
                            cursorShape: Qt.PointingHandCursor
                            hoverEnabled: true
                            onClicked: currentTab = index
                        }
                    }
                }
            }

            // Settings backup, tucked at the right end of the tab strip.
            Row {
                anchors.right: parent.right
                anchors.rightMargin: 22
                anchors.verticalCenter: parent.verticalCenter
                spacing: 4

                IconActionButton {
                    // Arrow up out of a tray - export settings
                    svg: "M21 15 V19 A2 2 0 0 1 19 21 H5 A2 2 0 0 1 3 19 V15 M7 8 L12 3 L17 8 M12 3 V15"
                    active: false
                    tip: "Export settings"
                    onClicked: backend.export_settings_dialog()
                }

                IconActionButton {
                    // Arrow down into a tray - import settings
                    svg: "M21 15 V19 A2 2 0 0 1 19 21 H5 A2 2 0 0 1 3 19 V15 M7 10 L12 15 L17 10 M12 15 V3"
                    active: false
                    tip: "Import settings"
                    onClicked: backend.import_settings_dialog()
                }
            }

            Rectangle {
                width: parent.width
                height: 1
                anchors.bottom: parent.bottom
                color: colorBorder
            }
        }

        Item { width: 1; height: 14 }

        // TAB CONTENT
        Flickable {
            id: tabFlick
            width: parent.width
            Layout.fillHeight: true
            height: parent.height - 164
            contentWidth: width
            contentHeight: tabColumn.implicitHeight
            clip: true
            interactive: contentHeight > height
            boundsBehavior: Flickable.StopAtBounds
            ScrollBar.vertical: ScrollBar {
                id: tabScrollBar
                policy: parent.contentHeight > parent.height ? ScrollBar.AsNeeded : ScrollBar.AlwaysOff
                width: 6
                contentItem: Rectangle {
                    implicitWidth: 6
                    radius: 3
                    color: tabScrollBar.pressed ? colorBorderLight : colorBorder
                    opacity: tabScrollBar.active ? 0.85 : 0.4
                    Behavior on opacity { NumberAnimation { duration: 150 } }
                }
            }

            Connections {
                target: mainWindow
                function onCurrentTabChanged() {
                    tabFlick.contentY = 0
                    tabFadeAnim.restart()
                }
            }

            NumberAnimation {
                id: tabFadeAnim
                target: tabColumn
                property: "opacity"
                from: 0.35
                to: 1.0
                duration: 180
                easing.type: Easing.OutQuad
            }

            Column {
                id: tabColumn
                width: parent.width
                spacing: 0

        
        // ===== TAB 0: DRIVE =====
        // Flat dashboard: selector band up top, bars as the hero, settings rows below.
        SectionCard {
            visible: currentTab === 0
            title: ""
            flat: true
            cardHeight: drivePanel.implicitHeight + 12

            Column {
                id: drivePanel
                spacing: 0
                width: parent.width

                Item {
                    width: parent.width
                    height: 60

                    Row {
                        anchors.left: parent.left
                        anchors.leftMargin: 28
                        anchors.right: driveActions.left
                        anchors.rightMargin: 14
                        anchors.top: parent.top
                        spacing: 12

                        Column {
                            width: (parent.width - 12) / 2
                            spacing: 6

                            Text {
                                text: "Device"
                                font.pixelSize: 10
                                font.weight: Font.DemiBold
                                font.letterSpacing: 0.8
                                font.capitalization: Font.AllUppercase
                                color: colorTextMuted
                                font.family: "Inter"
                            }

                            StyledComboBox {
                                id: deviceCombo
                                width: parent.width
                                model: backend.devices

                                property bool updatingFromBackend: true

                                Component.onCompleted: {
                                    updatingFromBackend = false
                                }

                                Connections {
                                    target: backend
                                    function onCurrentDeviceIndexChanged(index) {
                                        deviceCombo.updatingFromBackend = true
                                        deviceCombo.currentIndex = index
                                        deviceCombo.updatingFromBackend = false
                                    }
                                }

                                onCurrentIndexChanged: {
                                    if (!updatingFromBackend && currentIndex >= 0) {
                                        backend.select_device(currentIndex)
                                    }
                                }
                            }
                        }

                        Column {
                            width: (parent.width - 12) / 2
                            spacing: 6

                            Text {
                                text: "Profile"
                                font.pixelSize: 10
                                font.weight: Font.DemiBold
                                font.letterSpacing: 0.8
                                font.capitalization: Font.AllUppercase
                                color: colorTextMuted
                                font.family: "Inter"
                            }

                            StyledComboBox {
                                id: profileCombo
                                width: parent.width
                                model: backend.profileNames

                                property bool updatingFromBackend: true

                                Component.onCompleted: {
                                    updatingFromBackend = false
                                }

                                Connections {
                                    target: backend
                                    function onCurrentProfileChanged(profileName) {
                                        profileCombo.updatingFromBackend = true
                                        var profileNames = backend.profileNames
                                        for (var i = 0; i < profileNames.length; i++) {
                                            if (profileNames[i] === profileName) {
                                                profileCombo.currentIndex = i
                                                break
                                            }
                                        }
                                        profileCombo.updatingFromBackend = false
                                    }
                                }

                                onCurrentTextChanged: {
                                    if (!updatingFromBackend && currentText !== "") {
                                        backend.select_profile(currentText)
                                    }
                                }
                            }
                        }
                    }

                    Row {
                        id: driveActions
                        anchors.right: parent.right
                        anchors.rightMargin: 22
                        anchors.bottom: parent.bottom
                        anchors.bottomMargin: 8
                        spacing: 4

                        IconActionButton {
                            // Circular arrows - rescan devices
                            svg: "M23 4 L23 10 L17 10 M1 20 L1 14 L7 14 M3.51 9 A9 9 0 0 1 18.36 5.64 L23 10 M1 14 L5.64 18.36 A9 9 0 0 0 20.49 15"
                            active: false
                            tip: "Refresh devices"
                            onClicked: backend.refresh_devices()
                        }

                        IconActionButton {
                            // Eye - open the input inspector
                            svg: "M1 12 C1 12 5 4 12 4 C19 4 23 12 23 12 C23 12 19 20 12 20 C5 20 1 12 1 12 Z M15 12 A3 3 0 1 1 9 12 A3 3 0 1 1 15 12 Z"
                            active: false
                            tip: "Input inspector"
                            onClicked: inspectorDialog.open()
                        }
                    }
                }

                Text {
                    id: profileDesc
                    text: "Select a profile"
                    visible: false
                    font.pixelSize: 10
                    color: colorTextMuted
                    width: parent.width
                    elide: Text.ElideRight
                    font.family: "Inter"
                }

                Item { width: 1; height: 14 }
                Rectangle { width: parent.width; height: 1; color: colorBorder; opacity: 0.6 }
                Item { width: 1; height: 18 }

                Column {
                    x: 28
                    width: parent.width - 56
                    spacing: 10

                    AxisBar {
                        id: steerBar
                        label: "Steer"
                        value: 0
                        direction: 0
                        gradientColors: silverGradient
                    }

                    AxisBar {
                        id: gasBar
                        label: "Gas"
                        value: 0
                        gradientColors: silverGradient
                    }

                    AxisBar {
                        id: brakeBar
                        label: "Brake"
                        value: 0
                        gradientColors: silverGradient
                    }

                    Item { width: 1; height: 8 }

                    Row {
                        anchors.horizontalCenter: parent.horizontalCenter
                        spacing: 24

                        StyledCheckBox {
                            id: invertGasCheckbox
                            text: "Invert Gas"
                            checked: false

                            Component.onCompleted: {
                                checked = backend.invert_gas
                            }

                            onToggled: backend.set_invert_gas(checked)
                        }

                        StyledCheckBox {
                            id: invertBrakeCheckbox
                            text: "Invert Brake"
                            checked: false

                            Component.onCompleted: {
                                checked = backend.invert_brake
                            }

                            onToggled: backend.set_invert_brake(checked)
                        }
                    }
                }

                Item { width: 1; height: 16 }
                Rectangle { width: parent.width; height: 1; color: colorBorder; opacity: 0.6 }
                Item { width: 1; height: 6 }

                Row {
                    width: parent.width
                    spacing: 0

                    Column {
                        width: parent.width
                        spacing: 9

                        Item {
                            width: parent.width
                            height: 34

                            Text {
                                text: "Square input"
                                font.pixelSize: 12
                                color: colorTextSecondary
                                anchors.left: parent.left
                                anchors.leftMargin: 28
                                anchors.verticalCenter: parent.verticalCenter
                                font.family: "Inter"
                            }

                            StyledCheckBox {
                                id: squareCheckbox
                                text: ""
                                checked: false
                                anchors.right: parent.right
                                anchors.rightMargin: 28
                                anchors.verticalCenter: parent.verticalCenter

                                Component.onCompleted: {
                                    checked = backend.square_input
                                    squareSteeringRow.visible = checked
                                }

                                onToggled: {
                                    backend.set_square_input(checked)
                                    squareSteeringRow.visible = checked
                                }
                            }
                        }

                        Row {
                            id: squareSteeringRow
                            spacing: 8
                            x: 28
                            width: parent.width - 56
                            visible: false

                            Rectangle {
                                id: slider
                                width: parent.width - 42
                                height: 22
                                radius: 11
                                color: "transparent"

                                property real from: 0.0
                                property real to: 0.99
                                property real value: 0.5
                                property real stepSize: 0.01

                                Component.onCompleted: {
                                    value = backend.square_area
                                }

                                Rectangle {
                                    anchors.verticalCenter: parent.verticalCenter
                                    width: parent.width
                                    height: 6
                                    radius: 3
                                    color: colorSurfaceHover
                                    border.width: 1
                                    border.color: colorBorder
                                }

                                Rectangle {
                                    anchors.verticalCenter: parent.verticalCenter
                                    width: parent.width * ((parent.value - parent.from) / (parent.to - parent.from))
                                    height: 6
                                    radius: 3
                                    color: colorInactive
                                }

                                Rectangle {
                                    x: Math.max(0, Math.min(parent.width - 14, (parent.width * ((parent.value - parent.from) / (parent.to - parent.from))) - 7))
                                    y: (parent.height - 14) / 2
                                    width: 14
                                    height: 14
                                    radius: 7
                                    color: colorTextPrimary
                                    border.width: 2
                                    border.color: colorBg
                                }

                                MouseArea {
                                    anchors.fill: parent
                                    hoverEnabled: true
                                    cursorShape: Qt.PointingHandCursor
                                    preventStealing: true
                                    propagateComposedEvents: false

                                    function updateValue(x) {
                                        var newValue = slider.from + (x / width) * (slider.to - slider.from)
                                        newValue = Math.max(slider.from, Math.min(slider.to, newValue))
                                        if (slider.stepSize > 0) {
                                            newValue = Math.round(newValue / slider.stepSize) * slider.stepSize
                                        }
                                        if (newValue !== slider.value) {
                                            slider.value = newValue
                                            backend.set_square_area(newValue)
                                        }
                                    }

                                    onPressed: function(mouse) { updateValue(mouse.x) }
                                    onPositionChanged: function(mouse) { if (pressed) updateValue(mouse.x) }
                                }
                            }

                            Text {
                                text: Math.round(slider.value * 100) + "%"
                                font.pixelSize: 10
                                color: colorTextMuted
                                width: 34
                                anchors.verticalCenter: parent.verticalCenter
                                font.family: "Inter"
                            }
                        }

                        Rectangle { width: parent.width; height: 1; color: colorBorder; opacity: 0.6 }

                        Item {
                            width: parent.width
                            height: 34

                            Text {
                                text: "Deadzone"
                                font.pixelSize: 12
                                color: colorTextSecondary
                                anchors.left: parent.left
                                anchors.leftMargin: 28
                                anchors.verticalCenter: parent.verticalCenter
                                font.family: "Inter"
                            }

                            StyledCheckBox {
                                id: deadzoneCheckbox
                                text: ""
                                checked: false
                                anchors.right: parent.right
                                anchors.rightMargin: 28
                                anchors.verticalCenter: parent.verticalCenter

                                Component.onCompleted: {
                                    checked = backend.deadzone_enabled
                                    deadzoneSliderRow.visible = checked
                                }

                                onToggled: {
                                    backend.set_deadzone_enabled(checked)
                                    deadzoneSliderRow.visible = checked
                                }
                            }
                        }

                        Row {
                            id: deadzoneSliderRow
                            spacing: 8
                            x: 28
                            width: parent.width - 56
                            visible: false

                            Rectangle {
                                id: deadzoneSlider
                                width: parent.width - 42
                                height: 22
                                radius: 11
                                color: "transparent"

                                property real from: 0.0
                                property real to: 0.30
                                property real value: 0.0
                                property real stepSize: 0.01

                                Component.onCompleted: {
                                    value = backend.deadzone
                                }

                                Rectangle {
                                    anchors.verticalCenter: parent.verticalCenter
                                    width: parent.width
                                    height: 6
                                    radius: 3
                                    color: colorSurfaceHover
                                    border.width: 1
                                    border.color: colorBorder
                                }

                                Rectangle {
                                    anchors.verticalCenter: parent.verticalCenter
                                    width: parent.width * ((parent.value - parent.from) / (parent.to - parent.from))
                                    height: 6
                                    radius: 3
                                    color: colorInactive
                                }

                                Rectangle {
                                    x: Math.max(0, Math.min(parent.width - 14, (parent.width * ((parent.value - parent.from) / (parent.to - parent.from))) - 7))
                                    y: (parent.height - 14) / 2
                                    width: 14
                                    height: 14
                                    radius: 7
                                    color: colorTextPrimary
                                    border.width: 2
                                    border.color: colorBg
                                }

                                MouseArea {
                                    anchors.fill: parent
                                    hoverEnabled: true
                                    cursorShape: Qt.PointingHandCursor
                                    preventStealing: true
                                    propagateComposedEvents: false

                                    function updateValue(x) {
                                        var newValue = deadzoneSlider.from + (x / width) * (deadzoneSlider.to - deadzoneSlider.from)
                                        newValue = Math.max(deadzoneSlider.from, Math.min(deadzoneSlider.to, newValue))
                                        if (deadzoneSlider.stepSize > 0) {
                                            newValue = Math.round(newValue / deadzoneSlider.stepSize) * deadzoneSlider.stepSize
                                        }
                                        if (newValue !== deadzoneSlider.value) {
                                            deadzoneSlider.value = newValue
                                            backend.set_deadzone(newValue)
                                        }
                                    }

                                    onPressed: function(mouse) { updateValue(mouse.x) }
                                    onPositionChanged: function(mouse) { if (pressed) updateValue(mouse.x) }
                                }
                            }

                            Text {
                                text: Math.round(deadzoneSlider.value * 100) + "%"
                                font.pixelSize: 10
                                color: colorTextMuted
                                width: 34
                                anchors.verticalCenter: parent.verticalCenter
                                font.family: "Inter"
                            }
                        }

                    }
                }
            }
        }

        // CONTROLS - uniform settings list; each row opens its dialog.
        SectionCard {
            visible: currentTab === 2
            title: "Controls"
            flat: true
            cardHeight: controlsColumn.implicitHeight + 12

            Column {
                id: controlsColumn
                spacing: 0
                width: parent.width

                SettingsRow {
                    label: "Button mapping"
                    value: buttonStatus.text
                    onClicked: buttonDialog.open()
                }

                Rectangle { width: parent.width; height: 1; color: colorBorder; opacity: 0.6 }

                SettingsRow {
                    label: "Macros"
                    onClicked: macroManagerDialog.open()
                }

                Rectangle { width: parent.width; height: 1; color: colorBorder; opacity: 0.6 }

                SettingsRow {
                    label: "Record macro"
                    value: backend.macro_recording ? "Recording\\u2026 click to stop" : ""
                    valueColor: backend.macro_recording ? colorError : colorTextMuted
                    rowEnabled: !backend.macro_playing
                    onClicked: {
                        if (backend.macro_recording) {
                            backend.stop_macro_recording()
                        } else {
                            macroNameDialog.open()
                        }
                    }
                }

                Rectangle { width: parent.width; height: 1; color: colorBorder; opacity: 0.6 }

                SettingsRow {
                    label: "Neutral hold"
                    value: backend.neutralSteeringButtonLabel
                    valueColor: backend.neutralSteeringButton >= 0 ? colorTextPrimary : colorTextMuted
                    onClicked: neutralSteeringDialog.open()
                }

                Rectangle { width: parent.width; height: 1; color: colorBorder; opacity: 0.6 }

                SettingsRow {
                    label: "Steering limit"
                    value: backend.steeringActionButton >= 0 ? backend.steeringActionButtonLabel + " - max " + backend.steeringActionMaxPercent + "% - " + (backend.steeringActionMode === "toggle" ? "Toggle" : "Hold") : "Not set"
                    valueColor: backend.steeringActionButton >= 0 ? colorTextPrimary : colorTextMuted
                    onClicked: steeringActionDialog.open()
                }

                Text {
                    id: buttonStatus
                    visible: false
                    text: ""
                }
            }
        }

        // DEVICE SLOTS (Multi-Device) - in Settings tab
        SectionCard {
            visible: currentTab === 2
            title: "Device Slots"
            flat: true
            topDivider: true
            cardHeight: slotsColumn.implicitHeight + 12

            Column {
                id: slotsColumn
                spacing: 8
                width: parent.width

                // Slot info display
                property var slotData: []

                function refreshSlots() {
                    slotData = backend.getSlotInfo()
                }

                Component.onCompleted: refreshSlots()

                Connections {
                    target: backend
                    function onDeviceSlotsChanged() {
                        slotsColumn.refreshSlots()
                    }
                    // Keep the Active/Idle badges honest when emulation starts or stops.
                    function onIsRunningChanged(running) {
                        slotsColumn.refreshSlots()
                    }
                }

                Repeater {
                    model: slotsColumn.slotData.length

                    Rectangle {
                        id: slotRect
                        width: parent.width
                        height: slotRow.implicitHeight + 16
                        color: index === backend.activeSlotIndex ? colorSurface : (slotMouse.containsMouse ? colorSurfaceHover : "transparent")
                        border.width: 0

                        // A slot counts as active while its own emulator runs, or while
                        // the app is emulating on the currently selected slot.
                        property bool slotActive: (slotsColumn.slotData[index] && slotsColumn.slotData[index].running === true)
                                                  || (backend.running && index === backend.activeSlotIndex)

                        Rectangle {
                            width: 3
                            color: colorPrimary
                            visible: index === backend.activeSlotIndex
                            anchors.left: parent.left
                            anchors.top: parent.top
                            anchors.bottom: parent.bottom
                        }

                        MouseArea {
                            id: slotMouse
                            anchors.fill: parent
                            hoverEnabled: true
                            cursorShape: Qt.PointingHandCursor
                            onClicked: backend.setActiveSlot(index)
                        }

                        Row {
                            id: slotRow
                            anchors.fill: parent
                            anchors.margins: 8
                            anchors.leftMargin: 28
                            anchors.rightMargin: 24
                            spacing: 8

                            Column {
                                width: parent.width - 100
                                spacing: 2

                                Text {
                                    text: {
                                        var d = slotsColumn.slotData[index]
                                        var name = d ? String(d.device_name) : ""
                                        return (name !== "" && name !== "No device") ? name : "No device"
                                    }
                                    font.pixelSize: 11
                                    font.weight: Font.DemiBold
                                    color: colorTextPrimary
                                    font.family: "Inter"
                                    elide: Text.ElideRight
                                    width: parent.width
                                }
                                Text {
                                    text: "Slot " + (index + 1) + (slotsColumn.slotData[index] && slotsColumn.slotData[index].profile ? "  \\u00B7  " + slotsColumn.slotData[index].profile : "")
                                    font.pixelSize: 10
                                    color: colorTextMuted
                                    font.family: "Inter"
                                    elide: Text.ElideRight
                                    width: parent.width
                                }
                                Text {
                                    visible: slotsColumn.slotData[index] ? slotsColumn.slotData[index].combined_mode : false
                                    text: "Combined: pedals from " + (slotsColumn.slotData[index] ? slotsColumn.slotData[index].combined_pedal_device : "")
                                    font.pixelSize: 9
                                    color: "#f59e0b"
                                    font.family: "Inter"
                                }
                            }

                            Row {
                                spacing: 10
                                anchors.verticalCenter: parent.verticalCenter

                                Rectangle {
                                    width: 7
                                    height: 7
                                    radius: 3.5
                                    color: slotRect.slotActive ? colorSuccess : colorInactive
                                    anchors.verticalCenter: parent.verticalCenter
                                }

                                Text {
                                    text: slotRect.slotActive ? "Active" : "Idle"
                                    font.pixelSize: 10
                                    color: slotRect.slotActive ? colorTextSecondary : colorTextMuted
                                    anchors.verticalCenter: parent.verticalCenter
                                    font.family: "Inter"
                                }

                                Rectangle {
                                    id: removeSlotButton
                                    width: 22
                                    height: 22
                                    radius: 6
                                    color: removeSlotMouse.containsMouse ? "#26ef4444" : "transparent"
                                    visible: index > 0
                                    opacity: (slotMouse.containsMouse || removeSlotMouse.containsMouse) ? 1.0 : 0.0
                                    anchors.verticalCenter: parent.verticalCenter

                                    Text {
                                        anchors.centerIn: parent
                                        text: "\\u00D7"
                                        font.pixelSize: 13
                                        color: removeSlotMouse.containsMouse ? colorError : colorTextMuted
                                        font.family: "Inter"
                                    }

                                    MouseArea {
                                        id: removeSlotMouse
                                        anchors.fill: parent
                                        hoverEnabled: true
                                        cursorShape: Qt.PointingHandCursor
                                        onClicked: backend.removeDeviceSlot(index)
                                    }
                                }
                            }
                        }
                    }
                }

                Item {
                    width: parent.width
                    height: 36

                    Rectangle {
                        anchors.fill: parent
                        color: addSlotMouse.containsMouse ? colorSurfaceHover : "transparent"
                    }

                    Text {
                        text: "+  Add slot"
                        font.pixelSize: 11
                        color: addSlotMouse.containsMouse ? colorTextSecondary : colorTextMuted
                        anchors.left: parent.left
                        anchors.leftMargin: 28
                        anchors.verticalCenter: parent.verticalCenter
                        font.family: "Inter"
                    }

                    MouseArea {
                        id: addSlotMouse
                        anchors.fill: parent
                        hoverEnabled: true
                        cursorShape: Qt.PointingHandCursor
                        onClicked: backend.addDeviceSlot()
                    }
                }

                Rectangle { width: parent.width; height: 1; color: colorBorder; opacity: 0.6 }

                // Combined Mode toggle (for active slot) - settings-row layout
                Item {
                    width: parent.width
                    height: 33
                    visible: backend.slotCount > 0

                    Text {
                        text: "Combined Mode (separate pedal device)"
                        font.pixelSize: 12
                        color: colorTextSecondary
                        anchors.left: parent.left
                        anchors.leftMargin: 28
                        anchors.verticalCenter: parent.verticalCenter
                        font.family: "Inter"
                    }

                    CheckBox {
                        id: combinedModeCheck
                        anchors.right: parent.right
                        anchors.rightMargin: 28
                        anchors.verticalCenter: parent.verticalCenter
                        width: 32
                        height: 18
                        checked: {
                            var data = slotsColumn.slotData
                            var idx = backend.activeSlotIndex
                            return (data[idx] && data[idx].combined_mode) ? true : false
                        }
                        onCheckedChanged: {
                            backend.setSlotCombinedMode(backend.activeSlotIndex, checked)
                        }

                        indicator: Rectangle {
                            width: 32
                            height: 18
                            radius: 9
                            anchors.verticalCenter: parent.verticalCenter
                            color: combinedModeCheck.checked ? colorPrimary : colorSurfaceHover
                            border.color: combinedModeCheck.checked ? colorPrimary : colorBorder
                            border.width: 1
                            Behavior on color { ColorAnimation { duration: 140 } }
                            Behavior on border.color { ColorAnimation { duration: 140 } }

                            Rectangle {
                                width: 12
                                height: 12
                                radius: 6
                                x: combinedModeCheck.checked ? parent.width - width - 3 : 3
                                anchors.verticalCenter: parent.verticalCenter
                                color: combinedModeCheck.checked ? colorBg : colorTextMuted
                                Behavior on x { NumberAnimation { duration: 140; easing.type: Easing.OutQuad } }
                                Behavior on color { ColorAnimation { duration: 140 } }
                            }
                        }
                    }
                }

                // Pedal device selector (visible in combined mode)
                Item {
                    width: parent.width
                    height: 44
                    visible: {
                        var data = slotsColumn.slotData
                        var idx = backend.activeSlotIndex
                        return (data[idx] && data[idx].combined_mode) ? true : false
                    }

                    Text {
                        text: "Pedal device"
                        font.pixelSize: 12
                        color: colorTextSecondary
                        anchors.left: parent.left
                        anchors.leftMargin: 28
                        anchors.verticalCenter: parent.verticalCenter
                        font.family: "Inter"
                    }

                    StyledComboBox {
                        id: pedalDeviceCombo
                        width: Math.min(300, parent.width - 180)
                        anchors.right: parent.right
                        anchors.rightMargin: 28
                        anchors.verticalCenter: parent.verticalCenter
                        model: backend.devices
                        onCurrentIndexChanged: {
                            if (currentIndex >= 0) {
                                backend.setSlotPedalDevice(backend.activeSlotIndex, currentIndex)
                            }
                        }
                    }
                }
            }
        }

        // STEERING RANGE & CENTER SPRING
        SectionCard {
            visible: currentTab === 1
            title: ""
            flat: true
            cardHeight: steeringRangeColumn.implicitHeight + 24

            Column {
                id: steeringRangeColumn
                spacing: 10
                width: parent.width

                Item { width: 1; height: 2 }

                // Steering Range
                Row {
                    spacing: 10
                    x: 28
                    width: parent.width - 56

                    Text {
                        text: "Steering Range"
                        font.pixelSize: 12
                        color: colorTextSecondary
                        width: 120
                        anchors.verticalCenter: parent.verticalCenter
                        font.family: "Inter"
                    }

                    Rectangle {
                        id: steeringRangeSlider
                        width: parent.width - 120 - 60
                        height: 24
                        radius: 12
                        color: "transparent"

                        property real from: 10
                        property real to: 2520
                        property real value: 900
                        property real stepSize: 10

                        Component.onCompleted: {
                            value = backend.steeringRange
                        }

                        Connections {
                            target: backend
                            function onSteeringRangeChanged(val) {
                                steeringRangeSlider.value = val
                            }
                        }

                        Rectangle {
                            anchors.verticalCenter: parent.verticalCenter
                            width: parent.width
                            height: 6
                            radius: 3
                            color: colorSurfaceHover
                            border.width: 1
                            border.color: colorBorder
                        }

                        Rectangle {
                            anchors.verticalCenter: parent.verticalCenter
                            width: parent.width * ((parent.value - parent.from) / (parent.to - parent.from))
                            height: 6
                            radius: 3
                            color: colorInactive
                        }

                        Rectangle {
                            x: Math.max(0, Math.min(parent.width - 16, (parent.width * ((parent.value - parent.from) / (parent.to - parent.from))) - 8))
                            y: (parent.height - 16) / 2
                            width: 16
                            height: 16
                            radius: 8
                            color: colorTextPrimary
                            border.width: 2
                            border.color: colorBg
                        }

                        MouseArea {
                            anchors.fill: parent
                            hoverEnabled: true
                            cursorShape: Qt.PointingHandCursor
                            preventStealing: true
                            propagateComposedEvents: false

                            function updateValue(x) {
                                var newValue = steeringRangeSlider.from + (x / width) * (steeringRangeSlider.to - steeringRangeSlider.from)
                                newValue = Math.max(steeringRangeSlider.from, Math.min(steeringRangeSlider.to, newValue))
                                if (steeringRangeSlider.stepSize > 0) {
                                    newValue = Math.round(newValue / steeringRangeSlider.stepSize) * steeringRangeSlider.stepSize
                                }
                                if (newValue !== steeringRangeSlider.value) {
                                    steeringRangeSlider.value = newValue
                                    backend.set_steering_range(newValue)
                                }
                            }

                            onPressed: function(mouse) { updateValue(mouse.x) }
                            onPositionChanged: function(mouse) { if (pressed) updateValue(mouse.x) }
                        }
                    }

                    Text {
                        text: steeringRangeSlider.value + " deg"
                        font.pixelSize: 11
                        color: colorTextMuted
                        width: 50
                        anchors.verticalCenter: parent.verticalCenter
                        font.family: "Inter"
                    }
                }

                // Quick presets row
                Row {
                    spacing: 6
                    x: 28
                    width: parent.width - 56

                    Text {
                        text: "Presets:"
                        font.pixelSize: 10
                        color: colorTextMuted
                        font.family: "Inter"
                        anchors.verticalCenter: parent.verticalCenter
                        width: 50
                    }

                    Repeater {
                        model: [270, 360, 540, 720, 900, 1080]

                        Rectangle {
                            width: 54
                            height: 22
                            radius: 6
                            color: steeringRangeSlider.value === modelData ? colorPrimary : colorSurface
                            border.width: 0

                            Text {
                                anchors.centerIn: parent
                                text: modelData + " deg"
                                font.pixelSize: 9
                                font.bold: steeringRangeSlider.value === modelData
                                color: steeringRangeSlider.value === modelData ? colorBg : colorTextMuted
                                font.family: "Inter"
                            }

                            MouseArea {
                                anchors.fill: parent
                                cursorShape: Qt.PointingHandCursor
                                onClicked: {
                                    steeringRangeSlider.value = modelData
                                    backend.set_steering_range(modelData)
                                }
                            }
                        }
                    }
                }

                Text {
                    text: {
                        var deg = steeringRangeSlider.value
                        if (deg <= 270) return "Tight range - very responsive, good for arcade racing"
                        if (deg <= 540) return "Medium range - balanced feel for most games"
                        if (deg <= 900) return "Full range - realistic sim racing feel"
                        if (deg <= 1080) return "Extended range - requires matching wheel hardware support"
                        return "Direct drive range - requires vendor hardware support"
                    }
                    font.pixelSize: 10
                    color: colorTextMuted
                    x: 28
                    width: parent.width - 56
                    wrapMode: Text.WordWrap
                    font.family: "Inter"
                }

                Row {
                    x: 28
                    width: parent.width - 56
                    spacing: 10

                    StyledButton {
                        text: "Hardware Test"
                        width: 132
                        height: 30
                        secondary: true
                        fontSize: 10
                        hoverEffect: true
                        onClicked: hardwareRangeDialog.openWithSnapshot()
                    }

                    Text {
                        visible: false
                        text: ""
                        font.pixelSize: 10
                        color: colorTextMuted
                        width: 0
                        elide: Text.ElideRight
                        anchors.verticalCenter: parent.verticalCenter
                        font.family: "Inter"
                    }
                }

                // Separator
                Rectangle {
                    width: parent.width
                    height: 1
                    color: colorBorder
                    opacity: 0.6
                }

                // Center Spring Strength
                Row {
                    spacing: 10
                    x: 28
                    width: parent.width - 56

                    Text {
                        text: "Center Spring"
                        font.pixelSize: 12
                        color: colorTextSecondary
                        width: 120
                        anchors.verticalCenter: parent.verticalCenter
                        font.family: "Inter"
                    }

                    Rectangle {
                        id: centerSpringSlider
                        width: parent.width - 120 - 50
                        height: 24
                        radius: 12
                        color: "transparent"

                        property real from: 0.0
                        property real to: 1.0
                        property real value: 0.0
                        property real stepSize: 0.01

                        Component.onCompleted: {
                            value = backend.centerSpring
                        }

                        Connections {
                            target: backend
                            function onCenterSpringChanged(val) {
                                centerSpringSlider.value = val
                            }
                        }

                        Rectangle {
                            anchors.verticalCenter: parent.verticalCenter
                            width: parent.width
                            height: 6
                            radius: 3
                            color: colorSurfaceHover
                            border.width: 1
                            border.color: colorBorder
                        }

                        Rectangle {
                            anchors.verticalCenter: parent.verticalCenter
                            width: parent.width * ((parent.value - parent.from) / (parent.to - parent.from))
                            height: 6
                            radius: 3
                            color: colorInactive
                        }

                        Rectangle {
                            x: Math.max(0, Math.min(parent.width - 16, (parent.width * ((parent.value - parent.from) / (parent.to - parent.from))) - 8))
                            y: (parent.height - 16) / 2
                            width: 16
                            height: 16
                            radius: 8
                            color: colorTextPrimary
                            border.width: 2
                            border.color: colorBg
                        }

                        MouseArea {
                            anchors.fill: parent
                            hoverEnabled: true
                            cursorShape: Qt.PointingHandCursor
                            preventStealing: true
                            propagateComposedEvents: false

                            function updateValue(x) {
                                var newValue = centerSpringSlider.from + (x / width) * (centerSpringSlider.to - centerSpringSlider.from)
                                newValue = Math.max(centerSpringSlider.from, Math.min(centerSpringSlider.to, newValue))
                                if (centerSpringSlider.stepSize > 0) {
                                    newValue = Math.round(newValue / centerSpringSlider.stepSize) * centerSpringSlider.stepSize
                                }
                                if (newValue !== centerSpringSlider.value) {
                                    centerSpringSlider.value = newValue
                                    backend.set_center_spring(newValue)
                                }
                            }

                            onPressed: function(mouse) { updateValue(mouse.x) }
                            onPositionChanged: function(mouse) { if (pressed) updateValue(mouse.x) }
                        }
                    }

                    Text {
                        text: Math.round(centerSpringSlider.value * 100) + "%"
                        font.pixelSize: 11
                        color: colorTextMuted
                        width: 40
                        anchors.verticalCenter: parent.verticalCenter
                        font.family: "Inter"
                    }
                }

                Text {
                    visible: centerSpringSlider.value > 0 || backend.centerSpringRamp
                    text: {
                        if (centerSpringSlider.value <= 0 && backend.centerSpringRamp) return "0% in normal range; Soft Ramp builds near lock"
                        if (centerSpringSlider.value <= 0) return ""
                        var pct = Math.round(centerSpringSlider.value * 100)
                        return "Hardware autocenter at " + pct + "%"
                    }
                    font.pixelSize: 10
                    color: colorTextMuted
                    x: 28
                    width: parent.width - 56
                    wrapMode: Text.WordWrap
                    font.family: "Inter"
                }

                // Separator (only meaningful while the soft-ramp rows are shown)
                Rectangle {
                    width: parent.width
                    height: 1
                    color: colorBorder
                    opacity: 0.6
                    visible: false
                }

                // Center Spring Ramp Toggle
                Row {
                    spacing: 10
                    x: 28
                    width: parent.width - 56
                    visible: false

                    Text {
                        text: "Soft Ramp"
                        font.pixelSize: 12
                        color: colorTextSecondary
                        width: 120
                        anchors.verticalCenter: parent.verticalCenter
                        font.family: "Inter"
                    }

                    Rectangle {
                        id: centerSpringRampToggle
                        width: 44
                        height: 24
                        radius: 12
                        state: backend.centerSpringRamp ? "on" : "off"
                        color: state == "on" ? colorPrimary : colorSurface
                        border.color: state == "on" ? colorPrimary : colorBorder
                        border.width: 1
                        anchors.verticalCenter: parent.verticalCenter

                        Component.onCompleted: {
                            state = backend.centerSpringRamp ? "on" : "off"
                        }

                        Connections {
                            target: backend
                            function onCenterSpringRampChanged(val) {
                                centerSpringRampToggle.state = val ? "on" : "off"
                            }
                        }

                        Rectangle {
                            x: centerSpringRampToggle.state == "on" ? 22 : 2
                            y: 2
                            width: 20
                            height: 20
                            radius: 10
                            color: centerSpringRampToggle.state == "on" ? colorBg : colorTextMuted
                            Behavior on x { NumberAnimation { duration: 150 } }
                        }

                        MouseArea {
                            anchors.fill: parent
                            cursorShape: Qt.PointingHandCursor
                            onClicked: {
                                backend.set_center_spring_ramp(!backend.centerSpringRamp)
                            }
                        }
                    }

                    Text {
                        text: backend.centerSpringRamp ? "On" : "Off"
                        font.pixelSize: 11
                        color: colorTextMuted
                        anchors.verticalCenter: parent.verticalCenter
                        font.family: "Inter"
                    }
                }

                // Ramp Width Slider (visible when ramp is enabled)
                Row {
                    spacing: 10
                    x: 28
                    width: parent.width - 56
                    visible: false

                    Text {
                        text: "Ramp Width"
                        font.pixelSize: 12
                        color: colorTextSecondary
                        width: 120
                        anchors.verticalCenter: parent.verticalCenter
                        font.family: "Inter"
                    }

                    Rectangle {
                        id: centerSpringRampWidthSlider
                        width: parent.width - 120 - 50
                        height: 24
                        radius: 12
                        color: "transparent"

                        property real from: 1
                        property real to: 45
                        property real value: 5
                        property real stepSize: 1

                        Component.onCompleted: {
                            value = backend.centerSpringRampWidth
                        }

                        Connections {
                            target: backend
                            function onCenterSpringRampWidthChanged(val) {
                                centerSpringRampWidthSlider.value = val
                            }
                        }

                        Rectangle {
                            anchors.verticalCenter: parent.verticalCenter
                            width: parent.width
                            height: 6
                            radius: 3
                            color: colorSurfaceHover
                            border.width: 1
                            border.color: colorBorder
                        }

                        Rectangle {
                            anchors.verticalCenter: parent.verticalCenter
                            width: parent.width * ((parent.value - parent.from) / (parent.to - parent.from))
                            height: 6
                            radius: 3
                            color: colorInactive
                        }

                        Rectangle {
                            x: Math.max(0, Math.min(parent.width - 16, (parent.width * ((parent.value - parent.from) / (parent.to - parent.from))) - 8))
                            y: (parent.height - 16) / 2
                            width: 16
                            height: 16
                            radius: 8
                            color: colorTextPrimary
                            border.width: 2
                            border.color: colorBg
                        }

                        MouseArea {
                            anchors.fill: parent
                            hoverEnabled: true
                            cursorShape: Qt.PointingHandCursor
                            preventStealing: true
                            propagateComposedEvents: false

                            function updateValue(x) {
                                var newValue = centerSpringRampWidthSlider.from + (x / width) * (centerSpringRampWidthSlider.to - centerSpringRampWidthSlider.from)
                                newValue = Math.max(centerSpringRampWidthSlider.from, Math.min(centerSpringRampWidthSlider.to, newValue))
                                if (centerSpringRampWidthSlider.stepSize > 0) {
                                    newValue = Math.round(newValue / centerSpringRampWidthSlider.stepSize) * centerSpringRampWidthSlider.stepSize
                                }
                                if (newValue !== centerSpringRampWidthSlider.value) {
                                    centerSpringRampWidthSlider.value = newValue
                                    backend.set_center_spring_ramp_width(newValue)
                                }
                            }

                            onPressed: function(mouse) { updateValue(mouse.x) }
                            onPositionChanged: function(mouse) { if (pressed) updateValue(mouse.x) }
                        }
                    }

                    Text {
                        text: centerSpringRampWidthSlider.value + " deg"
                        font.pixelSize: 11
                        color: colorTextMuted
                        width: 40
                        anchors.verticalCenter: parent.verticalCenter
                        font.family: "Inter"
                    }
                }

                // Ramp description
                Text {
                    visible: false
                    text: {
                        if (!backend.centerSpringRamp) return "Disabled - hardware ramp off"
                        var w = centerSpringRampWidthSlider.value
                        var pct = Math.round(centerSpringSlider.value * 100)
                        var sr = steeringRangeSlider.value
                        return "Final " + w + " deg before lock: " + pct + "% \u2192 100% spring"
                    }
                    font.pixelSize: 10
                    color: colorTextMuted
                    width: parent.width
                    wrapMode: Text.WordWrap
                    font.family: "Inter"
                }
            }
        }

        
        // Export status text
        Text {
            id: exportStatusText
            visible: currentTab === 2 && text !== ""
            text: ""
            font.pixelSize: 10
            color: colorSuccess
            font.family: "Inter"
            anchors.horizontalCenter: parent.horizontalCenter

            Connections {
                target: backend
                function onExportStatusChanged(status) {
                    exportStatusText.text = status
                    exportStatusTimer.restart()
                }
            }

            Timer {
                id: exportStatusTimer
                interval: 4000
                onTriggered: exportStatusText.text = ""
            }
        }

        Item { width: 1; height: 20 }

            } // end tabColumn
        } // end Flickable

        // STATUS FOOTER
        Item {
            width: parent.width
            height: 36

            Rectangle {
                width: parent.width
                height: 1
                anchors.top: parent.top
                color: colorBorder
            }

            // Update download progress, drawn along the footer's top edge.
            Rectangle {
                anchors.top: parent.top
                anchors.left: parent.left
                width: parent.width * (inlineUpdateProgress / 100)
                height: 2
                color: colorSuccess
                visible: updateInProgress
                Behavior on width { NumberAnimation { duration: 160; easing.type: Easing.OutQuad } }
            }

            Row {
                anchors.left: parent.left
                anchors.leftMargin: 28
                anchors.right: footerIcons.left
                anchors.rightMargin: 16
                anchors.verticalCenter: parent.verticalCenter
                spacing: 8

                Rectangle {
                    width: 7
                    height: 7
                    radius: 4
                    color: appStatusColor
                    anchors.verticalCenter: parent.verticalCenter
                    Behavior on color { ColorAnimation { duration: 200 } }

                    SequentialAnimation on opacity {
                        running: backend.running
                        loops: Animation.Infinite
                        alwaysRunToEnd: true
                        NumberAnimation { from: 1.0; to: 0.45; duration: 900; easing.type: Easing.InOutSine }
                        NumberAnimation { from: 0.45; to: 1.0; duration: 900; easing.type: Easing.InOutSine }
                    }
                }

                Text {
                    text: appStatusText
                    font.pixelSize: 11
                    font.family: "Inter"
                    color: colorTextSecondary
                    width: parent.width - 15
                    elide: Text.ElideRight
                    anchors.verticalCenter: parent.verticalCenter
                }
            }

            // Quick toggles + update, tucked in the corner with tooltips.
            Row {
                id: footerIcons
                anchors.right: parent.right
                anchors.rightMargin: 20
                anchors.verticalCenter: parent.verticalCenter
                spacing: 4

                Text {
                    text: backend.running ? sessionTimeDisplay : ""
                    font.pixelSize: 10
                    font.family: "Inter"
                    color: colorTextMuted
                    anchors.verticalCenter: parent.verticalCenter
                    rightPadding: 8
                }

                IconActionButton {
                    id: autoStartCheckbox
                    property bool checked: false
                    // Power symbol - start with Windows
                    svg: "M18.36 6.64 A9 9 0 1 1 5.64 6.64 M12 2 L12 11"
                    active: checked
                    tip: "Start with Windows: " + (checked ? "on" : "off")
                    anchors.verticalCenter: parent.verticalCenter
                    onClicked: {
                        checked = !checked
                        backend.set_auto_start(checked)
                    }

                    Connections {
                        target: backend
                        function onAutoStartChanged(enabled) {
                            autoStartCheckbox.checked = enabled
                        }
                    }
                }

                IconActionButton {
                    id: startMinimizedCheckbox
                    property bool checked: false
                    // Window with minimize bar - start minimized
                    svg: "M5 3 H19 A2 2 0 0 1 21 5 V19 A2 2 0 0 1 19 21 H5 A2 2 0 0 1 3 19 V5 A2 2 0 0 1 5 3 Z M8 16 H16"
                    active: checked
                    actionEnabled: autoStartCheckbox.checked
                    tip: autoStartCheckbox.checked
                         ? "Start minimized: " + (checked ? "on" : "off")
                         : "Start minimized (needs Start with Windows)"
                    anchors.verticalCenter: parent.verticalCenter
                    onClicked: {
                        checked = !checked
                        backend.set_start_minimized(checked)
                    }

                    Connections {
                        target: backend
                        function onStartMinimizedChanged(enabled) {
                            startMinimizedCheckbox.checked = enabled
                        }
                    }
                }

                IconActionButton {
                    id: hideToTrayCheckbox
                    property bool checked: false
                    // Tray / inbox - minimize to tray
                    svg: "M22 12 H16 L14 15 H10 L8 12 H2 M5.45 5.11 L2 12 V18 A2 2 0 0 0 4 20 H20 A2 2 0 0 0 22 18 V12 L18.55 5.11 A2 2 0 0 0 16.76 4 H7.24 A2 2 0 0 0 5.45 5.11 Z"
                    active: checked
                    actionEnabled: trayManager.trayAvailable
                    tip: "Minimize to tray: " + (checked ? "on" : "off")
                    anchors.verticalCenter: parent.verticalCenter
                    onClicked: {
                        checked = !checked
                        trayManager.set_hide_to_tray(checked)
                    }

                    Connections {
                        target: trayManager
                        function onHideToTrayEnabledChanged(enabled) {
                            hideToTrayCheckbox.checked = enabled
                        }
                    }
                }

                Rectangle {
                    width: 1
                    height: 16
                    color: colorBorder
                    anchors.verticalCenter: parent.verticalCenter
                    visible: updateAvailable || updatePreview
                }

                IconActionButton {
                    // Arrow-down-in-circle - update available
                    svg: "M22 12 A10 10 0 1 1 2 12 A10 10 0 1 1 22 12 Z M8 12 L12 16 L16 12 M12 8 L12 16"
                    visible: (updateAvailable || updatePreview) && !updateInProgress
                    glyphColor: colorSuccess
                    tip: "Update v" + (updatePreview ? "9.9.9" : latestVersion) + " available - click to install"
                    anchors.verticalCenter: parent.verticalCenter
                    onClicked: {
                        if (updatePreview) {
                            updateDemoTimer.start()
                        } else {
                            backend.install_update()
                        }
                    }
                }
            }
        }
    } // end main Column

    // Preview mode for the update flow (Ctrl+U): shows the footer update
    // icon and, when clicked, plays a fake download so the styling can be
    // judged without a real release.
    Shortcut {
        sequence: "Ctrl+U"
        onActivated: {
            updatePreview = !updatePreview
            if (!updatePreview) {
                updateDemoTimer.stop()
                updateInProgress = false
                inlineUpdateProgress = 0
                statusRevertTimer.restart()
            }
        }
    }

    Timer {
        id: updateDemoTimer
        interval: 50
        repeat: true
        onTriggered: {
            updateInProgress = true
            inlineUpdateProgress = Math.min(100, inlineUpdateProgress + 1)
            appStatusText = "Downloading update v9.9.9 (preview)... " + inlineUpdateProgress + "%"
            appStatusColor = colorSuccess
            if (inlineUpdateProgress >= 100) {
                stop()
                appStatusText = "Update downloaded - restarting to apply (preview)"
                updateInProgress = false
                inlineUpdateProgress = 0
                statusRevertTimer.restart()
            }
        }
    }

    // HARDWARE RANGE TEST DIALOG
    Dialog {
        id: hardwareRangeDialog
        width: Math.min(mainWindow.width - 40, 560)
        height: Math.min(mainWindow.height - 44, 590)
        anchors.centerIn: parent
        modal: true
        title: "Hardware Range Test"

        property var snapshot: ({})
        property var testResults: []
        property int pendingDegrees: 0
        property string copyStatus: ""
        property color copyStatusColor: colorSuccess

        function parseResult(raw) {
            try {
                return JSON.parse(raw)
            } catch (err) {
                return { ok: false, status: "Could not read hardware test result" }
            }
        }

        function refreshSnapshot() {
            snapshot = parseResult(backend.hardware_range_snapshot_json())
        }

        function openWithSnapshot() {
            refreshSnapshot()
            open()
        }

        function runRange(deg) {
            copyStatus = ""
            copyStatusColor = colorSuccess
            var result = parseResult(backend.run_hardware_range_test_json(deg))
            pendingDegrees = deg
            testResults = [result].concat(testResults)
            refreshSnapshot()
        }

        function runModeSwitch() {
            copyStatus = ""
            copyStatusColor = colorSuccess
            var result = parseResult(backend.run_g923_mode_switch_test_json())
            testResults = [result].concat(testResults)
            refreshSnapshot()
        }

        function confirmLock(answer) {
            if (pendingDegrees <= 0) return
            var result = parseResult(backend.record_hardware_range_confirmation_json(pendingDegrees, answer))
            testResults = [result].concat(testResults)
            pendingDegrees = 0
            copyStatus = "Confirmation saved"
        }

        function copyReport() {
            var result = parseResult(backend.copy_hardware_range_report_json())
            copyStatus = result.status || "Report copied"
        }

        function sendReport() {
            var result = parseResult(backend.submit_hardware_range_report_json())
            copyStatus = result.status || "Sending hardware report..."
            copyStatusColor = colorTextMuted
        }

        Connections {
            target: backend
            function onHardwareReportStatusChanged(status, color) {
                if (hardwareRangeDialog.visible) {
                    hardwareRangeDialog.copyStatus = status
                    hardwareRangeDialog.copyStatusColor = color
                }
            }
        }

        background: Rectangle {
            color: colorBg
            radius: 12
            border.color: colorBorder
            border.width: 1
        }

        header: Item {
            height: 72

            Column {
                anchors.left: parent.left
                anchors.leftMargin: 22
                anchors.verticalCenter: parent.verticalCenter
                spacing: 5

                Text {
                    text: "Hardware Range Test"
                    font.pixelSize: 15
                    font.weight: Font.DemiBold
                    color: colorTextPrimary
                    font.family: "Inter"
                }

                Text {
                    text: "Checks the real wheel lock, not software scaling"
                    font.pixelSize: 11
                    color: colorTextMuted
                    font.family: "Inter"
                }
            }
        }

        contentItem: ScrollView {
            clip: true

            Column {
                width: hardwareRangeDialog.width - 44
                spacing: 12

                Rectangle {
                    width: parent.width
                    height: 96
                    radius: 8
                    color: colorCard
                    border.color: colorBorder
                    border.width: 1

                    Column {
                        anchors.fill: parent
                        anchors.margins: 14
                        spacing: 7

                        Row {
                            width: parent.width
                            spacing: 8

                            Text {
                                text: "Selected"
                                width: 72
                                font.pixelSize: 10
                                font.bold: true
                                color: colorTextMuted
                                font.family: "Inter"
                            }

                            Text {
                                text: hardwareRangeDialog.snapshot.selectedDevice || "No device selected"
                                width: parent.width - 80
                                elide: Text.ElideRight
                                font.pixelSize: 11
                                color: colorTextPrimary
                                font.family: "Inter"
                            }
                        }

                        Row {
                            width: parent.width
                            spacing: 8

                            Text {
                                text: "Provider"
                                width: 72
                                font.pixelSize: 10
                                font.bold: true
                                color: colorTextMuted
                                font.family: "Inter"
                            }

                            Text {
                                text: {
                                    var protocols = hardwareRangeDialog.snapshot.protocols || []
                                    if (protocols.length > 0) return protocols.join(", ")
                                    return hardwareRangeDialog.snapshot.provider || "diagnostic only"
                                }
                                width: parent.width - 80
                                elide: Text.ElideRight
                                font.pixelSize: 11
                                color: hardwareRangeDialog.snapshot.supported ? colorSuccess : colorWarning
                                font.bold: true
                                font.family: "Inter"
                            }
                        }

                        Text {
                            text: hardwareRangeDialog.snapshot.message || ""
                            width: parent.width
                            elide: Text.ElideRight
                            font.pixelSize: 10
                            color: colorTextMuted
                            font.family: "Inter"
                        }

                        Text {
                            text: {
                                var diagnostics = hardwareRangeDialog.snapshot.diagnosticHidCandidates || []
                                var reason = hardwareRangeDialog.snapshot.supportReason || ""
                                return diagnostics.length > 0 ? (diagnostics.length + " HID endpoints captured - " + reason) : reason
                            }
                            width: parent.width
                            elide: Text.ElideRight
                            font.pixelSize: 10
                            color: colorTextMuted
                            font.family: "Inter"
                        }
                    }
                }

                Rectangle {
                    width: parent.width
                    height: 102
                    radius: 8
                    color: colorCard
                    border.color: colorBorder
                    border.width: 1

                    Column {
                        anchors.fill: parent
                        anchors.margins: 14
                        spacing: 10

                        Text {
                            text: "Test Range"
                            font.pixelSize: 12
                            font.bold: true
                            color: colorTextPrimary
                            font.family: "Inter"
                        }

                        Row {
                            width: parent.width
                            spacing: 8

                            StyledButton {
                                text: "Set 270"
                                width: (parent.width - 16) / 3
                                height: 32
                                primary: true
                                fontSize: 11
                                hoverEffect: true
                                enabled: hardwareRangeDialog.snapshot.supported === true
                                onClicked: hardwareRangeDialog.runRange(270)
                            }

                            StyledButton {
                                text: "Set 900"
                                width: (parent.width - 16) / 3
                                height: 32
                                primary: true
                                fontSize: 11
                                hoverEffect: true
                                enabled: hardwareRangeDialog.snapshot.supported === true
                                onClicked: hardwareRangeDialog.runRange(900)
                            }

                            StyledButton {
                                text: "Refresh"
                                width: (parent.width - 16) / 3
                                height: 32
                                secondary: true
                                fontSize: 11
                                hoverEffect: true
                                onClicked: hardwareRangeDialog.refreshSnapshot()
                            }
                        }

                        StyledButton {
                            text: "Switch G923 Mode"
                            width: parent.width
                            height: 30
                            secondary: true
                            fontSize: 10
                            hoverEffect: true
                            visible: {
                                var protocols = hardwareRangeDialog.snapshot.protocols || []
                                return protocols.indexOf("g923_ps_mode") >= 0
                            }
                            onClicked: hardwareRangeDialog.runModeSwitch()
                        }
                    }
                }

                Rectangle {
                    width: parent.width
                    height: hardwareRangeDialog.pendingDegrees > 0 ? 86 : 0
                    radius: 8
                    color: colorSurface
                    border.color: colorBorder
                    border.width: hardwareRangeDialog.pendingDegrees > 0 ? 1 : 0
                    visible: hardwareRangeDialog.pendingDegrees > 0
                    clip: true

                    Column {
                        anchors.fill: parent
                        anchors.margins: 12
                        spacing: 10

                        Text {
                            text: "Did the real wheel lock change near " + hardwareRangeDialog.pendingDegrees + " degrees?"
                            width: parent.width
                            elide: Text.ElideRight
                            font.pixelSize: 11
                            font.bold: true
                            color: colorTextPrimary
                            font.family: "Inter"
                        }

                        Row {
                            width: parent.width
                            spacing: 8

                            StyledButton {
                                text: "Yes"
                                width: (parent.width - 16) / 3
                                height: 30
                                primary: true
                                fontSize: 11
                                hoverEffect: true
                                onClicked: hardwareRangeDialog.confirmLock("yes")
                            }

                            StyledButton {
                                text: "No"
                                width: (parent.width - 16) / 3
                                height: 30
                                danger: true
                                fontSize: 11
                                hoverEffect: true
                                onClicked: hardwareRangeDialog.confirmLock("no")
                            }

                            StyledButton {
                                text: "Skip"
                                width: (parent.width - 16) / 3
                                height: 30
                                secondary: true
                                fontSize: 11
                                hoverEffect: true
                                onClicked: hardwareRangeDialog.confirmLock("skip")
                            }
                        }
                    }
                }

                Rectangle {
                    width: parent.width
                    height: Math.max(92, Math.min(210, 44 + hardwareRangeDialog.testResults.length * 48))
                    radius: 8
                    color: colorCard
                    border.color: colorBorder
                    border.width: 1

                    Column {
                        anchors.fill: parent
                        anchors.margins: 12
                        spacing: 8

                        Row {
                            width: parent.width
                            spacing: 8

                            Text {
                                text: "Results"
                                width: parent.width - 224
                                font.pixelSize: 12
                                font.bold: true
                                color: colorTextPrimary
                                font.family: "Inter"
                            }

                            StyledButton {
                                text: "Send Report"
                                width: 104
                                height: 26
                                primary: true
                                fontSize: 10
                                hoverEffect: true
                                onClicked: hardwareRangeDialog.sendReport()
                            }

                            StyledButton {
                                text: "Copy Report"
                                width: 104
                                height: 26
                                secondary: true
                                fontSize: 10
                                hoverEffect: true
                                onClicked: hardwareRangeDialog.copyReport()
                            }
                        }

                        Text {
                            visible: hardwareRangeDialog.testResults.length === 0
                            text: "Run 270 and 900, then confirm what the wheel physically did."
                            width: parent.width
                            wrapMode: Text.WordWrap
                            font.pixelSize: 10
                            color: colorTextMuted
                            font.family: "Inter"
                        }

                        Repeater {
                            model: hardwareRangeDialog.testResults.slice(0, 3)

                            Rectangle {
                                width: parent.width
                                height: 40
                                radius: 6
                                color: colorSurface
                                border.color: modelData.ok ? colorSuccess : colorBorder
                                border.width: 1

                                Row {
                                    anchors.fill: parent
                                    anchors.margins: 9
                                    spacing: 8

                                    Rectangle {
                                        width: 7
                                        height: 7
                                        radius: 4
                                        color: modelData.ok ? colorSuccess : colorWarning
                                        anchors.verticalCenter: parent.verticalCenter
                                    }

                                    Text {
                                        text: modelData.status || "No status"
                                        width: parent.width - 20
                                        elide: Text.ElideRight
                                        font.pixelSize: 10
                                        color: colorTextSecondary
                                        font.family: "Inter"
                                        anchors.verticalCenter: parent.verticalCenter
                                    }
                                }
                            }
                        }

                        Text {
                            visible: hardwareRangeDialog.copyStatus !== ""
                            text: hardwareRangeDialog.copyStatus
                            width: parent.width
                            elide: Text.ElideRight
                            font.pixelSize: 10
                            color: hardwareRangeDialog.copyStatusColor
                            font.family: "Inter"
                        }
                    }
                }

                Item { width: 1; height: 6 }
            }
        }

        footer: DialogButtonBox {
            padding: 16
            background: Rectangle {
                color: colorBg
                border.color: colorBorder
                border.width: 1
            }

            StyledButton {
                text: "Close"
                secondary: true
                width: 120
                height: 32
                hoverEffect: true
                onClicked: hardwareRangeDialog.close()
            }
        }
    }

    // INPUT INSPECTOR DIALOG
    Dialog {
        id: inspectorDialog
        width: 420
        height: 550
        anchors.centerIn: parent
        modal: true
        title: "Input Inspector"
        
        background: Rectangle {
        color: colorBg
        radius: 12
        border.color: colorBorder
        border.width: 1
        }
        
        header: Item {
        height: 70
        
        Column {
            anchors.left: parent.left
            anchors.leftMargin: 20
            anchors.top: parent.top
            anchors.topMargin: 18
            spacing: 6
            
            Text {
                text: "Input Inspector"
                font.pixelSize: 15
                font.weight: Font.DemiBold
                color: colorTextPrimary
                font.family: "Inter"
            }
            
            Text {
                text: "Identify axis IDs by moving controls"
                font.pixelSize: 12
                color: colorTextMuted
                font.family: "Inter"
            }
        }
        }
        
        contentItem: ScrollView {
        clip: true
        
        Column {
            spacing: 6
            width: parent.width
            
            Repeater {
                model: backend.numAxes
                
                Rectangle {
                    width: 380
                    height: 50
                    radius: 8
                    color: colorCard
                    border.color: colorBorder
                    border.width: 1
                    
                    property int axisIndex: index
                    property real axisValue: 0.5
                    
                    Component.onCompleted: {
                        axisValue = backend.getAxisValue(index)
                    }
                    
                    Connections {
                        target: backend
                        function onAxisValuesChanged() {
                            axisValue = backend.getAxisValue(axisIndex)
                        }
                    }
                    
                    Row {
                        anchors.fill: parent
                        anchors.margins: 12
                        spacing: 0
                        
                        Text {
                            text: "Axis " + parent.parent.axisIndex
                            font.pixelSize: 11
                            font.bold: true
                            color: colorTextSecondary
                            width: 50
                            anchors.verticalCenter: parent.verticalCenter
                            font.family: "Inter"
                        }
                        
                        Item {
                            width: parent.width - 50 - 55
                            height: parent.height
                            anchors.verticalCenter: parent.verticalCenter
                            
                            Rectangle {
                                id: axisBarBg
                                anchors.fill: parent
                                anchors.leftMargin: 5
                                anchors.rightMargin: 5
                                height: 8
                                radius: 4
                                color: colorSurface
                                anchors.verticalCenter: parent.verticalCenter
                                clip: true
                                
                                Rectangle {
                                    id: axisBarFill
                                    width: axisBarBg.width * Math.max(0, Math.min(1, axisValue))
                                    height: parent.height
                                    radius: 4
                                    
                                    gradient: Gradient {
                                        orientation: Gradient.Horizontal
                                        GradientStop { position: 0.0; color: hudSilver1 }
                                        GradientStop { position: 0.5; color: hudSilver2 }
                                        GradientStop { position: 1.0; color: hudSilver3 }
                                    }
                                    
                                    Behavior on width {
                                        NumberAnimation { duration: 50; easing.type: Easing.OutQuad }
                                    }
                                }
                            }
                        }
                        
                        Text {
                            id: axisValueText
                            text: Math.round(Math.max(0, Math.min(1, axisValue)) * 100) + "%"
                            font.pixelSize: 10
                            color: colorTextMuted
                            width: 45
                            anchors.verticalCenter: parent.verticalCenter
                            horizontalAlignment: Text.AlignRight
                            font.family: "Inter"
                        }
                    }
                }
            }
        }
        }
        
        footer: DialogButtonBox {
        padding: 16
        background: Rectangle {
            color: colorBg
            border.color: colorBorder
            border.width: 1
        }
        
        StyledButton {
            text: "Close"
            secondary: true
            hoverEffect: true
            onClicked: inspectorDialog.close()
        }
        }
    }
    
    Dialog {
        id: neutralSteeringDialog
        width: 420
        height: 244
        anchors.centerIn: parent
        modal: true

        property var capturedButtons: []

        background: Rectangle {
            color: colorBg
            radius: 12
            border.color: colorBorderLight
            border.width: 1
        }

        header: Item {
            width: parent.width
            height: 52

            Text {
                anchors.left: parent.left
                anchors.leftMargin: 20
                anchors.verticalCenter: parent.verticalCenter
                text: "Neutral Steering Button"
                font.pixelSize: 15
                font.weight: Font.DemiBold
                color: colorTextPrimary
                font.family: "Inter"
            }
        }

        contentItem: Column {
            width: neutralSteeringDialog.width - 40
            spacing: 14
            anchors.margins: 20

            Text {
                text: "Hold this button to force steering output to center."
                font.pixelSize: 12
                color: colorTextSecondary
                width: parent.width
                wrapMode: Text.WordWrap
                font.family: "Inter"
            }

            Rectangle {
                width: parent.width
                height: 64
                radius: 8
                color: colorCard
                border.width: 1
                border.color: neutralSteeringDialog.capturedButtons.length > 0 ? colorSuccess : colorBorder

                Text {
                    anchors.centerIn: parent
                    text: neutralSteeringDialog.capturedButtons.length > 0 ?
                          "Captured: " + backend.get_button_name(neutralSteeringDialog.capturedButtons[0]) :
                          "Press the physical button now"
                    font.pixelSize: 13
                    font.weight: Font.DemiBold
                    color: neutralSteeringDialog.capturedButtons.length > 0 ? colorSuccess : colorTextMuted
                    font.family: "Inter"
                }
            }

            Row {
                width: parent.width
                spacing: 8

                StyledButton {
                    text: "Cancel"
                    width: (parent.width - 16) / 3
                    height: 38
                    secondary: true
                    hoverEffect: true
                    onClicked: neutralSteeringDialog.close()
                }

                StyledButton {
                    text: "Clear"
                    width: (parent.width - 16) / 3
                    height: 38
                    secondary: true
                    hoverEffect: true
                    enabled: backend.neutralSteeringButton >= 0
                    onClicked: {
                        backend.clear_neutral_steering_button()
                        neutralSteeringDialog.close()
                    }
                }

                StyledButton {
                    text: "Use Button"
                    width: (parent.width - 16) / 3
                    height: 38
                    primary: true
                    hoverEffect: true
                    enabled: neutralSteeringDialog.capturedButtons.length > 0
                    onClicked: {
                        backend.set_neutral_steering_button(neutralSteeringDialog.capturedButtons[0])
                        neutralSteeringDialog.close()
                    }
                }
            }
        }

        Timer {
            interval: 50
            repeat: true
            running: neutralSteeringDialog.visible
            onTriggered: {
                var pressed = backend.get_pressed_buttons()
                if (pressed.length > 0) {
                    neutralSteeringDialog.capturedButtons = [pressed[0]]
                }
            }
        }

        onOpened: capturedButtons = []
    }

    Dialog {
        id: steeringActionDialog
        width: 460
        height: 352
        anchors.centerIn: parent
        modal: true

        property var capturedButtons: []
        property string selectedMode: "hold"
        property int selectedMax: 0

        background: Rectangle {
            color: colorBg
            radius: 12
            border.color: colorBorderLight
            border.width: 1
        }

        header: Item {
            width: parent.width
            height: 52

            Text {
                anchors.left: parent.left
                anchors.leftMargin: 20
                anchors.verticalCenter: parent.verticalCenter
                text: "Steering Limit"
                font.pixelSize: 15
                font.weight: Font.DemiBold
                color: colorTextPrimary
                font.family: "Inter"
            }
        }

        contentItem: Column {
            width: steeringActionDialog.width - 40
            spacing: 14
            anchors.margins: 20

            Text {
                text: "Set a physical button that limits virtual steering while active."
                font.pixelSize: 12
                color: colorTextSecondary
                width: parent.width
                wrapMode: Text.WordWrap
                font.family: "Inter"
            }

            Rectangle {
                width: parent.width
                height: 58
                radius: 8
                color: colorCard
                border.width: 1
                border.color: steeringActionDialog.capturedButtons.length > 0 ? colorSuccess : colorBorder

                Text {
                    anchors.centerIn: parent
                    text: steeringActionDialog.capturedButtons.length > 0 ?
                          "Captured: " + backend.get_button_name(steeringActionDialog.capturedButtons[0]) :
                          "Press the physical button now"
                    font.pixelSize: 13
                    font.weight: Font.DemiBold
                    color: steeringActionDialog.capturedButtons.length > 0 ? colorSuccess : colorTextMuted
                    font.family: "Inter"
                }
            }

            Rectangle {
                width: parent.width
                height: 34
                radius: 8
                color: colorSurface
                border.width: 1
                border.color: colorBorder

                Row {
                    anchors.fill: parent
                    anchors.margins: 3
                    spacing: 3

                    Repeater {
                        model: [["Hold", "hold"], ["Toggle", "toggle"]]
                        Rectangle {
                            width: (parent.width - 3) / 2
                            height: parent.height
                            radius: 6
                            color: steeringActionDialog.selectedMode === modelData[1] ? colorCardElevated : (segMouse.containsMouse ? colorSurfaceHover : "transparent")
                            border.width: steeringActionDialog.selectedMode === modelData[1] ? 1 : 0
                            border.color: colorBorderLight

                            Text {
                                anchors.centerIn: parent
                                text: modelData[0]
                                font.pixelSize: 11
                                font.weight: Font.DemiBold
                                color: steeringActionDialog.selectedMode === modelData[1] ? colorTextPrimary : colorTextMuted
                                font.family: "Inter"
                            }

                            MouseArea {
                                id: segMouse
                                anchors.fill: parent
                                hoverEnabled: true
                                cursorShape: Qt.PointingHandCursor
                                onClicked: steeringActionDialog.selectedMode = modelData[1]
                            }
                        }
                    }
                }
            }

            Column {
                width: parent.width
                spacing: 8

                Row {
                    width: parent.width
                    height: 22

                    Text {
                        text: "Max steering"
                        font.pixelSize: 11
                        font.weight: Font.DemiBold
                        color: colorTextSecondary
                        width: parent.width - 54
                        anchors.verticalCenter: parent.verticalCenter
                        font.family: "Inter"
                    }

                    Text {
                        text: steeringActionDialog.selectedMax + "%"
                        font.pixelSize: 11
                        color: colorTextMuted
                        width: 54
                        horizontalAlignment: Text.AlignRight
                        anchors.verticalCenter: parent.verticalCenter
                        font.family: "Inter"
                    }
                }

                Rectangle {
                    id: steeringActionSlider
                    width: parent.width
                    height: 24
                    radius: 12
                    color: "transparent"

                    property real from: 0
                    property real to: 100
                    property real value: steeringActionDialog.selectedMax
                    property real stepSize: 1

                    Rectangle {
                        anchors.verticalCenter: parent.verticalCenter
                        width: parent.width
                        height: 6
                        radius: 3
                        color: colorSurfaceHover
                        border.width: 1
                        border.color: colorBorder
                    }

                    Rectangle {
                        anchors.verticalCenter: parent.verticalCenter
                        width: parent.width * ((parent.value - parent.from) / (parent.to - parent.from))
                        height: 6
                        radius: 3
                        color: colorInactive
                    }

                    Rectangle {
                        x: Math.max(0, Math.min(parent.width - 16, (parent.width * ((parent.value - parent.from) / (parent.to - parent.from))) - 8))
                        y: (parent.height - 16) / 2
                        width: 16
                        height: 16
                        radius: 8
                        color: colorTextPrimary
                        border.width: 2
                        border.color: colorBg
                    }

                    MouseArea {
                        anchors.fill: parent
                        hoverEnabled: true
                        cursorShape: Qt.PointingHandCursor
                        preventStealing: true
                        propagateComposedEvents: false

                        function updateValue(x) {
                            var newValue = steeringActionSlider.from + (x / width) * (steeringActionSlider.to - steeringActionSlider.from)
                            newValue = Math.max(steeringActionSlider.from, Math.min(steeringActionSlider.to, newValue))
                            if (steeringActionSlider.stepSize > 0) {
                                newValue = Math.round(newValue / steeringActionSlider.stepSize) * steeringActionSlider.stepSize
                            }
                            steeringActionDialog.selectedMax = Math.round(newValue)
                        }

                        onPressed: function(mouse) { updateValue(mouse.x) }
                        onPositionChanged: function(mouse) { if (pressed) updateValue(mouse.x) }
                    }
                }
            }

            Row {
                width: parent.width
                spacing: 8

                StyledButton {
                    text: "Cancel"
                    width: (parent.width - 16) / 3
                    height: 38
                    secondary: true
                    hoverEffect: true
                    onClicked: steeringActionDialog.close()
                }

                StyledButton {
                    text: "Clear"
                    width: (parent.width - 16) / 3
                    height: 38
                    secondary: true
                    hoverEffect: true
                    enabled: backend.steeringActionButton >= 0
                    onClicked: {
                        backend.clear_steering_action_button()
                        steeringActionDialog.close()
                    }
                }

                StyledButton {
                    text: "Save"
                    width: (parent.width - 16) / 3
                    height: 38
                    primary: true
                    hoverEffect: true
                    enabled: steeringActionDialog.capturedButtons.length > 0 || backend.steeringActionButton >= 0
                    onClicked: {
                        if (steeringActionDialog.capturedButtons.length > 0) {
                            backend.set_steering_action_button(steeringActionDialog.capturedButtons[0])
                        }
                        backend.set_steering_action_max_percent(steeringActionDialog.selectedMax)
                        backend.set_steering_action_mode(steeringActionDialog.selectedMode)
                        steeringActionDialog.close()
                    }
                }
            }
        }

        Timer {
            interval: 50
            repeat: true
            running: steeringActionDialog.visible
            onTriggered: {
                var pressed = backend.get_pressed_buttons()
                if (pressed.length > 0) {
                    steeringActionDialog.capturedButtons = [pressed[0]]
                }
            }
        }

        onOpened: {
            capturedButtons = backend.steeringActionButton >= 0 ? [backend.steeringActionButton] : []
            selectedMode = backend.steeringActionMode
            selectedMax = backend.steeringActionMaxPercent
        }
    }

    // BUTTON MAPPING DIALOG
    Dialog {
        id: buttonDialog
        width: 480
        height: 550
        anchors.centerIn: parent
        modal: true
        title: "Button Mapping"
        
        background: Rectangle {
        color: colorBg
        radius: 12
        border.color: colorBorder
        border.width: 1
        }
        
        header: Item {
        height: 70
        
        Column {
            anchors.left: parent.left
            anchors.leftMargin: 20
            anchors.top: parent.top
            anchors.topMargin: 18
            spacing: 6
            
            Text {
                text: "Button Mapping"
                font.pixelSize: 15
                font.weight: Font.DemiBold
                color: colorTextPrimary
                font.family: "Inter"
            }
            
            Text {
                text: backend.numButtons + " buttons available"
                font.pixelSize: 12
                color: colorTextMuted
                font.family: "Inter"
            }
        }
        }
        
        contentItem: Column {
        spacing: 8
        
        ScrollView {
            width: parent.width
            height: 390
            clip: true
            
            Column {
                spacing: 2
                width: parent.width
                
                Repeater {
                    model: backend.numButtons
                    
                    Rectangle {
                        width: 440
                        height: 44
                        radius: 6
                        color: colorCard
                        border.color: colorBorder
                        border.width: 1
                        
                        property int buttonIndex: index
                        property bool buttonPressed: false
                        
                        Component.onCompleted: {
                            buttonPressed = backend.getButtonState(index)
                        }
                        
                        Connections {
                            target: backend
                            function onButtonStatesChanged() {
                                buttonPressed = backend.getButtonState(buttonIndex)
                            }
                        }
                        
                        Row {
                            anchors.centerIn: parent
                            spacing: 10
                            width: parent.width - 20
                            
                            Rectangle {
                                width: 18
                                height: 18
                                radius: 9
                                color: parent.parent.buttonPressed ? colorAccent : colorInactive
                                border.color: parent.parent.buttonPressed ? colorPrimary : colorBorder
                                border.width: 2
                                anchors.verticalCenter: parent.verticalCenter
                                
                                Rectangle {
                                    anchors.centerIn: parent
                                    width: 6
                                    height: 6
                                    radius: 3
                                    color: parent.parent.parent.buttonPressed ? colorBg : "transparent"
                                    visible: parent.parent.parent.buttonPressed
                                }
                                
                                Behavior on color {
                                    ColorAnimation { duration: 100 }
                                }
                                
                                Behavior on border.color {
                                    ColorAnimation { duration: 100 }
                                }
                            }
                            
                            Text {
                                text: "BTN " + parent.parent.buttonIndex
                                font.pixelSize: 12
                                font.weight: Font.Medium
                                color: colorTextSecondary
                                width: 55
                                anchors.verticalCenter: parent.verticalCenter
                                font.family: "Inter"
                            }
                            
                            Item { width: 1; Layout.fillWidth: true }
                            
                            StyledComboBox {
                                id: buttonComboBox
                                width: 180
                                height: 30
                                model: backend.xboxButtonNames
                                
                                property bool updatingFromBackend: true  // Start as true to prevent initial trigger
                                
                                Component.onCompleted: {
                                    // Set initial value without triggering change handler
                                    var mapping = backend.get_button_mapping(buttonIndex)
                                    currentIndex = model.indexOf(mapping)
                                    // Now allow user changes
                                    updatingFromBackend = false
                                }
                                
                                onCurrentTextChanged: {
                                    // Only call backend if not updating from backend
                                    if (!updatingFromBackend) {
                                        backend.set_button_mapping(buttonIndex, currentText)
                                    }
                                }
                                
                                Connections {
                                    target: backend
                                    function onButtonMappingsChanged() {
                                        // Set flag to prevent triggering onCurrentTextChanged
                                        buttonComboBox.updatingFromBackend = true
                                        
                                        // Force re-evaluation of currentIndex
                                        var mapping = backend.get_button_mapping(buttonIndex)
                                        buttonComboBox.currentIndex = buttonComboBox.model.indexOf(mapping)
                                        
                                        // Reset flag after a short delay
                                        Qt.callLater(function() {
                                            buttonComboBox.updatingFromBackend = false
                                        })
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
        
        }

        footer: Item {
        width: parent.width
        height: 58

        Row {
            anchors.fill: parent
            anchors.leftMargin: 20
            anchors.rightMargin: 20
            anchors.topMargin: 8
            anchors.bottomMargin: 10
            spacing: 8
            
            StyledButton {
                text: "Auto-Configure"
                width: parent.width * 0.36
                height: 40
                secondary: true
                hoverEffect: true
                onClicked: {
                    buttonDialog.close()
                    calibrationDialog.open()
                }
            }
            
            StyledButton {
                text: "Save & Close"
                width: parent.width * 0.38
                height: 40
                primary: true
                hoverEffect: true
                onClicked: {
                    backend.save_button_mapping()
                    buttonDialog.close()
                }
            }
            
            StyledButton {
                text: "Cancel"
                width: parent.width * 0.26 - 16
                height: 40
                secondary: true
                hoverEffect: true
                onClicked: buttonDialog.close()
            }
        }
        }
    }
    
    // BUTTON AUTO-CALIBRATION WIZARD DIALOG
    Dialog {
        id: calibrationDialog
        width: 520
        height: 620
        anchors.centerIn: parent
        modal: true
        closePolicy: Dialog.NoAutoClose
        
        property int currentStep: 0
        property var buttonSequence: [
            { name: "A (Cross)", instruction: "A / Cross", desc: "Primary action button" },
            { name: "B (Circle)", instruction: "B / Circle", desc: "Secondary action button" },
            { name: "X (Square)", instruction: "X / Square", desc: "Alternative action button" },
            { name: "Y (Triangle)", instruction: "Y / Triangle", desc: "Alternative action button" },
            { name: "LB (L1)", instruction: "Left Bumper", desc: "Left shoulder button" },
            { name: "RB (R1)", instruction: "Right Bumper", desc: "Right shoulder button" },
            { name: "Back (Share)", instruction: "Back / Share", desc: "Menu/share button" },
            { name: "Start (Options)", instruction: "Start / Options", desc: "Pause/options button" },
            { name: "LThumb (L3)", instruction: "Left Stick Press", desc: "Press left analog stick" },
            { name: "RThumb (R3)", instruction: "Right Stick Press", desc: "Press right analog stick" },
        ]
        property var detectedButtons: []
        property bool waitingForRelease: false
        
        background: Rectangle {
            color: colorBg
            radius: 12
            border.color: colorBorder
            border.width: 1
        }
        
        // Remove default header and footer - we'll build custom ones
        header: Item { height: 0 }
        footer: Item { height: 0 }
        
        contentItem: Item {
            anchors.fill: parent
            
            Column {
                anchors.fill: parent
                spacing: 0
                
                // CUSTOM HEADER
                Rectangle {
                    width: parent.width
                    height: 90
                    color: colorCard
                    radius: 12
                    
                    Column {
                        anchors.centerIn: parent
                        spacing: 10
                        width: parent.width - 40
                        
                        Text {
                            text: "Button Configuration"
                            font.pixelSize: 20
                            font.weight: Font.Bold
                            color: colorTextPrimary
                            font.family: "Inter"
                            anchors.horizontalCenter: parent.horizontalCenter
                        }
                        
                        Row {
                            anchors.horizontalCenter: parent.horizontalCenter
                            spacing: 8
                            
                            Text {
                                text: calibrationDialog.currentStep < calibrationDialog.buttonSequence.length ?
                                      "Step " + (calibrationDialog.currentStep + 1) + " of " + calibrationDialog.buttonSequence.length :
                                      "Complete"
                                font.pixelSize: 12
                                color: colorTextMuted
                                font.family: "Inter"
                                anchors.verticalCenter: parent.verticalCenter
                            }
                        }
                        
                        // Progress bar
                        Rectangle {
                            width: 440
                            height: 4
                            radius: 2
                            color: colorSurface
                            anchors.horizontalCenter: parent.horizontalCenter
                            
                            Rectangle {
                                width: parent.width * (calibrationDialog.currentStep / calibrationDialog.buttonSequence.length)
                                height: parent.height
                                radius: 2
                                color: colorPrimary
                                
                                Behavior on width {
                                    NumberAnimation { duration: 300; easing.type: Easing.OutCubic }
                                }
                            }
                        }
                    }
                }
                
                // MAIN CONTENT
                Item {
                    width: parent.width
                    height: 380
                    
                    Column {
                        anchors.centerIn: parent
                        anchors.verticalCenterOffset: -20
                        spacing: 20
                        width: parent.width - 60
                        
                        // Instruction card
                        Rectangle {
                            width: parent.width
                            height: 160
                            radius: 8
                            color: colorCard
                            border.color: calibrationDialog.waitingForRelease ? colorPrimary : colorBorder
                            border.width: 1
                            anchors.horizontalCenter: parent.horizontalCenter
                            
                            Behavior on border.color {
                                ColorAnimation { duration: 150 }
                            }
                            
                            Column {
                                anchors.centerIn: parent
                                spacing: 16
                                width: parent.width - 40
                                
                                // Status icon
                                Text {
                                    text: calibrationDialog.waitingForRelease ? "OK" :
                                          (calibrationDialog.currentStep >= calibrationDialog.buttonSequence.length ? "OK" : "o")
                                    font.pixelSize: 48
                                    color: calibrationDialog.waitingForRelease ? colorPrimary : colorTextMuted
                                    anchors.horizontalCenter: parent.horizontalCenter
                                    font.family: "Inter"
                                    
                                    Behavior on color {
                                        ColorAnimation { duration: 150 }
                                    }
                                }
                                
                                Text {
                                    text: calibrationDialog.currentStep < calibrationDialog.buttonSequence.length ?
                                          "Press: " + calibrationDialog.buttonSequence[calibrationDialog.currentStep].instruction :
                                          "Configuration Complete!"
                                    font.pixelSize: 18
                                    font.weight: Font.DemiBold
                                    color: colorTextPrimary
                                    horizontalAlignment: Text.AlignHCenter
                                    width: parent.width
                                    font.family: "Inter"
                                }
                                
                                Text {
                                    text: calibrationDialog.waitingForRelease ? 
                                          "Now release the button..." :
                                          (calibrationDialog.currentStep < calibrationDialog.buttonSequence.length ?
                                           calibrationDialog.buttonSequence[calibrationDialog.currentStep].desc :
                                           "All buttons have been mapped successfully!")
                                    font.pixelSize: 12
                                    color: colorTextMuted
                                    horizontalAlignment: Text.AlignHCenter
                                    width: parent.width
                                    wrapMode: Text.WordWrap
                                    font.family: "Inter"
                                }
                            }
                        }
                        
                        // Detection status
                        Rectangle {
                            width: parent.width
                            height: 70
                            radius: 8
                            color: calibrationDialog.detectedButtons.length > 0 ? colorPrimary : colorSurface
                            border.color: calibrationDialog.detectedButtons.length > 0 ? colorSuccess : colorBorder
                            border.width: 1
                            anchors.horizontalCenter: parent.horizontalCenter
                            
                            Behavior on color {
                                ColorAnimation { duration: 150 }
                            }
                            
                            Behavior on border.color {
                                ColorAnimation { duration: 150 }
                            }
                            
                            Column {
                                anchors.centerIn: parent
                                spacing: 6
                                
                                Text {
                                    text: calibrationDialog.detectedButtons.length > 0 ?
                                          "Button " + calibrationDialog.detectedButtons[0] + " Detected" :
                                          "Waiting for input..."
                                    font.pixelSize: 14
                                    font.weight: Font.Medium
                                    color: calibrationDialog.detectedButtons.length > 0 ? colorBg : colorTextMuted
                                    horizontalAlignment: Text.AlignHCenter
                                    anchors.horizontalCenter: parent.horizontalCenter
                                    font.family: "Inter"
                                }
                                
                                Text {
                                    text: calibrationDialog.detectedButtons.length > 1 ?
                                          "+" + (calibrationDialog.detectedButtons.length - 1) + " other button(s)" :
                                          (calibrationDialog.detectedButtons.length > 0 ? "Button registered" : "Press any button to continue")
                                    font.pixelSize: 11
                                    color: calibrationDialog.detectedButtons.length > 0 ? colorBg : colorTextMuted
                                    opacity: calibrationDialog.detectedButtons.length > 0 ? 0.9 : 0.6
                                    horizontalAlignment: Text.AlignHCenter
                                    anchors.horizontalCenter: parent.horizontalCenter
                                    font.family: "Inter"
                                }
                            }
                        }
                    }
                }
                
                // CUSTOM FOOTER WITH BUTTONS
                Rectangle {
                    width: parent.width
                    height: 150
                    color: colorBg
                    
                    Column {
                        anchors.centerIn: parent
                        spacing: 10
                        width: parent.width - 60
                        
                        // Skip button - ALWAYS VISIBLE during calibration
                        Rectangle {
                            width: parent.width
                            height: 48
                            radius: 8
                            color: skipMouseArea.containsMouse ? colorSurface : colorCard
                            border.color: colorBorder
                            border.width: 1
                            visible: calibrationDialog.currentStep < calibrationDialog.buttonSequence.length
                            
                            Behavior on color {
                                ColorAnimation { duration: 100 }
                            }
                            
                            Text {
                                text: "Skip This Button"
                                font.pixelSize: 14
                                font.weight: Font.Medium
                                color: colorTextPrimary
                                anchors.centerIn: parent
                                font.family: "Inter"
                            }
                            
                            MouseArea {
                                id: skipMouseArea
                                anchors.fill: parent
                                hoverEnabled: true
                                cursorShape: Qt.PointingHandCursor
                                
                                onClicked: {
                                    // Skip to next button
                                    calibrationDialog.waitingForRelease = false
                                    calibrationDialog.detectedButtons = []
                                    calibrationDialog.currentStep++
                                    
                                    // Check if we're done after skipping
                                    if (calibrationDialog.currentStep >= calibrationDialog.buttonSequence.length) {
                                        backend.finish_calibration()
                                        closeTimer.start()
                                    }
                                }
                            }
                        }
                        
                        // Cancel/Done button
                        Rectangle {
                            width: parent.width
                            height: 48
                            radius: 8
                            color: cancelMouseArea.containsMouse ?
                                   (calibrationDialog.currentStep < calibrationDialog.buttonSequence.length ? "#dc2626" : colorPrimaryHover) :
                                   (calibrationDialog.currentStep < calibrationDialog.buttonSequence.length ? "#ef4444" : colorPrimary)
                            border.width: 0

                            Text {
                                text: calibrationDialog.currentStep < calibrationDialog.buttonSequence.length ? 
                                      "Cancel Configuration" : "Done"
                                font.pixelSize: 14
                                font.weight: Font.Bold
                                color: colorBg
                                anchors.centerIn: parent
                                font.family: "Inter"
                            }
                            
                            MouseArea {
                                id: cancelMouseArea
                                anchors.fill: parent
                                hoverEnabled: true
                                cursorShape: Qt.PointingHandCursor
                                
                                onClicked: {
                                    if (calibrationDialog.currentStep >= calibrationDialog.buttonSequence.length) {
                                        backend.finish_calibration()
                                    }
                                    calibrationDialog.currentStep = 0
                                    calibrationDialog.waitingForRelease = false
                                    calibrationDialog.detectedButtons = []
                                    calibrationDialog.close()
                                }
                            }
                        }
                    }
                }
            }
        }
        
        // Timer to auto-close after completion
        Timer {
            id: closeTimer
            interval: 1500
            repeat: false
            onTriggered: {
                calibrationDialog.currentStep = 0
                calibrationDialog.waitingForRelease = false
                calibrationDialog.detectedButtons = []
                calibrationDialog.close()
            }
        }
        
        // Monitor button presses during calibration
        Timer {
            id: calibrationTimer
            interval: 50
            running: calibrationDialog.visible && calibrationDialog.currentStep < calibrationDialog.buttonSequence.length
            repeat: true
            
            property int noChangeCounter: 0
            
            onTriggered: {
                var pressed = backend.get_pressed_buttons()
                
                if (calibrationDialog.waitingForRelease) {
                    // Waiting for user to release the button
                    if (pressed.length === 0) {
                        calibrationDialog.waitingForRelease = false
                        calibrationDialog.detectedButtons = []
                        calibrationDialog.currentStep++
                        noChangeCounter = 0
                        
                        // Check if we're done
                        if (calibrationDialog.currentStep >= calibrationDialog.buttonSequence.length) {
                            backend.finish_calibration()
                            // Auto-close after a short delay
                            closeTimer.start()
                        }
                    } else {
                        // Still waiting for release - increment counter
                        noChangeCounter++
                        
                        // If stuck for more than 10 seconds (200 cycles at 50ms), auto-advance
                        if (noChangeCounter > 200) {
                            console.log("Button stuck - auto-advancing")
                            calibrationDialog.waitingForRelease = false
                            calibrationDialog.detectedButtons = []
                            calibrationDialog.currentStep++
                            noChangeCounter = 0
                            
                            if (calibrationDialog.currentStep >= calibrationDialog.buttonSequence.length) {
                                backend.finish_calibration()
                                closeTimer.start()
                            }
                        }
                    }
                } else {
                    // Waiting for user to press a button
                    if (pressed.length > 0) {
                        calibrationDialog.detectedButtons = pressed
                        
                        // Map the first detected button
                        var buttonIdx = pressed[0]
                        var buttonName = calibrationDialog.buttonSequence[calibrationDialog.currentStep].name
                        backend.calibrate_button(buttonIdx, buttonName)
                        
                        calibrationDialog.waitingForRelease = true
                        noChangeCounter = 0
                    } else {
                        calibrationDialog.detectedButtons = []
                    }
                }
            }
        }
        
        onOpened: {
            calibrationDialog.currentStep = 0
            calibrationDialog.waitingForRelease = false
            calibrationDialog.detectedButtons = []
            backend.start_button_calibration()
        }
    }
    
    // VIGEM DRIVER INSTALLATION DIALOG
    Dialog {
        id: vigEmDialog
        width: 420
        height: 300
        anchors.centerIn: parent
        modal: true
        title: "ViGEm Driver Required"
        closePolicy: Dialog.RejectOnEscape  // Don't allow closing via X or Escape
        
        property bool installing: false
        
        background: Rectangle {
            color: colorBg
            radius: 12
            border.color: colorWarning
            border.width: 2
        }
        
        header: Item {
            height: 70
            
            Column {
                anchors.left: parent.left
                anchors.leftMargin: 20
                anchors.top: parent.top
                anchors.topMargin: 18
                spacing: 6
                
                Text {
                    text: "ViGEm Driver Not Detected"
                    font.pixelSize: 15
                    font.weight: Font.DemiBold
                    color: colorWarning
                    font.family: "Inter"
                }
                
                Text {
                    text: "TrueAxis requires the ViGEm driver to work"
                    font.pixelSize: 12
                    color: colorTextMuted
                    font.family: "Inter"
                }
            }
        }
        
        contentItem: Column {
            spacing: 16
            
            Text {
                width: parent.width - 40
                text: "TrueAxis needs the ViGEm driver to create a virtual Xbox controller. Without it, the application cannot function.\n\nClick the button below to automatically download and install the driver. No action is needed from you - it installs silently in the background."
                font.pixelSize: 12
                color: colorTextSecondary
                font.family: "Inter"
                wrapMode: Text.WordWrap
                lineHeight: 1.4
            }
            
            // Live status text - shows download/install progress
            Text {
                id: vigEmInstallStatus
                width: parent.width - 40
                text: {
                    if (vigEmStatus === "not installed" || vigEmStatus === "installed" || vigEmStatus === "") return ""
                    return vigEmStatus
                }
                font.pixelSize: 11
                color: vigEmDialog.installing ? colorWarning : colorError
                font.family: "Inter"
                wrapMode: Text.WordWrap
                visible: text !== ""
            }
            
            // Install button
            StyledButton {
                id: vigEmInstallBtn
                text: vigEmDialog.installing ? "Installing..." : "Install ViGEm"
                width: parent.width - 40
                height: 40
                primary: !vigEmDialog.installing
                enabled: !vigEmDialog.installing
                hoverEffect: !vigEmDialog.installing
                onClicked: {
                    vigEmDialog.installing = true
                    backend.install_vigem_driver()
                }
            }
        }
        
        // No footer / no close button - dialog closes automatically on success
        footer: null
    }
    
    // IMPORT SETTINGS DIALOG
    Dialog {
        id: importDialog
        width: 420
        height: 280
        anchors.centerIn: parent
        modal: true
        title: "Import Settings"
        
        property string selectedFile: ""
        
        background: Rectangle {
            color: colorBg
            radius: 12
            border.color: colorBorder
            border.width: 1
        }
        
        header: Item {
            height: 70
            
            Column {
                anchors.left: parent.left
                anchors.leftMargin: 20
                anchors.top: parent.top
                anchors.topMargin: 18
                spacing: 6
                
                Text {
                    text: "Import Settings"
                    font.pixelSize: 15
                    font.weight: Font.DemiBold
                    color: colorTextPrimary
                    font.family: "Inter"
                }
                
                Text {
                    text: "Restore from a previously exported file"
                    font.pixelSize: 12
                    color: colorTextMuted
                    font.family: "Inter"
                }
            }
        }
        
        contentItem: Column {
            spacing: 16
            
            Text {
                width: parent.width - 40
                text: "Enter the full path to your TrueAxis settings file (e.g., TrueAxis_Settings_*.json in your Documents folder):"
                font.pixelSize: 12
                color: colorTextSecondary
                font.family: "Inter"
                wrapMode: Text.WordWrap
                lineHeight: 1.4
            }
            
            Rectangle {
                width: parent.width - 40
                height: 40
                color: colorSurface
                radius: 8
                border.color: colorBorder
                border.width: 1
                
                TextInput {
                    id: importPathInput
                    anchors.fill: parent
                    anchors.margins: 10
                    font.pixelSize: 12
                    font.family: "Inter"
                    color: colorTextPrimary
                    verticalAlignment: Text.AlignVCenter
                    selectByMouse: true
                    clip: true
                    
                    Text {
                        anchors.verticalCenter: parent.verticalCenter
                        text: "Path to settings file..."
                        color: colorTextMuted
                        font.pixelSize: 12
                        font.family: "Inter"
                        visible: !parent.text && !parent.activeFocus
                    }
                }
            }
            
            Row {
                spacing: 10
                
                StyledButton {
                    text: "Cancel"
                    width: 110
                    height: 40
                    secondary: true
                    hoverEffect: true
                    onClicked: importDialog.close()
                }
                
                StyledButton {
                    text: "Import"
                    width: 110
                    height: 40
                    primary: true
                    enabled: importPathInput.text.length > 0
                    hoverEffect: enabled
                    onClicked: {
                        var success = backend.import_settings(importPathInput.text)
                        if (success) {
                            importPathInput.text = ""
                            importDialog.close()
                        }
                }
            }
        }
        }
    }
    
    // MACRO NAME INPUT DIALOG
    Dialog {
        id: macroNameDialog
        width: 400
        height: 300
        anchors.centerIn: parent
        modal: true
        title: "Record New Macro"
        
        background: Rectangle {
            color: colorBg
            radius: 12
            border.color: colorBorder
            border.width: 1
        }
        
        header: Item {
            width: parent.width
            height: 60
            
            Text {
                anchors.left: parent.left
                anchors.leftMargin: 20
                anchors.verticalCenter: parent.verticalCenter
                text: "Record New Macro"
                font.pixelSize: 15
                font.weight: Font.DemiBold
                color: colorTextPrimary
                font.family: "Inter"
            }
        }
        
        Column {
            anchors.fill: parent
            anchors.margins: 20
            spacing: 15
            
            Text {
                text: "Enter a name for this macro:"
                font.pixelSize: 12
                color: colorTextSecondary
                font.family: "Inter"
            }
            
            Rectangle {
                width: parent.width
                height: 40
                color: colorCard
                border.color: macroNameInput.activeFocus ? colorPrimary : colorBorder
                border.width: 1
                radius: 6
                
                TextInput {
                    id: macroNameInput
                    anchors.fill: parent
                    anchors.margins: 10
                    color: colorTextPrimary
                    font.pixelSize: 13
                    font.family: "Inter"
                    verticalAlignment: TextInput.AlignVCenter
                    selectByMouse: true
                    
                    Keys.onReturnPressed: {
                        if (text.length > 0) {
                            backend.start_macro_recording(text)
                            text = ""
                            macroNameDialog.close()
                        }
                    }
                }
            }
            
            Row {
                width: parent.width
                spacing: 10
                
                StyledButton {
                    text: "Cancel"
                    width: (parent.width - 10) / 2
                    secondary: true
                    onClicked: {
                        macroNameInput.text = ""
                        macroNameDialog.close()
                    }
                }
                
                StyledButton {
                    text: "Start Recording"
                    width: (parent.width - 10) / 2
                    primary: true
                    enabled: macroNameInput.text.length > 0
                    onClicked: {
                        backend.start_macro_recording(macroNameInput.text)
                        macroNameInput.text = ""
                        macroNameDialog.close()
                    }
                }
            }
        }
    }
    
    // MACRO MANAGER DIALOG
    Dialog {
        id: macroManagerDialog
        width: 540
        height: 430
        anchors.centerIn: parent
        modal: true
        title: "Macro Manager"
        
        background: Rectangle {
            color: colorBg
            radius: 12
            border.color: colorBorder
            border.width: 1
        }
        
        header: Item {
            width: parent.width
            height: 54
            
            Text {
                anchors.left: parent.left
                anchors.leftMargin: 20
                anchors.verticalCenter: parent.verticalCenter
                text: "Macro Manager"
                font.pixelSize: 15
                font.weight: Font.DemiBold
                color: colorTextPrimary
                font.family: "Inter"
            }
        }
        
        Column {
            anchors.fill: parent
            anchors.margins: 18
            spacing: 12
            
            Text {
                text: "Your saved macros:"
                font.pixelSize: 12
                color: colorTextSecondary
                font.family: "Inter"
            }
            
            ScrollView {
                width: parent.width
                height: 220
                clip: true
                
                Column {
                    id: macroList
                    width: parent.width
                    spacing: 10
                    
                    property var macroArray: []
                    
                    Repeater {
                        id: macroRepeater
                        model: macroList.macroArray.length
                        
                        delegate: Rectangle {
                            id: macroCard
                            width: macroList.width
                            height: 128
                            color: colorCard
                            radius: 8
                            border.color: colorBorder
                            border.width: 1
                            
                            property var macroData: macroList.macroArray[index] || {}
                            
                            Column {
                                anchors.fill: parent
                                anchors.margins: 14
                                spacing: 8
                                
                                Row {
                                    width: parent.width
                                    spacing: 10
                                    
                                    Text {
                                        text: macroCard.macroData.name || ""
                                        font.pixelSize: 14
                                        font.bold: true
                                        color: colorTextPrimary
                                        font.family: "Inter"
                                        width: parent.width - 70
                                        elide: Text.ElideRight
                                    }
                                    
                                    Text {
                                        text: (macroCard.macroData.duration || 0) + "s"
                                        font.pixelSize: 11
                                        color: colorPrimary
                                        font.family: "Inter"
                                        width: 60
                                        horizontalAlignment: Text.AlignRight
                                    }
                                }
                                
                                Text {
                                    text: (macroCard.macroData.frames || 0) + " steps - Created " + (macroCard.macroData.created || "Unknown")
                                    font.pixelSize: 10
                                    color: colorTextMuted
                                    font.family: "Inter"
                                }

                                Text {
                                    text: {
                                        var triggers = macroCard.macroData.triggers || []
                                        return triggers.length > 0 ? "Trigger: " + triggers.join(", ") : "Trigger: not set"
                                    }
                                    font.pixelSize: 10
                                    color: colorTextMuted
                                    font.family: "Inter"
                                    elide: Text.ElideRight
                                    width: parent.width
                                }
                                
                                Row {
                                    width: parent.width
                                    spacing: 8
                                    
                                    Rectangle {
                                        width: parent.width
                                        height: 34
                                        radius: 7
                                        color: colorSurface
                                        border.color: colorBorder
                                        border.width: 1

                                        Row {
                                            anchors.fill: parent
                                            anchors.margins: 4
                                            spacing: 8

                                            StyledButton {
                                                text: "Play"
                                                width: 84
                                                height: 26
                                                fontSize: 11
                                                primary: true
                                                enabled: backend.running && !backend.macro_playing
                                                onClicked: backend.play_macro(macroCard.macroData.name || "")
                                            }
                                            
                                            StyledButton {
                                                text: "Set Trigger"
                                                width: 112
                                                height: 26
                                                fontSize: 11
                                                secondary: true
                                                onClicked: {
                                                    macroTriggerDialog.currentMacro = macroCard.macroData.name || ""
                                                    macroTriggerDialog.open()
                                                }
                                            }

                                            Item { width: parent.width - 84 - 112 - 76 - 32; height: 1 }

                                            StyledButton {
                                                text: "Delete"
                                                width: 76
                                                height: 26
                                                fontSize: 11
                                                danger: true
                                                onClicked: {
                                                    backend.delete_macro(macroCard.macroData.name || "")
                                                    macroManagerDialog.refreshMacroList()
                                                }
                                            }
                                        }
                                    }
                                }
                            }
                        }
                    }
                    
                    Text {
                        visible: macroRepeater.count === 0
                        text: "No macros recorded yet.\nClick 'Record Macro' or 'Create Custom' to create one."
                        font.pixelSize: 12
                        color: colorTextMuted
                        font.family: "Inter"
                        horizontalAlignment: Text.AlignHCenter
                        width: parent.width
                    }
                }
            }
            
        }

        footer: Item {
            width: parent.width
            height: 58

            Row {
                anchors.fill: parent
                anchors.leftMargin: 18
                anchors.rightMargin: 18
                anchors.topMargin: 8
                anchors.bottomMargin: 12
                spacing: 10
                
                StyledButton {
                    text: "+ Create Custom"
                    width: (parent.width - 10) / 2
                    height: 38
                    primary: true
                    onClicked: {
                        customMacroDialog.open()
                        macroManagerDialog.close()
                    }
                }
                
                StyledButton {
                    text: "Close"
                    width: (parent.width - 10) / 2
                    height: 38
                    secondary: true
                    onClicked: macroManagerDialog.close()
                }
            }
        }
        
        onOpened: {
            refreshMacroList()
        }
        
        function refreshMacroList() {
            try {
                var list = JSON.parse(backend.get_macro_list())
                macroList.macroArray = list
            } catch(e) {
                console.log("Error parsing macro list: " + e)
                macroList.macroArray = []
            }
        }
        
        Connections {
            target: backend
            function onMacrosChanged() {
                if (macroManagerDialog.visible) {
                    macroManagerDialog.refreshMacroList()
                }
            }
        }
    }
    
    // MACRO TRIGGER SETUP DIALOG
    Dialog {
        id: macroTriggerDialog
        width: 450
        height: 300
        anchors.centerIn: parent
        modal: true
        title: "Set Macro Trigger"
        
        property string currentMacro: ""
        property var pressedButtons: []
        property var capturedButtons: []
        
        background: Rectangle {
            color: colorBg
            radius: 12
            border.color: colorBorder
            border.width: 1
        }
        
        header: Item {
            width: parent.width
            height: 60
            
            Text {
                anchors.left: parent.left
                anchors.leftMargin: 20
                anchors.verticalCenter: parent.verticalCenter
                text: "Set Trigger for: " + macroTriggerDialog.currentMacro
                font.pixelSize: 15
                font.weight: Font.DemiBold
                color: colorTextPrimary
                font.family: "Inter"
                elide: Text.ElideRight
                width: parent.width - 40
            }
        }
        
        Column {
            anchors.fill: parent
            anchors.margins: 20
            spacing: 15
            
            Text {
                text: "Press the button combination once. It stays captured until you clear it."
                font.pixelSize: 12
                color: colorTextSecondary
                font.family: "Inter"
                wrapMode: Text.WordWrap
                width: parent.width
            }
            
            Rectangle {
                width: parent.width
                height: 80
                color: colorCard
                radius: 8
                border.color: macroTriggerDialog.capturedButtons.length > 0 ? colorSuccess : colorBorder
                border.width: 2
                
                Text {
                    anchors.centerIn: parent
                    text: macroTriggerDialog.capturedButtons.length > 0 ? 
                          "Captured: " + macroTriggerDialog.formatButtons(macroTriggerDialog.capturedButtons) :
                          "Waiting for buttons..."
                    font.pixelSize: 14
                    font.bold: true
                    color: macroTriggerDialog.capturedButtons.length > 0 ? colorSuccess : colorTextMuted
                    font.family: "Inter"
                }
            }
            
            Row {
                width: parent.width
                spacing: 10
                
                StyledButton {
                    text: "Cancel"
                    width: (parent.width - 20) / 3
                    secondary: true
                    onClicked: {
                        macroTriggerDialog.pressedButtons = []
                        macroTriggerDialog.capturedButtons = []
                        macroTriggerDialog.close()
                    }
                }
                
                StyledButton {
                    text: "Clear Trigger"
                    width: (parent.width - 20) / 3
                    danger: true
                    onClicked: {
                        backend.clear_macro_triggers(macroTriggerDialog.currentMacro)
                        macroTriggerDialog.pressedButtons = []
                        macroTriggerDialog.capturedButtons = []
                        macroTriggerDialog.close()
                    }
                }
                
                StyledButton {
                    text: "Set Trigger"
                    width: (parent.width - 20) / 3
                    primary: true
                    enabled: macroTriggerDialog.capturedButtons.length > 0
                    onClicked: {
                        var combo = macroTriggerDialog.capturedButtons.join("+")
                        backend.set_macro_trigger(combo, macroTriggerDialog.currentMacro)
                        macroTriggerDialog.pressedButtons = []
                        macroTriggerDialog.capturedButtons = []
                        macroTriggerDialog.close()
                    }
                }
            }
        }
        
        Timer {
            id: triggerMonitor
            interval: 50
            running: macroTriggerDialog.visible
            repeat: true
            
            onTriggered: {
                var pressed = backend.get_pressed_buttons()
                macroTriggerDialog.pressedButtons = pressed
                if (pressed.length > 0) {
                    macroTriggerDialog.capturedButtons = pressed
                }
            }
        }

        onOpened: {
            pressedButtons = []
            capturedButtons = []
        }

        function formatButtons(buttons) {
            var names = []
            for (var i = 0; i < buttons.length; i++) {
                names.push(backend.get_button_name(buttons[i]))
            }
            return names.join(" + ")
        }
    }
    
    // CUSTOM MACRO BUILDER DIALOG
    Dialog {
        id: customMacroDialog
        width: 520
        height: 560
        anchors.centerIn: parent
        modal: true
        title: "Custom Macro Builder"
        
        property var actions: []
        property string macroName: ""
        
        background: Rectangle {
            color: colorBg
            radius: 12
            border.color: colorBorder
            border.width: 1
        }
        
        header: Item {
            width: parent.width
            height: 58
            
            Text {
                anchors.left: parent.left
                anchors.leftMargin: 20
                anchors.verticalCenter: parent.verticalCenter
                text: "Custom Macro Builder"
                font.pixelSize: 15
                font.weight: Font.DemiBold
                color: colorTextPrimary
                font.family: "Inter"
            }
        }
        
        Column {
            anchors.fill: parent
            anchors.margins: 20
            spacing: 14
            
            // Macro Name Input
            Column {
                width: parent.width
                spacing: 8
                
                Text {
                    text: "Macro Name"
                    font.pixelSize: 13
                    font.bold: true
                    color: colorTextSecondary
                    font.family: "Inter"
                }
                
                Rectangle {
                    width: parent.width
                    height: 38
                    color: colorCard
                    radius: 8
                    border.color: customMacroNameInput.activeFocus ? colorPrimary : colorBorder
                    border.width: 1
                    
                    TextInput {
                        id: customMacroNameInput
                        anchors.fill: parent
                        anchors.leftMargin: 12
                        anchors.rightMargin: 12
                        font.pixelSize: 13
                        color: colorTextPrimary
                        font.family: "Inter"
                        verticalAlignment: TextInput.AlignVCenter
                        selectByMouse: true
                        
                        Text {
                            visible: !parent.text && !parent.activeFocus
                            text: "Enter a name for your macro..."
                            color: colorTextMuted
                            font.pixelSize: 13
                            font.family: "Inter"
                            anchors.verticalCenter: parent.verticalCenter
                        }
                    }
                }
            }
            
            // Action List
            Column {
                width: parent.width
                spacing: 8
                
                Row {
                    width: parent.width
                    spacing: 10
                    
                    Text {
                        text: "Actions Sequence"
                        font.pixelSize: 13
                        font.bold: true
                        color: colorTextSecondary
                        font.family: "Inter"
                        anchors.verticalCenter: parent.verticalCenter
                    }
                    
                    Rectangle {
                        width: 60
                        height: 24
                        radius: 12
                        color: colorPrimary
                        opacity: 0.1
                        anchors.verticalCenter: parent.verticalCenter
                        
                        Text {
                            anchors.centerIn: parent
                            text: customMacroDialog.actions.length + " steps"
                            font.pixelSize: 11
                            font.bold: true
                            color: colorPrimary
                            font.family: "Inter"
                        }
                    }
                }
                
                Item {
                    width: parent.width
                    height: 156

                    Rectangle {
                        anchors.fill: parent
                        visible: customMacroDialog.actions.length === 0
                        color: colorCard
                        radius: 8
                        border.color: colorBorder
                        border.width: 1

                        Text {
                            anchors.centerIn: parent
                            text: "No steps yet"
                            font.pixelSize: 12
                            color: colorTextMuted
                            font.family: "Inter"
                        }
                    }

                    ScrollView {
                        anchors.fill: parent
                        visible: customMacroDialog.actions.length > 0
                        clip: true
                        
                        Column {
                            id: actionsList
                            width: parent.width - 10
                            spacing: 8
                            
                            Repeater {
                                model: customMacroDialog.actions.length
                                
                                delegate: Rectangle {
                                    id: actionCard
                                    width: actionsList.width
                                    height: 58
                                    color: colorCard
                                    radius: 8
                                    border.color: colorBorder
                                    border.width: 1
                                    
                                    property var actionData: customMacroDialog.actions[index] || {}
                                    
                                    Row {
                                        anchors.fill: parent
                                        anchors.margins: 12
                                        spacing: 12
                                        
                                        Rectangle {
                                            width: 30
                                            height: 30
                                            radius: 15
                                            color: colorPrimary
                                            anchors.verticalCenter: parent.verticalCenter
                                            
                                            Text {
                                                anchors.centerIn: parent
                                                text: (index + 1).toString()
                                                font.pixelSize: 12
                                                font.bold: true
                                                color: "white"
                                                font.family: "Inter"
                                            }
                                        }
                                        
                                        Column {
                                            width: parent.width - 84
                                            spacing: 3
                                            anchors.verticalCenter: parent.verticalCenter
                                            
                                            Text {
                                                text: {
                                                    var type = actionCard.actionData.type || ""
                                                    if (type === "hold") return "Hold Button(s)"
                                                    if (type === "tap") return "Tap Button(s)"
                                                    if (type === "press") return "Press Button(s)"
                                                    if (type === "release") return "Release Button(s)"
                                                    if (type === "wait") return "Wait"
                                                    return type
                                                }
                                                font.pixelSize: 13
                                                font.bold: true
                                                color: colorTextPrimary
                                                font.family: "Inter"
                                            }
                                            
                                            Text {
                                                text: {
                                                    var action = actionCard.actionData
                                                    var details = ""
                                                    if (action.buttons && action.buttons.length > 0) {
                                                        var buttonNames = []
                                                        for (var i = 0; i < action.buttons.length; i++) {
                                                            buttonNames.push(backend.get_button_name(action.buttons[i]))
                                                        }
                                                        details = buttonNames.join(", ")
                                                    }
                                                    if (action.duration) {
                                                        details += (details ? " - " : "") + action.duration + "ms"
                                                    }
                                                    return details || "No details"
                                                }
                                                font.pixelSize: 11
                                                color: colorTextMuted
                                                font.family: "Inter"
                                                elide: Text.ElideRight
                                                width: parent.width
                                            }
                                        }
                                        
                                        Rectangle {
                                            width: 32
                                            height: 32
                                            radius: 8
                                            color: deleteMouseArea.containsMouse ? "#26ef4444" : "transparent"
                                            border.color: deleteMouseArea.containsMouse ? "#ef4444" : colorBorder
                                            border.width: 1
                                            anchors.verticalCenter: parent.verticalCenter
                                            
                                            Text {
                                                anchors.centerIn: parent
                                                text: "x"
                                                font.pixelSize: 18
                                                color: deleteMouseArea.containsMouse ? "#ef4444" : colorTextMuted
                                                font.family: "Inter"
                                            }
                                            
                                            MouseArea {
                                                id: deleteMouseArea
                                                anchors.fill: parent
                                                hoverEnabled: true
                                                cursorShape: Qt.PointingHandCursor
                                                onClicked: {
                                                    var newActions = customMacroDialog.actions.slice()
                                                    newActions.splice(index, 1)
                                                    customMacroDialog.actions = newActions
                                                }
                                            }
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
            
            // Add Action Buttons
            Column {
                width: parent.width
                spacing: 8
                
                Text {
                    text: "Add Action"
                    font.pixelSize: 13
                    font.bold: true
                    color: colorTextSecondary
                    font.family: "Inter"
                }
                
                Grid {
                    columns: 3
                    columnSpacing: 10
                    rowSpacing: 10
                    width: parent.width
                    
                    StyledButton {
                        text: "Hold Button"
                        width: (parent.width - 20) / 3
                        height: 34
                        onClicked: actionConfigDialog.open("hold")
                    }

                    StyledButton {
                        text: "Tap Button"
                        width: (parent.width - 20) / 3
                        height: 34
                        onClicked: actionConfigDialog.open("tap")
                    }
                    
                    StyledButton {
                        text: "Wait"
                        width: (parent.width - 20) / 3
                        height: 34
                        onClicked: actionConfigDialog.open("wait")
                    }
                    
                    StyledButton {
                        text: "Press Button"
                        width: (parent.width - 20) / 3
                        height: 34
                        onClicked: actionConfigDialog.open("press")
                    }
                    
                    StyledButton {
                        text: "Release Button"
                        width: (parent.width - 20) / 3
                        height: 34
                        onClicked: actionConfigDialog.open("release")
                    }
                }
            }
            
        }

        footer: Item {
            width: parent.width
            height: 60

            Row {
                anchors.fill: parent
                anchors.leftMargin: 20
                anchors.rightMargin: 20
                anchors.topMargin: 10
                anchors.bottomMargin: 12
                spacing: 10
                
                StyledButton {
                    text: "Clear All"
                    width: (parent.width - 20) / 3
                    height: 38
                    danger: customMacroDialog.actions.length > 0
                    secondary: customMacroDialog.actions.length === 0
                    enabled: customMacroDialog.actions.length > 0
                    onClicked: customMacroDialog.actions = []
                }
                
                StyledButton {
                    text: "Cancel"
                    width: (parent.width - 20) / 3
                    height: 38
                    secondary: true
                    onClicked: customMacroDialog.close()
                }
                
                StyledButton {
                    text: "Create Macro"
                    width: (parent.width - 20) / 3
                    height: 38
                    primary: true
                    enabled: customMacroNameInput.text.length > 0 && customMacroDialog.actions.length > 0
                    onClicked: {
                        var actionsJson = JSON.stringify(customMacroDialog.actions)
                        if (backend.create_custom_macro(customMacroNameInput.text, actionsJson)) {
                            customMacroDialog.actions = []
                            customMacroNameInput.text = ""
                            customMacroDialog.close()
                        }
                    }
                }
            }
        }
        
        onOpened: {
            customMacroNameInput.text = ""
            customMacroDialog.actions = []
        }
    }
    
    // ACTION CONFIGURATION DIALOG
    Dialog {
        id: actionConfigDialog
        width: 480
        height: 600
        anchors.centerIn: parent
        modal: true
        
        property string actionType: ""
        property var selectedButtons: []
        property int duration: 100
        
        background: Rectangle {
            color: colorBg
            radius: 12
            border.color: colorBorder
            border.width: 1
        }
        
        header: Item {
            width: parent.width
            height: 60
            
            Column {
                anchors.left: parent.left
                anchors.leftMargin: 20
                anchors.verticalCenter: parent.verticalCenter
                spacing: 4
                
                Text {
                    text: {
                        if (actionConfigDialog.actionType === "hold") return "Hold Button"
                        if (actionConfigDialog.actionType === "tap") return "Tap Button"
                        if (actionConfigDialog.actionType === "wait") return "Wait"
                        if (actionConfigDialog.actionType === "press") return "Press Button"
                        if (actionConfigDialog.actionType === "release") return "Release Button"
                        return "Configure Action"
                    }
                    font.pixelSize: 15
                    font.weight: Font.DemiBold
                    color: colorTextPrimary
                    font.family: "Inter"
                }
                
                Text {
                    text: {
                        if (actionConfigDialog.actionType === "hold") return "Select buttons and set how long to hold them"
                        if (actionConfigDialog.actionType === "tap") return "Press and release selected buttons after a custom interval"
                        if (actionConfigDialog.actionType === "wait") return "Set how long to pause before the next action"
                        if (actionConfigDialog.actionType === "press") return "Select buttons to press down"
                        if (actionConfigDialog.actionType === "release") return "Select buttons to release"
                        return ""
                    }
                    font.pixelSize: 12
                    color: colorTextMuted
                    font.family: "Inter"
                }
            }
        }
        
        Column {
            anchors.fill: parent
            anchors.margins: 20
            spacing: 16
            
            // Button Selection (for press/release/hold)
            Column {
                width: parent.width
                spacing: 10
                visible: actionConfigDialog.actionType !== "wait"
                
                Text {
                    text: "Select Button(s)"
                    font.pixelSize: 13
                    font.bold: true
                    color: colorTextSecondary
                    font.family: "Inter"
                }
                
                Rectangle {
                    width: parent.width
                    height: 42
                    color: colorSurface
                    radius: 8
                    border.color: colorBorder
                    border.width: 1
                    
                    Row {
                        anchors.fill: parent
                        anchors.margins: 12
                        spacing: 10
                        
                        Text {
                            text: "i"
                            font.pixelSize: 14
                            color: colorTextMuted
                            font.family: "Inter"
                            anchors.verticalCenter: parent.verticalCenter
                        }
                        
                        Text {
                            text: "Press buttons on your controller to select them"
                            font.pixelSize: 12
                            color: colorTextMuted
                            font.family: "Inter"
                            anchors.verticalCenter: parent.verticalCenter
                            width: parent.width - 30
                            wrapMode: Text.WordWrap
                        }
                    }
                }
                
                Rectangle {
                    width: parent.width
                    height: 64
                    color: colorCard
                    radius: 8
                    border.color: actionConfigDialog.selectedButtons.length > 0 ? colorSuccess : colorBorder
                    border.width: 2
                    
                    Column {
                        anchors.centerIn: parent
                        width: parent.width - 24
                        spacing: 6
                        
                        Text {
                            text: actionConfigDialog.selectedButtons.length > 0 ? 
                                  "Selected Buttons:" : 
                                  "No buttons selected"
                            font.pixelSize: 12
                            color: colorTextMuted
                            font.family: "Inter"
                            anchors.horizontalCenter: parent.horizontalCenter
                            width: parent.width
                            horizontalAlignment: Text.AlignHCenter
                            elide: Text.ElideRight
                        }
                        
                        Text {
                            visible: actionConfigDialog.selectedButtons.length > 0
                            text: {
                                var names = []
                                for (var i = 0; i < actionConfigDialog.selectedButtons.length; i++) {
                                    names.push(backend.get_button_name(actionConfigDialog.selectedButtons[i]))
                                }
                                return names.join(", ")
                            }
                            font.pixelSize: 16
                            font.bold: true
                            color: colorSuccess
                            font.family: "Inter"
                            anchors.horizontalCenter: parent.horizontalCenter
                            width: parent.width
                            horizontalAlignment: Text.AlignHCenter
                            elide: Text.ElideRight
                        }
                    }
                }
                
                StyledButton {
                    text: "Clear Selection"
                    width: parent.width
                    height: 34
                    secondary: true
                    enabled: actionConfigDialog.selectedButtons.length > 0
                    onClicked: actionConfigDialog.selectedButtons = []
                }
            }
            
            // Duration Configuration (for wait/hold)
            Column {
                width: parent.width
                spacing: 10
                visible: actionConfigDialog.actionType === "wait" || actionConfigDialog.actionType === "hold" || actionConfigDialog.actionType === "tap"
                
                Text {
                    text: "Duration"
                    font.pixelSize: 13
                    font.bold: true
                    color: colorTextSecondary
                    font.family: "Inter"
                }
                
                Row {
                    width: parent.width
                    spacing: 10
                    
                    Rectangle {
                        width: parent.width - 70
                        height: 40
                        color: colorCard
                        radius: 8
                        border.color: durationInput.activeFocus ? colorPrimary : colorBorder
                        border.width: 1
                        
                        TextInput {
                            id: durationInput
                            anchors.fill: parent
                            anchors.leftMargin: 12
                            anchors.rightMargin: 12
                            font.pixelSize: 14
                            color: colorTextPrimary
                            font.family: "Inter"
                            verticalAlignment: TextInput.AlignVCenter
                            selectByMouse: true
                            text: actionConfigDialog.duration.toString()
                            validator: IntValidator { bottom: 1; top: 300000 }
                            
                            onTextChanged: {
                                var val = parseInt(text)
                                if (!isNaN(val) && val > 0) {
                                    actionConfigDialog.duration = val
                                }
                            }
                        }
                    }
                    
                    Text {
                        text: "ms"
                        font.pixelSize: 14
                        font.bold: true
                        color: colorTextSecondary
                        font.family: "Inter"
                        anchors.verticalCenter: parent.verticalCenter
                        width: 60
                        horizontalAlignment: Text.AlignLeft
                    }
                }
                
                Text {
                    text: "Quick presets:"
                    font.pixelSize: 11
                    color: colorTextMuted
                    font.family: "Inter"
                }
                
                Flow {
                    width: parent.width
                    spacing: 7
                    
                    Repeater {
                        model: [
                            {label: "10ms", value: 10},
                            {label: "25ms", value: 25},
                            {label: "50ms", value: 50},
                            {label: "100ms", value: 100},
                            {label: "250ms", value: 250},
                            {label: "500ms", value: 500},
                            {label: "1 sec", value: 1000},
                            {label: "2 sec", value: 2000}
                        ]
                        
                        StyledButton {
                            text: modelData.label
                            width: 72
                            height: 32
                            secondary: true
                            onClicked: {
                                actionConfigDialog.duration = modelData.value
                                durationInput.text = modelData.value.toString()
                            }
                        }
                    }
                }
            }
            
        }

        footer: Item {
            width: parent.width
            height: 58

            Row {
                anchors.fill: parent
                anchors.leftMargin: 20
                anchors.rightMargin: 20
                anchors.topMargin: 8
                anchors.bottomMargin: 10
                spacing: 12
                
                StyledButton {
                    text: "Cancel"
                    width: (parent.width - 12) / 2
                    height: 40
                    secondary: true
                    onClicked: actionConfigDialog.close()
                }
                
                StyledButton {
                    text: "Add Action"
                    width: (parent.width - 12) / 2
                    height: 40
                    primary: true
                    enabled: {
                        if (actionConfigDialog.actionType === "wait") return true
                        return actionConfigDialog.selectedButtons.length > 0
                    }
                    onClicked: {
                        var action = {
                            type: actionConfigDialog.actionType
                        }
                        
                        if (actionConfigDialog.actionType !== "wait") {
                            action.buttons = actionConfigDialog.selectedButtons.slice()
                        }
                        
                        if (actionConfigDialog.actionType === "wait" || actionConfigDialog.actionType === "hold" || actionConfigDialog.actionType === "tap") {
                            action.duration = actionConfigDialog.duration
                        }
                        
                        var newActions = customMacroDialog.actions.slice()
                        newActions.push(action)
                        customMacroDialog.actions = newActions
                        
                        actionConfigDialog.close()
                    }
                }
            }
        }
        
        function open(type) {
            actionConfigDialog.actionType = type
            actionConfigDialog.selectedButtons = []
            actionConfigDialog.duration = 100
            durationInput.text = "100"
            actionConfigDialog.visible = true
        }
        
        Timer {
            id: actionConfigMonitor
            interval: 50
            running: actionConfigDialog.visible && actionConfigDialog.actionType !== "wait"
            repeat: true
            
            onTriggered: {
                var pressed = backend.get_pressed_buttons()
                if (pressed.length > 0) {
                    actionConfigDialog.selectedButtons = pressed
                }
            }
        }
    }
    
    // CONNECTIONS
    Connections {
        target: backend
        
        function onSteeringChanged(value) {
            steerBar.value = value
            // Convert 0-1 value to -1 to 1 for direction
            steerBar.direction = (value * 2) - 1
        }
        
        function onGasChanged(value) {
            gasBar.value = value
        }
        
        function onBrakeChanged(value) {
            brakeBar.value = value
        }
        
        function onStatusChanged(text, color) {
            appStatusText = text
            appStatusColor = color
            var c = String(color).toLowerCase()
            if (c === "#ef4444" || c === "#f59e0b") {
                statusRevertTimer.stop()
            } else {
                statusRevertTimer.restart()
            }
        }

        function onUpdateProgressChanged(progress) {
            inlineUpdateProgress = progress
            if (progress > 0 && progress < 100) {
                updateInProgress = true
            }
        }

        function onUpdateStatusTextChanged(status) {
            inlineUpdateStatus = status
            if (updateInProgress) {
                appStatusText = status
                appStatusColor = colorSuccess
            }
        }

        function onUpdateWindowVisibleChanged(visible) {
            updateInProgress = visible
            if (!visible) {
                inlineUpdateProgress = 0
            }
        }
        
        function onProfileDescChanged(desc) {
            profileDesc.text = desc
        }
        
        function onButtonStatusChanged(status) {
            buttonStatus.text = status
        }
        
        function onAutoStartChanged(enabled) {
            autoStartCheckbox.checked = enabled
        }
        
        function onStartMinimizedChanged(enabled) {
            startMinimizedCheckbox.checked = enabled
        }
    }
    
    Connections {
        target: trayManager
        
        function onHideToTrayEnabledChanged(enabled) {
            hideToTrayCheckbox.checked = enabled
        }
        
        function onTrayAvailableChanged(available) {
            hideToTrayCheckbox.enabled = available
        }
    }
    
    // CUSTOM COMPONENTS
    component SectionCard: Item {
        property string title: ""
        property string subtitle: ""
        property int cardHeight: 100
        property bool flat: false
        property bool topDivider: false
        default property alias content: contentArea.children

        width: parent.width
        height: (topDivider ? 17 : 0) + (headerRow.visible ? headerRow.height + 12 : 8) + card.height + 16

        // Full-bleed divider announcing a new section.
        Rectangle {
            visible: topDivider
            width: parent.width
            height: 1
            anchors.top: parent.top
            color: colorBorder
        }
        
        Row {
            id: headerRow
            visible: title !== ""
            anchors.left: parent.left
            anchors.leftMargin: 28
            anchors.top: parent.top
            anchors.topMargin: topDivider ? 16 : 0
            spacing: 10

            Text {
                text: title
                font.pixelSize: 10
                font.weight: Font.DemiBold
                font.letterSpacing: 1.2
                font.capitalization: Font.AllUppercase
                color: colorTextMuted
                font.family: "Inter"
            }

            Text {
                text: subtitle
                font.pixelSize: 10
                color: colorInactive
                font.family: "Inter"
            }
        }

        // Soft borderless group (macOS-settings style): a barely-lighter
        // rounded surface holds the rows - grouping without box borders.
        Rectangle {
            id: card
            anchors.top: headerRow.visible ? headerRow.bottom : parent.top
            anchors.topMargin: headerRow.visible ? 8 : 0
            anchors.left: parent.left
            anchors.leftMargin: flat ? 0 : 28
            anchors.right: parent.right
            anchors.rightMargin: flat ? 0 : 28
            height: cardHeight
            radius: 12
            color: flat ? "transparent" : colorCard
            border.width: 0

            Item {
                id: contentArea
                anchors.fill: parent
                anchors.leftMargin: flat ? 0 : 16
                anchors.rightMargin: flat ? 0 : 16
                anchors.topMargin: flat ? 6 : 12
                anchors.bottomMargin: flat ? 6 : 12
            }
        }
    }
    
    component IconActionButton: Item {
        id: iab
        property string svg: ""
        property bool active: true
        property bool actionEnabled: true
        property string tip: ""
        property color glyphColor: iab.active ? colorTextPrimary : (iabMouse.containsMouse && iab.actionEnabled ? colorTextSecondary : colorInactive)
        property real glyphScale: 0.75
        property real strokeW: 2
        signal clicked()

        width: 28
        height: 28
        opacity: actionEnabled ? 1.0 : 0.45

        ToolTip.visible: iabMouse.containsMouse && tip !== ""
        ToolTip.delay: 350
        ToolTip.text: tip

        Rectangle {
            anchors.fill: parent
            radius: 7
            color: iabMouse.containsMouse && iab.actionEnabled ? colorSurfaceHover : "transparent"
        }

        Shape {
            anchors.centerIn: parent
            width: 24
            height: 24
            scale: iab.glyphScale
            preferredRendererType: Shape.CurveRenderer

            ShapePath {
                strokeWidth: iab.strokeW
                strokeColor: iab.glyphColor
                fillColor: "transparent"
                capStyle: ShapePath.RoundCap
                joinStyle: ShapePath.RoundJoin
                PathSvg { path: iab.svg }
            }
        }

        MouseArea {
            id: iabMouse
            anchors.fill: parent
            hoverEnabled: true
            cursorShape: iab.actionEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor
            onClicked: if (iab.actionEnabled) iab.clicked()
        }
    }

    component SettingsRow: Item {
        id: srow
        property string label: ""
        property string value: ""
        property color valueColor: colorTextMuted
        property bool rowEnabled: true
        property bool showChevron: true
        signal clicked()

        width: parent.width
        height: 40
        opacity: rowEnabled ? 1.0 : 0.45

        Rectangle {
            anchors.fill: parent
            color: srowMouse.containsMouse && srow.rowEnabled ? colorSurfaceHover : "transparent"
        }

        Text {
            text: srow.label
            font.pixelSize: 12
            color: colorTextSecondary
            anchors.left: parent.left
            anchors.leftMargin: 28
            anchors.verticalCenter: parent.verticalCenter
            font.family: "Inter"
        }

        Text {
            id: srowChevron
            text: "\\u203A"
            visible: srow.showChevron
            font.pixelSize: 14
            color: srowMouse.containsMouse && srow.rowEnabled ? colorTextSecondary : colorInactive
            anchors.right: parent.right
            anchors.rightMargin: 28
            anchors.verticalCenter: parent.verticalCenter
            font.family: "Inter"
        }

        Text {
            text: srow.value
            font.pixelSize: 11
            color: srow.valueColor
            anchors.right: srow.showChevron ? srowChevron.left : parent.right
            anchors.rightMargin: srow.showChevron ? 10 : 28
            anchors.left: parent.left
            anchors.leftMargin: 150
            horizontalAlignment: Text.AlignRight
            elide: Text.ElideRight
            anchors.verticalCenter: parent.verticalCenter
            font.family: "Inter"
        }

        MouseArea {
            id: srowMouse
            anchors.fill: parent
            hoverEnabled: true
            cursorShape: srow.rowEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor
            onClicked: if (srow.rowEnabled) srow.clicked()
        }
    }

    component AxisBar: Item {
        property string label: ""
        property color barColor: colorPrimary
        property real value: 0.0
        property real direction: 0.0 // -1 to 1 for steering direction
        property var gradientColors: mainWindow.silverGradient
        
        width: parent.width
        height: 30

        Row {
            anchors.fill: parent
            spacing: 0

            Text {
                text: label
                font.pixelSize: 12
                font.weight: Font.DemiBold
                color: colorTextSecondary
                width: 70
                anchors.verticalCenter: parent.verticalCenter
                font.family: "Inter"
            }
            
            Item {
                width: parent.width - 70 - 40
                height: parent.height
                anchors.verticalCenter: parent.verticalCenter
                
                Rectangle {
                    id: axisBarBg
                    anchors.fill: parent
                    anchors.leftMargin: 5
                    anchors.rightMargin: 5
                    height: 10
                    radius: 5
                    color: colorSurfaceHover
                    anchors.verticalCenter: parent.verticalCenter
                    clip: true
                    
                    Rectangle {
                        width: parent.width * Math.max(0, Math.min(1, value))
                        height: parent.height
                        radius: 5
                        
                        gradient: Gradient {
                            orientation: Gradient.Horizontal
                            GradientStop { position: gradientColors[0].position; color: gradientColors[0].color }
                            GradientStop { position: gradientColors[1].position; color: gradientColors[1].color }
                            GradientStop { position: gradientColors[2].position; color: gradientColors[2].color }
                        }
                        
                        Behavior on width {
                            NumberAnimation { duration: 50; easing.type: Easing.OutQuad }
                        }
                    }
                }
            }
            
            Text {
                text: Math.round(Math.max(0, Math.min(1, value)) * 100) + "%"
                font.pixelSize: 10
                color: colorTextMuted
                width: 30
                anchors.verticalCenter: parent.verticalCenter
                horizontalAlignment: Text.AlignRight
                font.family: "Inter"
            }
        }
    }
    
    component StyledButton: Rectangle {
        id: btn
        property string text: ""
        property bool primary: false
        property bool danger: false
        property bool secondary: false
        property bool hoverEffect: true
        property bool hovered: btnMouse.containsMouse && btn.enabled
        property int fontSize: 12
        signal clicked()
        
        width: 100
        height: 34
        radius: 7
        color: {
            if (!enabled) return colorSurface
            if (danger) return btn.hovered && hoverEffect ? "#dc2626" : colorError
            if (primary) return btn.hovered && hoverEffect ? colorPrimaryHover : colorPrimary
            return btn.hovered && hoverEffect ? colorSurfaceHover : colorSurface
        }
        border.color: colorBorder
        border.width: 0
        onEnabledChanged: {
            if (!enabled) {
                opacity = 1.0
            }
        }

        Text {
            anchors.centerIn: parent
            text: btn.text
            font.pixelSize: fontSize
            font.weight: (primary || danger) ? Font.DemiBold : Font.Medium
            color: !btn.enabled ? colorTextMuted : (danger ? "#fafafa" : (primary ? (darkMode ? "#141414" : "#fafafa") : (btn.hovered && hoverEffect ? colorTextPrimary : colorTextSecondary)))
            font.family: "Inter"
            elide: Text.ElideRight
            horizontalAlignment: Text.AlignHCenter
            width: parent.width - 20
        }
        
        MouseArea {
            id: btnMouse
            anchors.fill: parent
            enabled: btn.enabled
            hoverEnabled: true
            cursorShape: Qt.PointingHandCursor
            onExited: {
                btn.opacity = 1.0
            }
            onClicked: if (btn.enabled) btn.clicked()
            
            onPressed: btn.opacity = hoverEffect ? 0.92 : 1.0
            onReleased: btn.opacity = 1.0
            onCanceled: {
                btn.opacity = 1.0
            }
        }
        
        Behavior on opacity {
            NumberAnimation { duration: 80; easing.type: Easing.OutQuad }
        }
    }
    
    component StyledComboBox: Rectangle {
        id: combo
        property var model: []
        property int currentIndex: 0
        property string currentText: model[currentIndex] || ""
        property bool hovered: false
        
        width: 200
        height: 38
        radius: 7
        bottomLeftRadius: popup.visible ? 0 : 7
        bottomRightRadius: popup.visible ? 0 : 7
        color: popup.visible ? colorSurface : (combo.hovered ? colorSurfaceHover : colorSurface)
        border.color: popup.visible ? colorBorderLight : colorBorder
        border.width: 1
        onEnabledChanged: {
            if (!enabled) {
                hovered = false
            }
        }

        Text {
            anchors.left: parent.left
            anchors.leftMargin: 12
            anchors.verticalCenter: parent.verticalCenter
            text: combo.currentText
            font.pixelSize: 12
            color: colorTextPrimary
            font.family: "Inter"
            elide: Text.ElideRight
            width: parent.width - 40
        }

        Text {
            anchors.right: parent.right
            anchors.rightMargin: 12
            anchors.verticalCenter: parent.verticalCenter
            text: "\\u25BE"
            font.pixelSize: 10
            color: combo.hovered || popup.visible ? colorTextSecondary : colorTextMuted
            rotation: popup.visible ? 180 : 0
            Behavior on rotation { NumberAnimation { duration: 140; easing.type: Easing.OutQuad } }
        }

        MouseArea {
            id: comboMouse
            anchors.fill: parent
            enabled: combo.enabled
            hoverEnabled: true
            cursorShape: Qt.PointingHandCursor
            onEntered: combo.hovered = true
            onExited: combo.hovered = false
            onCanceled: combo.hovered = false
            onClicked: popup.visible ? popup.close() : popup.open()
        }

        // Dropdown extends seamlessly from the box above.
        Popup {
            id: popup
            y: parent.height - 1
            width: parent.width
            height: Math.min(listView.contentHeight + 8, 300)
            padding: 4

            enter: Transition {
                NumberAnimation { property: "opacity"; from: 0.0; to: 1.0; duration: 100 }
            }
            exit: Transition {
                NumberAnimation { property: "opacity"; from: 1.0; to: 0.0; duration: 80 }
            }

            background: Rectangle {
                color: colorSurface
                radius: 7
                topLeftRadius: 0
                topRightRadius: 0
                border.color: colorBorderLight
                border.width: 1
            }

            contentItem: ListView {
                id: listView
                clip: true
                model: combo.model
                currentIndex: combo.currentIndex

                ScrollBar.vertical: ScrollBar {
                    id: comboScrollBar
                    width: 5
                    contentItem: Rectangle {
                        implicitWidth: 5
                        radius: 2.5
                        color: colorBorderLight
                        opacity: comboScrollBar.active ? 0.8 : 0.3
                    }
                }

                delegate: Rectangle {
                    width: ListView.view.width
                    height: 32
                    color: delegateMouse.containsMouse ? colorSurfaceHover : "transparent"
                    radius: 5

                    Text {
                        anchors.left: parent.left
                        anchors.leftMargin: 12
                        width: parent.width - 24
                        anchors.verticalCenter: parent.verticalCenter
                        text: modelData
                        font.pixelSize: 12
                        font.weight: index === combo.currentIndex ? Font.DemiBold : Font.Normal
                        color: index === combo.currentIndex ? colorTextPrimary : colorTextSecondary
                        font.family: "Inter"
                        elide: Text.ElideRight
                    }

                    MouseArea {
                        id: delegateMouse
                        anchors.fill: parent
                        hoverEnabled: true
                        cursorShape: Qt.PointingHandCursor
                        onClicked: {
                            combo.currentIndex = index
                            popup.close()
                        }
                    }
                }
            }
        }
    }

    component StyledCheckBox: Item {
        id: check
        property string text: ""
        property bool checked: false
        property bool enabled: true
        signal toggled()

        width: checkRow.width
        height: 20

        Row {
            id: checkRow
            spacing: 8

            Rectangle {
                width: 32
                height: 18
                radius: 9
                color: check.checked && check.enabled ? colorPrimary : colorSurfaceHover
                border.color: check.enabled ? (check.checked ? colorPrimary : colorBorder) : colorBorder
                border.width: 1
                anchors.verticalCenter: parent.verticalCenter
                opacity: check.enabled ? 1.0 : 0.45

                Behavior on color { ColorAnimation { duration: 140 } }
                Behavior on border.color { ColorAnimation { duration: 140 } }

                Rectangle {
                    width: 12
                    height: 12
                    radius: 6
                    x: check.checked ? parent.width - width - 3 : 3
                    anchors.verticalCenter: parent.verticalCenter
                    color: check.checked ? colorBg : colorTextMuted
                    Behavior on x { NumberAnimation { duration: 140; easing.type: Easing.OutQuad } }
                    Behavior on color { ColorAnimation { duration: 140 } }
                }
            }

            Text {
                text: check.text
                visible: check.text !== ""
                font.pixelSize: 11
                color: check.enabled ? colorTextSecondary : colorInactive
                anchors.verticalCenter: parent.verticalCenter
                font.family: "Inter"
            }
        }

        MouseArea {
            anchors.fill: parent
            cursorShape: check.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
            onClicked: {
                if (check.enabled) {
                    check.checked = !check.checked
                    check.toggled()
                }
            }
        }
    }
    
    // UPDATE PROGRESS WINDOW
    Window {
        id: updateProgressWindow
        width: 480
        height: 280
        visible: false
        color: colorBg
        modality: Qt.ApplicationModal
        flags: Qt.Dialog | Qt.WindowStaysOnTopHint
        title: "TrueAxis Update"
        
        property int progressValue: 0
        property string statusText: "Initializing update..."
        
        // The update flow lives entirely in the main window's status bar now;
        // this window stays hidden and is kept only for compatibility.
        Connections {
            target: backend
            function onUpdateProgressChanged(progress) {
                updateProgressWindow.progressValue = progress
            }
            function onUpdateStatusTextChanged(status) {
                updateProgressWindow.statusText = status
            }
            function onUpdateWindowVisibleChanged(visible) {
                updateProgressWindow.visible = false
            }
        }
        
        Rectangle {
            anchors.fill: parent
            color: colorBg
            
            Column {
                anchors.centerIn: parent
                spacing: 32
                width: parent.width - 64
                
                // Header
                Column {
                    width: parent.width
                    spacing: 12
                    
                    // Icon
                    Rectangle {
                        width: 64
                        height: 64
                        radius: 32
                        anchors.horizontalCenter: parent.horizontalCenter
                        color: colorCard
                        border.width: 2
                        border.color: colorPrimary
                        
                        Text {
                            anchors.centerIn: parent
                            text: "R"
                            font.pixelSize: 36
                            color: colorPrimary
                            font.bold: true
                        }
                    }
                    
                    Text {
                        text: "Updating TrueAxis"
                        font.pixelSize: 24
                        font.bold: true
                        color: colorTextPrimary
                        anchors.horizontalCenter: parent.horizontalCenter
                        font.family: "Inter"
                    }
                }
                
                // Progress Section
                Column {
                    width: parent.width
                    spacing: 16
                    
                    // Status text
                    Text {
                        text: updateProgressWindow.statusText
                        font.pixelSize: 13
                        color: colorTextSecondary
                        anchors.horizontalCenter: parent.horizontalCenter
                        font.family: "Inter"
                    }
                    
                    // Progress bar
                    Rectangle {
                        width: parent.width
                        height: 8
                        radius: 4
                        color: colorCard
                        border.width: 1
                        border.color: colorBorder
                        
                        Rectangle {
                            width: (parent.width - 2) * (updateProgressWindow.progressValue / 100)
                            height: parent.height - 2
                            radius: 3
                            x: 1
                            y: 1
                            
                            gradient: Gradient {
                                GradientStop { position: 0.0; color: "#4a4a4a" }
                                GradientStop { position: 0.5; color: "#a8a8a8" }
                                GradientStop { position: 1.0; color: "#6e6e6e" }
                            }
                            
                            Behavior on width {
                                NumberAnimation { duration: 200; easing.type: Easing.OutQuad }
                            }
                        }
                    }
                    
                    // Percentage
                    Text {
                        text: updateProgressWindow.progressValue + "%"
                        font.pixelSize: 14
                        font.bold: true
                        color: colorPrimary
                        anchors.horizontalCenter: parent.horizontalCenter
                        font.family: "Inter"
                    }
                }
                
                // Info text
                Text {
                    text: "The update is downloaded from trueaxis.nl, verified, and installed into your TrueAxis folder.\nThe application will restart automatically."
                    font.pixelSize: 11
                    color: colorTextMuted
                    horizontalAlignment: Text.AlignHCenter
                    anchors.horizontalCenter: parent.horizontalCenter
                    font.family: "Inter"
                }
            }
        }
    }
}
"""

def create_app_icon_pixmap(size):
    """Render the TrueAxis icon at a given size: a silver steering wheel
    on a graphite rounded square, matching the app theme."""
    pm = QPixmap(size, size)
    pm.fill(Qt.GlobalColor.transparent)
    p = QPainter(pm)
    p.setRenderHint(QPainter.Antialiasing)
    s = float(size)

    # Graphite rounded-square background
    m = s * 0.03
    rect = QRectF(m, m, s - 2 * m, s - 2 * m)
    grad = QLinearGradient(0, 0, 0, s)
    grad.setColorAt(0.0, QColor("#2e2e2e"))
    grad.setColorAt(1.0, QColor("#141414"))
    p.setBrush(QBrush(grad))
    p.setPen(QPen(QColor("#404040"), max(1.0, s * 0.02)))
    corner = s * 0.22
    p.drawRoundedRect(rect, corner, corner)

    silver = QColor("#dcdcdc")
    cx = cy = s / 2.0

    # Wheel rim
    r = s * 0.30
    rim_w = max(1.5, s * 0.075)
    p.setPen(QPen(silver, rim_w))
    p.setBrush(Qt.BrushStyle.NoBrush)
    p.drawEllipse(QRectF(cx - r, cy - r, 2 * r, 2 * r))

    # Spokes: left, right, down
    hub_r = s * 0.085
    spoke_w = max(1.2, s * 0.06)
    p.setPen(QPen(silver, spoke_w))
    for ang_deg in (180.0, 0.0, 90.0):
        ang = math.radians(ang_deg)
        x1 = cx + hub_r * math.cos(ang)
        y1 = cy + hub_r * math.sin(ang)
        x2 = cx + (r - rim_w * 0.35) * math.cos(ang)
        y2 = cy + (r - rim_w * 0.35) * math.sin(ang)
        p.drawLine(QPointF(x1, y1), QPointF(x2, y2))

    # Hub
    p.setPen(Qt.PenStyle.NoPen)
    p.setBrush(silver)
    p.drawEllipse(QRectF(cx - hub_r, cy - hub_r, 2 * hub_r, 2 * hub_r))

    p.end()
    return pm


def create_app_icon():
    """Multi-resolution QIcon so the taskbar, alt-tab and tray all stay crisp."""
    icon = QIcon()
    for sz in (16, 24, 32, 48, 64, 128, 256):
        icon.addPixmap(create_app_icon_pixmap(sz))
    return icon


class SystemTrayManager(QObject):
    """Manages system tray functionality for TrueAxis"""
    hideToTrayEnabledChanged = Signal(bool)
    trayAvailableChanged = Signal(bool)
    
    def __init__(self, main_window=None, backend=None):
        super().__init__()
        self.main_window = main_window
        self.backend = backend
        self.tray_icon = None
        self._hide_to_tray_enabled = False
        self._tray_available = False
        self.setup_tray()
    
    @Property(bool, notify=hideToTrayEnabledChanged)
    def hideToTrayEnabled(self):
        return self._hide_to_tray_enabled
    
    @Property(bool, notify=trayAvailableChanged)
    def trayAvailable(self):
        return self._tray_available
    
    @Slot(bool)
    def set_hide_to_tray(self, enabled):
        self._hide_to_tray_enabled = enabled
        self.hideToTrayEnabledChanged.emit(enabled)
        self.save_settings()
    
    def setup_tray(self):
        """Setup system tray icon and menu"""
        if QSystemTrayIcon.isSystemTrayAvailable():
            self._tray_available = True
            self.trayAvailableChanged.emit(True)
            
            # Create tray icon
            self.tray_icon = QSystemTrayIcon()
            
            # Create a simple icon for TrueAxis
            icon = self.create_trueaxis_icon()
            self.tray_icon.setIcon(icon)
            self.tray_icon.setToolTip("TrueAxis - Running in background")
            
            # Create tray menu
            tray_menu = QMenu()
            
            # Show/Hide action
            self.show_action = QAction("Show TrueAxis", tray_menu)
            self.show_action.triggered.connect(self.show_main_window)
            tray_menu.addAction(self.show_action)
            
            tray_menu.addSeparator()
            
            # Exit action
            exit_action = QAction("Exit", tray_menu)
            exit_action.triggered.connect(self.quit_application)
            tray_menu.addAction(exit_action)
            
            self.tray_icon.setContextMenu(tray_menu)
            
            # Connect tray icon click
            self.tray_icon.activated.connect(self.on_tray_activated)
        else:
            self._tray_available = False
            self.trayAvailableChanged.emit(False)
            print("System tray is not available on this system")
    
    def create_trueaxis_icon(self):
        """Create a TrueAxis tray icon"""
        return create_app_icon()
    
    @Slot()
    def show_main_window(self):
        """Show the main window from tray"""
        if self.main_window:
            self.main_window.show()
            self.main_window.raise_()
            self.main_window.requestActivate()
            self.hide_tray()
            if self.show_action:
                self.show_action.setText("Show TrueAxis")
    
    @Slot()
    def hide_main_window(self):
        """Hide the main window to tray"""
        if self.main_window:
            self.main_window.hide()
    
    @Slot()
    def show_tray(self):
        """Show the tray icon"""
        if self.tray_icon and self._tray_available:
            self.tray_icon.show()
    
    @Slot()
    def hide_tray(self):
        """Hide the tray icon"""
        if self.tray_icon:
            self.tray_icon.hide()
    
    def on_tray_activated(self, reason):
        """Handle tray icon activation"""
        if reason == QSystemTrayIcon.ActivationReason.DoubleClick:
            self.show_main_window()
        elif reason == QSystemTrayIcon.ActivationReason.Trigger:
            pass  # Single click - do nothing
    
    def quit_application(self):
        """Quit the application"""
        app = QApplication.instance()
        if app:
            app.quit()
    
    @Slot()
    def save_settings(self):
        """Save tray settings"""
        # Don't save during backend initialization
        if self.backend and hasattr(self.backend, '_initializing') and self.backend._initializing:
            return

        try:
            settings = _load_json_with_backup(SETTINGS_FILE) or {}

            settings['tray_settings'] = {
                'hide_to_tray': self._hide_to_tray_enabled
            }

            _atomic_json_save(SETTINGS_FILE, settings)
        except Exception as e:
            print(f"Error saving tray settings: {e}")

    def load_settings(self):
        """Load tray settings"""
        try:
            settings = _load_json_with_backup(SETTINGS_FILE)
            if settings and 'tray_settings' in settings:
                tray_settings = settings['tray_settings']
                self._hide_to_tray_enabled = tray_settings.get('hide_to_tray', False)
                self.hideToTrayEnabledChanged.emit(self._hide_to_tray_enabled)
        except Exception as e:
            print(f"Error loading tray settings: {e}")


class AutoStartManager(QObject):
    """Manages auto-start with Windows functionality"""
    
    def __init__(self):
        super().__init__()
        self.app_name = "TrueAxis"

        if getattr(sys, 'frozen', False):
            # PyInstaller executable
            self.app_path = os.path.abspath(sys.executable)
            self.script_path = None
        else:
            # Running as Python script - need python.exe + script path
            self.app_path = os.path.abspath(sys.executable)
            script_candidate = os.path.abspath(sys.argv[0])
            self.script_path = script_candidate if os.path.exists(script_candidate) else os.path.abspath(__file__)
    
    def enable_auto_start(self, start_minimized=True):
        """Enable auto-start with Windows"""
        try:
            if sys.platform == 'win32':
                return self._enable_windows_auto_start(start_minimized)
            elif sys.platform == 'darwin':  # macOS
                return self._enable_macos_auto_start(start_minimized)
            elif sys.platform == 'linux':
                return self._enable_linux_auto_start(start_minimized)
            else:
                print(f"Auto-start not supported on platform: {sys.platform}")
                return False
        except Exception as e:
            print(f"Error enabling auto-start: {e}")
            return False
    
    def disable_auto_start(self):
        """Disable auto-start with Windows"""
        try:
            if sys.platform == 'win32':
                return self._disable_windows_auto_start()
            elif sys.platform == 'darwin':  # macOS
                return self._disable_macos_auto_start()
            elif sys.platform == 'linux':
                return self._disable_linux_auto_start()
            else:
                print(f"Auto-start not supported on platform: {sys.platform}")
                return False
        except Exception as e:
            print(f"Error disabling auto-start: {e}")
            return False
    
    def is_auto_start_enabled(self):
        """Check if auto-start is enabled"""
        try:
            if sys.platform == 'win32':
                return self._is_windows_auto_start_enabled()
            elif sys.platform == 'darwin':  # macOS
                return self._is_macos_auto_start_enabled()
            elif sys.platform == 'linux':
                return self._is_linux_auto_start_enabled()
            else:
                return False
        except Exception as e:
            print(f"Error checking auto-start: {e}")
            return False
    
    def _windows_run_key_path(self):
        return r"Software\Microsoft\Windows\CurrentVersion\Run"

    def _launch_args(self, start_minimized=True):
        args = ["--startup"]
        if self.script_path:
            args.insert(0, self.script_path)
        if start_minimized:
            args.append("--minimized")
        return args

    def _windows_launch_parts(self, start_minimized=True):
        args = self._launch_args(start_minimized)
        working_dir = os.path.dirname(self.script_path or self.app_path)
        return self.app_path, subprocess.list2cmdline(args), working_dir

    def _windows_launch_command(self, start_minimized=True):
        return subprocess.list2cmdline([self.app_path] + self._launch_args(start_minimized))

    def _windows_startup_shortcut_path(self):
        try:
            import winshell
            startup_dir = winshell.startup()
        except Exception:
            startup_dir = os.path.join(os.getenv("APPDATA", ""), "Microsoft", "Windows", "Start Menu", "Programs", "Startup")
        return os.path.join(startup_dir, f"{self.app_name}.lnk")

    def _command_targets_current_app(self, command):
        if not command:
            return False
        normalized_command = os.path.normcase(str(command).replace("\\", "/"))
        target = os.path.normcase(self.app_path.replace("\\", "/"))
        return target in normalized_command and os.path.exists(self.app_path)

    def _shortcut_targets_current_app(self, shortcut_path):
        if not os.path.exists(shortcut_path):
            return False
        try:
            from win32com.client import Dispatch
            shell = Dispatch("WScript.Shell")
            shortcut = shell.CreateShortCut(shortcut_path)
            target_path = os.path.abspath(shortcut.Targetpath or "")
            if os.path.normcase(target_path) != os.path.normcase(self.app_path):
                return False
            if self.script_path and os.path.normcase(self.script_path) not in os.path.normcase(shortcut.Arguments or ""):
                return False
            return os.path.exists(target_path)
        except Exception as e:
            print(f"Windows startup shortcut check error: {e}")
            return False

    def _write_windows_startup_shortcut(self, start_minimized=True):
        try:
            shortcut_path = self._windows_startup_shortcut_path()
            os.makedirs(os.path.dirname(shortcut_path), exist_ok=True)
            target, args, working_dir = self._windows_launch_parts(start_minimized)
            from win32com.client import Dispatch
            shell = Dispatch("WScript.Shell")
            shortcut = shell.CreateShortCut(shortcut_path)
            shortcut.Targetpath = target
            shortcut.Arguments = args
            shortcut.WorkingDirectory = working_dir
            shortcut.IconLocation = target
            shortcut.Description = "Start TrueAxis when Windows starts"
            shortcut.save()
            return self._shortcut_targets_current_app(shortcut_path)
        except Exception as e:
            print(f"Windows startup shortcut error: {e}")
            return False

    def _enable_windows_auto_start(self, start_minimized=True):
        """Enable auto-start on Windows using both Run key and Startup shortcut."""
        registry_ok = False
        shortcut_ok = False
        try:
            import winreg
            with winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER, self._windows_run_key_path(), 0, winreg.KEY_SET_VALUE) as reg_key:
                reg_value = self._windows_launch_command(start_minimized)
                winreg.SetValueEx(reg_key, self.app_name, 0, winreg.REG_SZ, reg_value)
            registry_ok = self._is_windows_registry_current()
        except Exception as e:
            print(f"Windows registry error: {e}")

        shortcut_ok = self._write_windows_startup_shortcut(start_minimized)
        return registry_ok or shortcut_ok
    
    def _disable_windows_auto_start(self):
        """Disable auto-start on Windows"""
        registry_ok = False
        shortcut_ok = False
        try:
            import winreg
            with winreg.OpenKey(winreg.HKEY_CURRENT_USER, self._windows_run_key_path(), 0, winreg.KEY_SET_VALUE) as reg_key:
                try:
                    winreg.DeleteValue(reg_key, self.app_name)
                except FileNotFoundError:
                    pass
            registry_ok = not self._is_windows_registry_current()
        except Exception as e:
            print(f"Windows registry error: {e}")

        try:
            shortcut_path = self._windows_startup_shortcut_path()
            if os.path.exists(shortcut_path):
                os.remove(shortcut_path)
            shortcut_ok = not os.path.exists(shortcut_path)
        except Exception as e:
            print(f"Windows startup shortcut removal error: {e}")
        return registry_ok and shortcut_ok

    def _is_windows_registry_current(self):
        try:
            import winreg
            with winreg.OpenKey(winreg.HKEY_CURRENT_USER, self._windows_run_key_path(), 0, winreg.KEY_READ) as reg_key:
                value, _ = winreg.QueryValueEx(reg_key, self.app_name)
                return self._command_targets_current_app(value)
        except FileNotFoundError:
            return False
        except OSError:
            return False
        except Exception as e:
            print(f"Windows registry check error: {e}")
            return False
    
    def _is_windows_auto_start_enabled(self):
        """Check if auto-start is enabled on Windows"""
        return self._is_windows_registry_current() or self._shortcut_targets_current_app(self._windows_startup_shortcut_path())
    
    def _enable_macos_auto_start(self, start_minimized=True):
        """Enable auto-start on macOS using launchd"""
        try:
            launchd_dir = Path.home() / "Library" / "LaunchAgents"
            launchd_dir.mkdir(parents=True, exist_ok=True)
            program_arguments = [self.app_path] + self._launch_args(start_minimized)
            
            plist_content = {
                'Label': f'com.trueaxis.{self.app_name.lower()}',
                'ProgramArguments': program_arguments,
                'RunAtLoad': True,
                'KeepAlive': False,
                'WorkingDirectory': os.path.dirname(self.script_path or self.app_path),
            }
            
            plist_path = launchd_dir / f'com.trueaxis.{self.app_name.lower()}.plist'
            with open(plist_path, 'wb') as f:
                plistlib.dump(plist_content, f)
            
            # Load the launch agent
            subprocess.run(['launchctl', 'load', str(plist_path)])
            return True
        except Exception as e:
            print(f"macOS launchd error: {e}")
            return False
    
    def _disable_macos_auto_start(self):
        """Disable auto-start on macOS"""
        try:
            plist_path = Path.home() / "Library" / "LaunchAgents" / f'com.trueaxis.{self.app_name.lower()}.plist'
            
            if plist_path.exists():
                # Unload the launch agent
                subprocess.run(['launchctl', 'unload', str(plist_path)])
                plist_path.unlink()
            return True
        except Exception as e:
            print(f"macOS launchd error: {e}")
            return False
    
    def _is_macos_auto_start_enabled(self):
        """Check if auto-start is enabled on macOS"""
        try:
            plist_path = Path.home() / "Library" / "LaunchAgents" / f'com.trueaxis.{self.app_name.lower()}.plist'
            return plist_path.exists()
        except Exception:
            return False
    
    def _enable_linux_auto_start(self, start_minimized=True):
        """Enable auto-start on Linux using .desktop file"""
        try:
            autostart_dir = Path.home() / ".config" / "autostart"
            autostart_dir.mkdir(parents=True, exist_ok=True)
            exec_cmd = subprocess.list2cmdline([self.app_path] + self._launch_args(start_minimized))
            
            desktop_content = f"""[Desktop Entry]
Type=Application
Name={self.app_name}
Exec={exec_cmd}
Comment=TrueAxis Application
Categories=Utility;
StartupNotify=false
Terminal=false
Hidden=false
"""
            
            desktop_path = autostart_dir / f"{self.app_name.lower()}.desktop"
            with open(desktop_path, 'w') as f:
                f.write(desktop_content)
            
            # Make it executable
            os.chmod(desktop_path, 0o755)
            return True
        except Exception as e:
            print(f"Linux autostart error: {e}")
            return False
    
    def _disable_linux_auto_start(self):
        """Disable auto-start on Linux"""
        try:
            desktop_path = Path.home() / ".config" / "autostart" / f"{self.app_name.lower()}.desktop"
            if desktop_path.exists():
                desktop_path.unlink()
            return True
        except Exception as e:
            print(f"Linux autostart error: {e}")
            return False
    
    def _is_linux_auto_start_enabled(self):
        """Check if auto-start is enabled on Linux"""
        try:
            desktop_path = Path.home() / ".config" / "autostart" / f"{self.app_name.lower()}.desktop"
            return desktop_path.exists()
        except Exception:
            return False


class DeviceSlot:
    """Represents a single device-to-virtual-gamepad mapping slot.
    Each slot independently maps one physical device (or a combined pair) to one virtual Xbox controller."""

    def __init__(self, slot_id=0):
        self.slot_id = slot_id
        self.joystick = None
        self.vg_gamepad = None
        self.input_reader = None
        self.mapper_thread = None
        self.profile = "Logitech G29 (Standard)"
        self.button_mapping = {}
        self.axis_values = []
        self.button_states = []
        self.running = False
        self.device_name = ""
        self.device_index = -1

        # Per-slot settings
        self.invert_gas = False
        self.invert_brake = False
        self.square_input = False
        self.square_area = 0.5
        self.deadzone_enabled = False
        self.deadzone = 0.0
        self.sensitivity_curve = "linear"
        self.steering_range = 900
        self.center_spring = 0.0
        self.center_spring_width = 0.05
        self.center_spring_ramp = False
        self.center_spring_ramp_width = 5.0
        self.neutral_steering_button = -1
        self.steering_action_button = -1
        self.steering_action_max_percent = 0
        self.steering_action_mode = "hold"
        self.steering_action_toggle_active = False
        self.steering_action_last_pressed = False

        # Combined device mode: secondary device feeds pedal axes into this slot's virtual gamepad
        self.combined_mode = False
        self.combined_pedal_device_name = ""
        self.combined_pedal_joystick = None
        self.combined_pedal_input_reader = None

    def to_dict(self):
        """Serialize slot configuration for saving"""
        return {
            'device_name': self.device_name,
            'device_index': self.device_index,
            'profile': self.profile,
            'button_mapping': self.button_mapping,
            'invert_gas': self.invert_gas,
            'invert_brake': self.invert_brake,
            'square_input': self.square_input,
            'square_area': self.square_area,
            'deadzone_enabled': self.deadzone_enabled,
            'deadzone': self.deadzone,
            'sensitivity_curve': self.sensitivity_curve,
            'steering_range': self.steering_range,
            'center_spring': self.center_spring,
            'center_spring_width': self.center_spring_width,
            'center_spring_ramp': self.center_spring_ramp if SOFT_RAMP_AVAILABLE else False,
            'center_spring_ramp_width': self.center_spring_ramp_width,
            'neutral_steering_button': self.neutral_steering_button,
            'steering_action_button': self.steering_action_button,
            'steering_action_max_percent': self.steering_action_max_percent,
            'steering_action_mode': self.steering_action_mode,
            'combined_mode': self.combined_mode,
            'combined_pedal_device_name': self.combined_pedal_device_name,
        }

    def from_dict(self, data):
        """Load slot configuration from saved data"""
        self.device_name = data.get('device_name', '')
        self.device_index = data.get('device_index', -1)
        self.profile = data.get('profile', 'Logitech G29 (Standard)')
        self.button_mapping = data.get('button_mapping', {})
        self.invert_gas = data.get('invert_gas', False)
        self.invert_brake = data.get('invert_brake', False)
        self.square_input = data.get('square_input', False)
        self.square_area = data.get('square_area', 0.5)
        self.deadzone_enabled = data.get('deadzone_enabled', False)
        self.deadzone = data.get('deadzone', 0.0)
        self.sensitivity_curve = data.get('sensitivity_curve', 'linear')
        self.steering_range = data.get('steering_range', 900)
        self.center_spring = data.get('center_spring', 0.0)
        self.center_spring_width = data.get('center_spring_width', 0.05)
        self.center_spring_ramp = data.get('center_spring_ramp', False) if SOFT_RAMP_AVAILABLE else False
        self.center_spring_ramp_width = data.get('center_spring_ramp_width', 5.0)
        self.neutral_steering_button = int(data.get('neutral_steering_button', -1) or -1)
        self.steering_action_button = int(data.get('steering_action_button', -1) or -1)
        self.steering_action_max_percent = max(0, min(100, int(data.get('steering_action_max_percent', 0) or 0)))
        mode = str(data.get('steering_action_mode', 'hold') or 'hold').lower()
        self.steering_action_mode = mode if mode in ('hold', 'toggle') else 'hold'
        self.steering_action_toggle_active = False
        self.steering_action_last_pressed = False
        self.combined_mode = data.get('combined_mode', False)
        self.combined_pedal_device_name = data.get('combined_pedal_device_name', '')


class InputReader(QThread):
    """Separate thread for reading input to prevent UI lag"""
    axisValuesChanged = Signal(list)
    buttonStatesChanged = Signal(list)
    deviceDisconnected = Signal()

    def __init__(self):
        super().__init__()
        self.joystick = None
        self._running = True
        self._consecutive_errors = 0
        self._lock = threading.Lock()
        self._axis_values = []
        self._button_states = []
        self._last_signal_emit_at = 0.0
        self._last_emitted_buttons = []

    def set_joystick(self, joystick):
        with self._lock:
            self.joystick = joystick
            self._axis_values = []
            self._button_states = []
            self._consecutive_errors = 0

    def snapshot(self):
        with self._lock:
            return list(self._axis_values), list(self._button_states)

    def run(self):
        while self._running:
            try:
                with self._lock:
                    joystick = self.joystick

                if joystick:
                    pygame.event.pump()

                    # Read axis values
                    axis_values = []
                    num_axes = joystick.get_numaxes()
                    for i in range(num_axes):
                        try:
                            raw = joystick.get_axis(i)
                            axis_values.append((raw + 1) / 2)
                        except Exception:
                            axis_values.append(0.5)

                    # Read button states
                    button_states = []
                    num_buttons = joystick.get_numbuttons()
                    for i in range(num_buttons):
                        try:
                            button_states.append(bool(joystick.get_button(i)))
                        except Exception:
                            button_states.append(False)

                    with self._lock:
                        self._axis_values = axis_values
                        self._button_states = button_states

                    now = time.time()
                    should_emit = (
                        now - self._last_signal_emit_at >= (1.0 / 60.0)
                        or button_states != self._last_emitted_buttons
                    )
                    if should_emit:
                        self.axisValuesChanged.emit(axis_values)
                        self.buttonStatesChanged.emit(button_states)
                        self._last_signal_emit_at = now
                        self._last_emitted_buttons = list(button_states)
                    self._consecutive_errors = 0

                # Small delay to prevent CPU overload
                time.sleep(INPUT_READER_POLL_SECONDS)
            except (pygame.error, OSError) as e:
                # Device likely disconnected
                self._consecutive_errors += 1
                if self._consecutive_errors >= 3:
                    print(f"Device disconnected: {e}")
                    self.deviceDisconnected.emit()
                    with self._lock:
                        self.joystick = None
                        self._axis_values = []
                        self._button_states = []
                    self._consecutive_errors = 0
                time.sleep(1)
            except Exception as e:
                print(f"Error in input reader: {e}")
                self._consecutive_errors += 1
                if self._consecutive_errors >= 5:
                    self.deviceDisconnected.emit()
                    with self._lock:
                        self.joystick = None
                        self._axis_values = []
                        self._button_states = []
                    self._consecutive_errors = 0
                time.sleep(1)

    def stop(self):
        self._running = False
        if self.isRunning():
            self.wait(1500)


class TrueAxisBackend(QObject):
    devicesChanged = Signal(list)
    profileDescChanged = Signal(str)
    currentProfileChanged = Signal(str)
    currentDeviceIndexChanged = Signal(int)
    invertGasChanged = Signal(bool)
    invertBrakeChanged = Signal(bool)
    squareInputChanged = Signal(bool)
    squareAreaChanged = Signal(float)
    deadzoneEnabledChanged = Signal(bool)
    deadzoneChanged = Signal(float)
    steeringChanged = Signal(float)
    gasChanged = Signal(float)
    brakeChanged = Signal(float)
    statusChanged = Signal(str, str)
    isRunningChanged = Signal(bool)
    buttonStatusChanged = Signal(str)
    axisValuesChanged = Signal()
    buttonStatesChanged = Signal()
    numAxesChanged = Signal()
    numButtonsChanged = Signal()
    buttonMappingsChanged = Signal()  # Signal when button mappings are loaded/changed
    autoStartChanged = Signal(bool)
    startMinimizedChanged = Signal(bool)
    updateAvailableChanged = Signal(bool)
    vigEmInstalledChanged = Signal(bool)
    vigEmStatusChanged = Signal(str)
    currentVersionChanged = Signal()
    updateProgressChanged = Signal(int)  # Update progress percentage
    updateStatusTextChanged = Signal(str)  # Update status message
    updateWindowVisibleChanged = Signal(bool)  # Show/hide update window
    sensitivityCurveChanged = Signal(str)  # Sensitivity curve type
    steeringRangeChanged = Signal(int)  # Steering range in degrees
    centerSpringChanged = Signal(float)  # Center spring strength 0-1
    centerSpringWidthChanged = Signal(float)  # Center spring dead zone width 0-1
    centerSpringRampChanged = Signal(bool)  # Center spring ramp enabled
    centerSpringRampWidthChanged = Signal(float)  # Center spring ramp width in degrees
    sessionTimeChanged = Signal(str)  # Session timer display
    exportStatusChanged = Signal(str)  # Export/import status
    deviceDisconnected = Signal()  # Device was disconnected
    deviceSlotsChanged = Signal()  # Multi-device slot list changed
    activeSlotIndexChanged = Signal(int)  # Active UI slot changed
    macrosChanged = Signal()  # Emitted when macros list changes
    macroRecordingChanged = Signal(bool)
    macroPlayingChanged = Signal(bool)
    themeModeChanged = Signal(str)
    hardwareReportStatusChanged = Signal(str, str)
    neutralSteeringButtonChanged = Signal()
    steeringActionChanged = Signal()
    
    def __init__(self):
        super().__init__()
        
        self._running = False
        self._devices = ["Scanning devices..."]
        self._current_device_index = 0
        self._current_profile = "Logitech G29 (Standard)"
        self._invert_gas = False
        self._invert_brake = False
        self._square_input = False
        self._square_area = 0.5  # Default: 50% square area
        self._deadzone_enabled = False  # Default: deadzone disabled
        self._deadzone = 0.0  # Default: 0% deadzone
        self._auto_start = False
        self._start_minimized = False
        self._launched_from_startup = False
        self._initializing = True  # Flag to prevent saving during initialization
        self._update_available = False
        self._latest_version = CURRENT_VERSION
        self._current_version = CURRENT_VERSION
        self._new_version = ""  # Initialize the new version string
        self._update_manifest = None
        self._vigem_installed = True  # Will be checked properly after pygame init
        self._sensitivity_curve = "linear"  # linear, smooth, or aggressive
        self._steering_range = 900  # Steering range in degrees (default 900 = full lock-to-lock)
        self._center_spring = 0.0  # Center spring strength (0 = off, 1 = max)
        self._center_spring_width = 0.05  # Center spring zone width (fraction of total range)
        self._center_spring_ramp = False  # Enable hardware spring ramp near steering lock
        self._center_spring_ramp_width = 5.0  # Ramp zone width in degrees
        self._neutral_steering_button = -1
        self._steering_action_button = -1
        self._steering_action_max_percent = 0
        self._steering_action_mode = "hold"
        self._steering_action_toggle_active = False
        self._steering_action_last_pressed = False
        self._last_hardware_spring = -1.0  # Track last sent spring to avoid flooding HID
        self._last_hardware_spring_sent_at = 0.0
        self._hardware_steering_range_active = False
        self._last_hardware_range = None
        self._hardware_range_test_log = []
        self._theme_mode = "light"
        self._install_id = _get_or_create_install_id()
        self._profile_source = "default"
        self._update_check_in_progress = False
        self._last_update_check_at = 0.0
        self._session_start_time = None  # Track when emulation started
        self._session_total_seconds = 0  # Total session time
        
        # Macro system
        self._macros = {}
        self._macro_recording = False
        self._macro_record_name = ""
        self._macro_record_data = []
        self._macro_record_start_time = 0
        self._macro_playing = False
        self._macro_play_thread = None
        self._macro_triggers = {}
        self._active_macro_buttons = set()
        self._pressed_macro_keys = set()
        self._macro_play_lock = threading.Lock()
        
        self.mapper_thread = None
        self.input_reader = None
        self.vg_gamepad = None
        self.joystick = None
        self.button_mapping = {}
        self._axis_values = []
        self._button_states = []
        self._last_preview_values = [None, None, None]
        
        # Track user-selected profiles per device (device_name -> profile_name)
        # When a user manually changes a profile, we store it here to prevent auto-detection
        self._user_profile_overrides = {}
        
        # Track user-modified button mappings per device (device_name -> {button_index: xbox_button})
        # When a user manually changes button mappings, we store them here per device
        self._user_button_overrides = {}
        
        # Track which devices have been explicitly configured by the user (device_name -> True)
        # This distinguishes between "never configured" and "configured but all disabled"
        self._user_configured_devices = {}
        
        # Flag to prevent marking auto-loaded button mappings as user overrides
        self._loading_button_mappings = False

        # Multi-device slots (max 4). Slot 0 is the primary (backward compatible) slot.
        self._device_slots = [DeviceSlot(slot_id=0)]
        self._active_slot_index = 0  # Which slot the UI is currently editing
        self._extra_input_readers = []  # Extra InputReaders for additional slots
        self._extra_mapper_threads = []  # Extra mapper threads for additional slots
        self._last_mapping_error_at = 0.0
        self._last_process_input_error_at = 0.0

        # Initialize keyboard controller if available
        if KEYBOARD_AVAILABLE:
            self.keyboard = KeyboardController()
            self._pressed_keys = set()  # Track currently pressed keyboard keys
        else:
            self.keyboard = None
            self._pressed_keys = set()
        
        # Initialize auto-start manager
        self.auto_start_manager = AutoStartManager()
        
        # Initialize only the pygame subsystems needed for controller polling.
        # Full pygame.init() also touches audio and other modules, which can add
        # unnecessary startup cost.
        if not pygame.display.get_init():
            pygame.display.init()
        if not pygame.joystick.get_init():
            pygame.joystick.init()
        
        # Load configuration
        self.load_button_mapping()
        self.load_settings()
        self.load_macros()
        
        # Create input reader thread
        self.input_reader = InputReader()
        self.input_reader.axisValuesChanged.connect(self.update_axis_values)
        self.input_reader.buttonStatesChanged.connect(self.update_button_states)
        self.input_reader.deviceDisconnected.connect(self._on_device_disconnected)
        QTimer.singleShot(200, self._start_input_reader)

        # Reconnect timer for auto-reconnection after device disconnect
        self._reconnect_timer = QTimer()
        self._reconnect_timer.timeout.connect(self._attempt_reconnect)
        self._reconnect_device_name = None
        
        # Initialize tray manager
        self.tray_manager = SystemTrayManager(backend=self)
        self.tray_manager.load_settings()
        
        # Start update timer for processing inputs
        self.update_timer = QTimer()
        self.update_timer.timeout.connect(self.process_inputs)
        self.update_timer.start(16)  # ~60 FPS for smooth updates
        
        # Scan devices after QML has had a chance to paint the first window.
        QTimer.singleShot(100, self.refresh_devices)
        
        # Restore saved device selection after a short delay (to let devices enumerate)
        if self._current_device_index >= 0:
            QTimer.singleShot(900, self.restore_device_selection)
        
        # Check ViGEm installation status
        QTimer.singleShot(1000, self.update_vigem_status)
        
        # Check for updates on startup (after 3 seconds)
        QTimer.singleShot(3000, self.check_for_updates)
        QTimer.singleShot(5200, self._send_startup_telemetry)

        self.update_check_timer = QTimer()
        self.update_check_timer.timeout.connect(lambda: self._check_for_updates(quiet=True))
        self.update_check_timer.start(UPDATE_CHECK_INTERVAL_MS)

        self.telemetry_heartbeat_timer = QTimer()
        self.telemetry_heartbeat_timer.timeout.connect(self._send_presence_telemetry)
        self.telemetry_heartbeat_timer.start(TELEMETRY_HEARTBEAT_INTERVAL_MS)

        self.hardware_reassert_timer = QTimer()
        self.hardware_reassert_timer.timeout.connect(self._reassert_hardware_controls)
        self.hardware_reassert_timer.start(HARDWARE_REASSERT_INTERVAL_MS)

        QTimer.singleShot(1600, lambda: self._reassert_hardware_controls(pulse=True))
        QTimer.singleShot(1800, self._consume_update_state)
        
        # Mark initialization as complete after a delay (to let QML finish loading)
        QTimer.singleShot(1000, lambda: setattr(self, '_initializing', False))

    def _start_input_reader(self):
        """Start the polling worker after the first UI paint."""
        try:
            if self.input_reader and not self.input_reader.isRunning():
                self.input_reader.start()
        except Exception as e:
            print(f"Could not start input reader: {e}")
    
    @Property(bool, notify=isRunningChanged)
    def running(self):
        return self._running
    
    @Property(list, notify=devicesChanged)
    def devices(self):
        return self._devices
    
    @Property(list, constant=True)
    def profileNames(self):
        return list(PROFILES.keys())
    
    @Property(str, notify=currentProfileChanged)
    def current_profile(self):
        return self._current_profile
    
    @Property(int, notify=currentDeviceIndexChanged)
    def current_device_index(self):
        return self._current_device_index
    
    @Property(bool, notify=invertGasChanged)
    def invert_gas(self):
        return self._invert_gas
    
    @Property(bool, notify=invertBrakeChanged)
    def invert_brake(self):
        return self._invert_brake
    
    @Property(bool, notify=squareInputChanged)
    def square_input(self):
        return self._square_input
    
    @Property(float, notify=squareAreaChanged)
    def square_area(self):
        return self._square_area
    
    @Property(bool, notify=deadzoneEnabledChanged)
    def deadzone_enabled(self):
        return self._deadzone_enabled
    
    @Property(float, notify=deadzoneChanged)
    def deadzone(self):
        return self._deadzone
    
    @Property(list, constant=True)
    def xboxButtonNames(self):
        return XBOX_BUTTON_NAMES
    
    @Property(bool, constant=True)
    def keyboardAvailable(self):
        return KEYBOARD_AVAILABLE
    
    @Property(str, notify=currentVersionChanged)
    def currentVersion(self):
        return self._current_version
    
    @Property(str, notify=updateAvailableChanged)
    def latestVersion(self):
        return self._latest_version
    
    @Property(bool, notify=updateAvailableChanged)
    def updateAvailable(self):
        return self._update_available
    
    @Property(bool, notify=vigEmInstalledChanged)
    def vigEmInstalled(self):
        return self._vigem_installed
    
    @Property(int, notify=numAxesChanged)
    def numAxes(self):
        if self.joystick:
            try:
                return self.joystick.get_numaxes()
            except Exception:
                pass
        return 0
    
    @Property(int, notify=numButtonsChanged)
    def numButtons(self):
        if self.joystick:
            try:
                return self.joystick.get_numbuttons()
            except Exception:
                pass
        return 0
    
    @Property(str, notify=currentVersionChanged)
    def currentVersion(self):
        return self._current_version
    
    @Property(bool, notify=updateAvailableChanged)
    def updateAvailable(self):
        return self._update_available
    
    @Property(str, notify=updateAvailableChanged)
    def newVersion(self):
        return self._latest_version
    
    @Property(str, notify=sensitivityCurveChanged)
    def sensitivityCurve(self):
        return self._sensitivity_curve

    @Property(str, notify=themeModeChanged)
    def themeMode(self):
        return self._theme_mode

    @Property(int, notify=steeringRangeChanged)
    def steeringRange(self):
        return self._steering_range

    @Property(float, notify=centerSpringChanged)
    def centerSpring(self):
        return self._center_spring

    @Property(float, notify=centerSpringWidthChanged)
    def centerSpringWidth(self):
        return self._center_spring_width

    @Property(bool, notify=centerSpringRampChanged)
    def centerSpringRamp(self):
        return self._center_spring_ramp

    @Property(float, notify=centerSpringRampWidthChanged)
    def centerSpringRampWidth(self):
        return self._center_spring_ramp_width

    @Property(int, notify=neutralSteeringButtonChanged)
    def neutralSteeringButton(self):
        return self._neutral_steering_button

    @Property(str, notify=neutralSteeringButtonChanged)
    def neutralSteeringButtonLabel(self):
        if self._neutral_steering_button < 0:
            return "Not set"
        return self.get_button_name(self._neutral_steering_button)

    @Property(int, notify=steeringActionChanged)
    def steeringActionButton(self):
        return self._steering_action_button

    @Property(str, notify=steeringActionChanged)
    def steeringActionButtonLabel(self):
        if self._steering_action_button < 0:
            return "Not set"
        return self.get_button_name(self._steering_action_button)

    @Property(int, notify=steeringActionChanged)
    def steeringActionMaxPercent(self):
        return int(self._steering_action_max_percent)

    @Property(str, notify=steeringActionChanged)
    def steeringActionMode(self):
        return self._steering_action_mode

    @Property(bool, notify=steeringActionChanged)
    def steeringActionActive(self):
        return bool(self._steering_action_toggle_active)
    
    @Property(str, notify=sessionTimeChanged)
    def sessionTime(self):
        if self._session_start_time and self._running:
            elapsed = int(time.time() - self._session_start_time)
            hours = elapsed // 3600
            minutes = (elapsed % 3600) // 60
            seconds = elapsed % 60
            if hours > 0:
                return f"{hours}:{minutes:02d}:{seconds:02d}"
            return f"{minutes}:{seconds:02d}"
        return "0:00"
    
    @Property(list, constant=True)
    def sensitivityCurveOptions(self):
        return ["Linear", "Smooth", "Aggressive", "S-Curve"]
    @Slot(int, result=float)
    def getAxisValue(self, index):
        try:
            if index < len(self._axis_values):
                value = float(self._axis_values[index])
                # Ensure value is a valid number
                if not (value >= 0 and value <= 1):
                    value = max(0.0, min(1.0, value))
                return value
        except (ValueError, TypeError):
            pass
        return 0.5
    
    @Slot(int, result=bool)
    def getButtonState(self, index):
        if index < len(self._button_states):
            return bool(self._button_states[index])
        return False

    @Slot(int)
    def set_neutral_steering_button(self, button_index):
        try:
            button_index = int(button_index)
        except (TypeError, ValueError):
            button_index = -1
        if button_index < 0:
            self.clear_neutral_steering_button()
            return
        self._neutral_steering_button = button_index
        self.neutralSteeringButtonChanged.emit()
        self.save_settings()
        self.statusChanged.emit(f"Neutral steering button set to {self.get_button_name(button_index)}", "#22c55e")

    @Slot()
    def clear_neutral_steering_button(self):
        self._neutral_steering_button = -1
        self.neutralSteeringButtonChanged.emit()
        self.save_settings()
        self.statusChanged.emit("Neutral steering button cleared", "#71717a")

    @Slot(int)
    def set_steering_action_button(self, button_index):
        try:
            button_index = int(button_index)
        except (TypeError, ValueError):
            button_index = -1
        if button_index < 0:
            self.clear_steering_action_button()
            return
        self._steering_action_button = button_index
        self._reset_steering_action_toggle()
        self.steeringActionChanged.emit()
        self.save_settings()
        self.statusChanged.emit(f"Steering limit button set to {self.get_button_name(button_index)}", "#22c55e")

    @Slot()
    def clear_steering_action_button(self):
        self._steering_action_button = -1
        self._reset_steering_action_toggle()
        self.steeringActionChanged.emit()
        self.save_settings()
        self.statusChanged.emit("Steering limit cleared", "#71717a")

    @Slot(int)
    def set_steering_action_max_percent(self, value):
        try:
            value = int(round(float(value)))
        except (TypeError, ValueError):
            value = 0
        self._steering_action_max_percent = max(0, min(100, value))
        self.steeringActionChanged.emit()
        self.save_settings()

    @Slot(str)
    def set_steering_action_mode(self, mode):
        mode = str(mode or "hold").strip().lower()
        if mode not in ("hold", "toggle"):
            mode = "hold"
        self._steering_action_mode = mode
        self._reset_steering_action_toggle()
        self.steeringActionChanged.emit()
        self.save_settings()
    
    @Slot(int, result=str)
    def get_button_mapping(self, button_index):
        return self.button_mapping.get(str(button_index), "DISABLED")
    
    @Slot(int, str)
    def set_button_mapping(self, button_index, xbox_button):
        print(f"[BUTTON CONFIG] set_button_mapping called: btn={button_index}, value={xbox_button}, loading_flag={self._loading_button_mappings}")
        
        # Ignore section headers
        if xbox_button in ["--- XBOX BUTTONS ---", "--- KEYBOARD KEYS ---"]:
            return
            
        if xbox_button == "DISABLED":
            if str(button_index) in self.button_mapping:
                del self.button_mapping[str(button_index)]
        else:
            self.button_mapping[str(button_index)] = xbox_button
        
        print(f"[BUTTON CONFIG] Current button_mapping after change: {self.button_mapping}")
        
        # Only mark as user override if not auto-loading
        if not self._loading_button_mappings:
            # Mark this as a user override for the current device
            if self.joystick:
                device_name = self.joystick.get_name()
                # Store the current button mapping state for this device
                self._user_button_overrides[device_name] = self.button_mapping.copy()
                # Mark this device as explicitly configured by the user
                self._user_configured_devices[device_name] = True
                print(f"[BUTTON CONFIG] User modified button mapping for device '{device_name}'")
                print(f"[BUTTON CONFIG] Stored in _user_button_overrides: {self._user_button_overrides[device_name]}")

        # Auto-save button mapping changes (only if not during initialization and not auto-loading)
        if not self._loading_button_mappings:
            self.save_button_mapping()
            # Persist the per-device override markers immediately as well.
            # The app usually dies with the Windows session (tray app), so
            # waiting for a clean exit would lose these on PC shutdown.
            self.save_settings(force=True)
    
    @Slot()
    def save_button_mapping(self, force=False):
        # Don't save during initialization to prevent overwriting loaded settings
        if not force and hasattr(self, '_initializing') and self._initializing:
            return

        try:
            _atomic_json_save(BUTTON_MAPPING_FILE, self.button_mapping)
            self.update_button_status()
        except Exception as e:
            print(f"Error saving button mapping: {e}")
    
    def load_button_mapping(self):
        try:
            self._loading_button_mappings = True
            data = _load_json_with_backup(BUTTON_MAPPING_FILE)
            if data is not None:
                self.button_mapping = data
                self.update_button_status()
            self._loading_button_mappings = False
        except Exception as e:
            self._loading_button_mappings = False
            print(f"Error loading button mapping: {e}")
    
    def load_settings(self):
        """Load application settings"""
        try:
            settings = _load_json_with_backup(SETTINGS_FILE)
            if settings is None:
                return

            # Load auto-start setting
            if 'auto_start' in settings:
                self._auto_start = settings['auto_start']
                self.autoStartChanged.emit(self._auto_start)

            # Load start minimized setting
            if 'start_minimized' in settings:
                self._start_minimized = settings['start_minimized']
                self.startMinimizedChanged.emit(self._start_minimized)

            # Repair startup commands after the first window is visible. When
            # startup is disabled, defer cleanup so app launch stays snappy.
            QTimer.singleShot(1500 if self._auto_start else 12000, self.apply_auto_start_setting)

            # Load user profile overrides
            if 'user_profile_overrides' in settings:
                self._user_profile_overrides = settings['user_profile_overrides']
                print(f"Loaded user profile overrides: {self._user_profile_overrides}")

            # Load user button overrides
            if 'user_button_overrides' in settings:
                self._user_button_overrides = settings['user_button_overrides']
                print(f"Loaded user button overrides for {len(self._user_button_overrides)} devices")

            # Load user configured devices tracking
            if 'user_configured_devices' in settings:
                self._user_configured_devices = settings['user_configured_devices']
                print(f"Loaded user configured devices: {list(self._user_configured_devices.keys())}")

            # Load other settings
            if 'invert_gas' in settings:
                self._invert_gas = settings['invert_gas']
                self.invertGasChanged.emit(self._invert_gas)
            if 'invert_brake' in settings:
                self._invert_brake = settings['invert_brake']
                self.invertBrakeChanged.emit(self._invert_brake)
            if 'square_input' in settings:
                self._square_input = settings['square_input']
                self.squareInputChanged.emit(self._square_input)
            if 'square_area' in settings:
                self._square_area = settings['square_area']
                self.squareAreaChanged.emit(self._square_area)
            if 'deadzone_enabled' in settings:
                self._deadzone_enabled = settings['deadzone_enabled']
                self.deadzoneEnabledChanged.emit(self._deadzone_enabled)
            if 'deadzone' in settings:
                self._deadzone = settings['deadzone']
                self.deadzoneChanged.emit(self._deadzone)
            if 'sensitivity_curve' in settings:
                self._sensitivity_curve = settings['sensitivity_curve']
                self.sensitivityCurveChanged.emit(self._sensitivity_curve)
            if 'theme_mode' in settings:
                theme_mode = str(settings['theme_mode']).strip().lower()
                self._theme_mode = theme_mode if theme_mode in ("light", "dark") else "light"
                self.themeModeChanged.emit(self._theme_mode)
            if 'steering_range' in settings:
                self._steering_range = settings['steering_range']
                self.steeringRangeChanged.emit(self._steering_range)
            if 'center_spring' in settings:
                self._center_spring = settings['center_spring']
                self.centerSpringChanged.emit(self._center_spring)
            if 'center_spring_width' in settings:
                self._center_spring_width = settings['center_spring_width']
                self.centerSpringWidthChanged.emit(self._center_spring_width)
            if 'center_spring_ramp' in settings:
                self._center_spring_ramp = settings['center_spring_ramp'] if SOFT_RAMP_AVAILABLE else False
                self.centerSpringRampChanged.emit(self._center_spring_ramp)
            if 'center_spring_ramp_width' in settings:
                self._center_spring_ramp_width = settings['center_spring_ramp_width']
                self.centerSpringRampWidthChanged.emit(self._center_spring_ramp_width)
            if 'neutral_steering_button' in settings:
                self._neutral_steering_button = int(settings.get('neutral_steering_button', -1) or -1)
                self.neutralSteeringButtonChanged.emit()
            if 'steering_action_button' in settings:
                self._steering_action_button = int(settings.get('steering_action_button', -1) or -1)
                self.steeringActionChanged.emit()
            if 'steering_action_max_percent' in settings:
                self._steering_action_max_percent = max(0, min(100, int(settings.get('steering_action_max_percent', 0) or 0)))
                self.steeringActionChanged.emit()
            if 'steering_action_mode' in settings:
                mode = str(settings.get('steering_action_mode', 'hold') or 'hold').lower()
                self._steering_action_mode = mode if mode in ('hold', 'toggle') else 'hold'
                self.steeringActionChanged.emit()
            if 'current_profile' in settings:
                self._current_profile = settings['current_profile']
                self._profile_source = "saved"
                self.currentProfileChanged.emit(self._current_profile)
                desc = PROFILES.get(self._current_profile, {}).get("desc", "Select a profile to see description")
                self.profileDescChanged.emit(desc)
            if 'current_device_index' in settings:
                self._current_device_index = settings['current_device_index']
                self.currentDeviceIndexChanged.emit(self._current_device_index)
                # Auto-start emulation if auto-start is enabled and device exists
                QTimer.singleShot(1000, self.try_auto_start_emulation)

            # Load multi-device slots
            if 'device_slots' in settings:
                slot_data_list = settings['device_slots']
                self._device_slots = []
                for i, slot_data in enumerate(slot_data_list):
                    slot = DeviceSlot(slot_id=i)
                    slot.from_dict(slot_data)
                    self._device_slots.append(slot)
                if not self._device_slots:
                    self._device_slots = [DeviceSlot(slot_id=0)]
                self._active_slot_index = settings.get('active_slot_index', 0)
                if 0 <= self._active_slot_index < len(self._device_slots):
                    self._neutral_steering_button = self._device_slots[self._active_slot_index].neutral_steering_button
                    self._steering_action_button = self._device_slots[self._active_slot_index].steering_action_button
                    self._steering_action_max_percent = self._device_slots[self._active_slot_index].steering_action_max_percent
                    self._steering_action_mode = self._device_slots[self._active_slot_index].steering_action_mode
                    self._reset_steering_action_toggle()
                    self.neutralSteeringButtonChanged.emit()
                    self.steeringActionChanged.emit()
                print(f"Loaded {len(self._device_slots)} device slot(s)")
            else:
                # Migration: create slot 0 from existing single-device settings
                slot0 = self._device_slots[0]
                slot0.profile = self._current_profile
                slot0.button_mapping = self.button_mapping.copy()
                slot0.invert_gas = self._invert_gas
                slot0.invert_brake = self._invert_brake
                slot0.square_input = self._square_input
                slot0.square_area = self._square_area
                slot0.deadzone_enabled = self._deadzone_enabled
                slot0.deadzone = self._deadzone
                slot0.sensitivity_curve = self._sensitivity_curve
                slot0.neutral_steering_button = self._neutral_steering_button
                slot0.steering_action_button = self._steering_action_button
                slot0.steering_action_max_percent = self._steering_action_max_percent
                slot0.steering_action_mode = self._steering_action_mode
                slot0.device_index = self._current_device_index
                print("Migrated single-device settings to slot 0")
            self.deviceSlotsChanged.emit()
        except Exception as e:
            print(f"Error loading settings: {e}")
    
    @Slot()
    def refresh_ui_from_settings(self):
        """Re-emit all signals to update QML UI with current backend state"""
        self.currentProfileChanged.emit(self._current_profile)
        desc = PROFILES.get(self._current_profile, {}).get("desc", "Select a profile to see description")
        self.profileDescChanged.emit(desc)
        self.currentDeviceIndexChanged.emit(self._current_device_index)
        self.invertGasChanged.emit(self._invert_gas)
        self.invertBrakeChanged.emit(self._invert_brake)
        self.squareInputChanged.emit(self._square_input)
        self.squareAreaChanged.emit(self._square_area)
        self.deadzoneEnabledChanged.emit(self._deadzone_enabled)
        self.deadzoneChanged.emit(self._deadzone)
        self.sensitivityCurveChanged.emit(self._sensitivity_curve)
        self.themeModeChanged.emit(self._theme_mode)
        self.steeringRangeChanged.emit(self._steering_range)
        self.centerSpringChanged.emit(self._center_spring)
        self.centerSpringWidthChanged.emit(self._center_spring_width)
        if not SOFT_RAMP_AVAILABLE:
            self._center_spring_ramp = False
        self.centerSpringRampChanged.emit(self._center_spring_ramp)
        self.centerSpringRampWidthChanged.emit(self._center_spring_ramp_width)
        self.neutralSteeringButtonChanged.emit()
        self.steeringActionChanged.emit()
        self.autoStartChanged.emit(self._auto_start)
        self.startMinimizedChanged.emit(self._start_minimized)
        self.update_button_status()  # Refresh button mapping status

        # Also refresh tray manager settings to update UI
        if self.tray_manager:
            self.tray_manager.hideToTrayEnabledChanged.emit(self.tray_manager._hide_to_tray_enabled)
    
    def try_auto_start_emulation(self):
        """Try to auto-start emulation with saved device"""
        if not getattr(self, '_launched_from_startup', False):
            return
        if not self._auto_start or not self._start_minimized:
            return
            
        try:
            # Check if the saved device index is valid
            if self._current_device_index < len(self._devices) and self._devices[self._current_device_index] != "No devices found":
                # Select the device
                self.select_device(self._current_device_index)
                # Small delay to ensure device is ready
                QTimer.singleShot(500, self.start_mapping)
                print(f"Auto-starting emulation with device index {self._current_device_index}")
        except Exception as e:
            print(f"Failed to auto-start emulation: {e}")
    
    def restore_device_selection(self):
        """Restore the saved device selection after startup"""
        try:
            # Check if the saved device index is valid
            if 0 <= self._current_device_index < len(self._devices):
                if self._devices[self._current_device_index] != "No devices found":
                    # Select the device without saving (to avoid triggering save during load)
                    self.select_device(self._current_device_index)
                    print(f"Restored device selection: {self._devices[self._current_device_index]}")
        except Exception as e:
            print(f"Failed to restore device selection: {e}")
    
    def save_settings(self, force=False):
        """Save application settings"""
        # Don't save during initialization to prevent overwriting loaded settings
        if not force and hasattr(self, '_initializing') and self._initializing:
            return

        try:
            # Load existing settings to preserve keys we don't manage here (e.g. tray_settings)
            settings = _load_json_with_backup(SETTINGS_FILE) or {}

            # Sync current UI state back to the active slot before saving
            self._sync_slot_from_ui(self._active_slot_index)

            settings.update({
                'auto_start': self._auto_start,
                'start_minimized': self._start_minimized,
                'invert_gas': self._invert_gas,
                'invert_brake': self._invert_brake,
                'square_input': self._square_input,
                'square_area': self._square_area,
                'deadzone_enabled': self._deadzone_enabled,
                'deadzone': self._deadzone,
                'sensitivity_curve': self._sensitivity_curve,
                'theme_mode': self._theme_mode,
                'steering_range': self._steering_range,
                'center_spring': self._center_spring,
                'center_spring_width': self._center_spring_width,
                'center_spring_ramp': self._center_spring_ramp if SOFT_RAMP_AVAILABLE else False,
                'center_spring_ramp_width': self._center_spring_ramp_width,
                'neutral_steering_button': self._neutral_steering_button,
                'steering_action_button': self._steering_action_button,
                'steering_action_max_percent': self._steering_action_max_percent,
                'steering_action_mode': self._steering_action_mode,
                'current_profile': self._current_profile,
                'current_device_index': self._current_device_index,
                'user_profile_overrides': self._user_profile_overrides,
                'user_button_overrides': self._user_button_overrides,
                'user_configured_devices': self._user_configured_devices,
                'device_slots': [slot.to_dict() for slot in self._device_slots],
                'active_slot_index': self._active_slot_index,
            })

            _atomic_json_save(SETTINGS_FILE, settings)
        except Exception as e:
            print(f"Error saving settings: {e}")
    
    # --- Multi-device slot management ---

    @Property(int, notify=activeSlotIndexChanged)
    def activeSlotIndex(self):
        return self._active_slot_index

    @Property(int, notify=deviceSlotsChanged)
    def slotCount(self):
        return len(self._device_slots)

    @Slot(result=list)
    def getSlotInfo(self):
        """Return a list of slot info dicts for QML"""
        info = []
        for slot in self._device_slots:
            info.append({
                'id': slot.slot_id,
                'device_name': slot.device_name or "No device",
                'profile': slot.profile,
                'running': slot.running,
                'combined_mode': slot.combined_mode,
                'combined_pedal_device': slot.combined_pedal_device_name,
            })
        return info

    @Slot(int)
    def setActiveSlot(self, index):
        """Switch the UI to show/edit a different slot"""
        if 0 <= index < len(self._device_slots):
            # Save current slot state back
            self._sync_slot_from_ui(self._active_slot_index)
            self._active_slot_index = index
            self.activeSlotIndexChanged.emit(index)
            # Load new slot state into UI
            self._sync_ui_from_slot(index)

    @Slot()
    def addDeviceSlot(self):
        """Add a new device slot (max 4)"""
        if len(self._device_slots) >= 4:
            self.statusChanged.emit("Maximum 4 device slots allowed", "#f59e0b")
            return
        new_id = len(self._device_slots)
        slot = DeviceSlot(slot_id=new_id)
        self._device_slots.append(slot)
        self.deviceSlotsChanged.emit()
        self.statusChanged.emit(f"Added device slot {new_id + 1}", "#22c55e")
        self.save_settings()

    @Slot(int)
    def removeDeviceSlot(self, index):
        """Remove a device slot (cannot remove slot 0)"""
        if index <= 0 or index >= len(self._device_slots):
            return
        slot = self._device_slots[index]
        # Stop emulation on this slot if running
        if slot.running:
            self._stop_slot_emulation(slot)
        # Clean up
        if slot.input_reader:
            slot.input_reader.stop()
        if slot.combined_pedal_input_reader:
            slot.combined_pedal_input_reader.stop()
        self._device_slots.pop(index)
        # Reindex remaining slots
        for i, s in enumerate(self._device_slots):
            s.slot_id = i
        if self._active_slot_index >= len(self._device_slots):
            self._active_slot_index = 0
            self.activeSlotIndexChanged.emit(0)
            self._sync_ui_from_slot(0)
        self.deviceSlotsChanged.emit()
        self.save_settings()

    @Slot(int, int)
    def assignDeviceToSlot(self, slot_index, device_index):
        """Assign a physical device (by device list index) to a specific slot"""
        if slot_index < 0 or slot_index >= len(self._device_slots):
            return
        if device_index < 0 or device_index >= len(self._devices):
            return
        slot = self._device_slots[slot_index]
        if slot.running:
            self.statusChanged.emit("Stop emulation before changing device", "#f59e0b")
            return
        slot.device_index = device_index
        # Parse device name
        try:
            slot.device_name = self._devices[device_index].split(" (ID:")[0]
        except Exception:
            slot.device_name = self._devices[device_index]

        # If this is the active slot, also update the main UI
        if slot_index == self._active_slot_index:
            self.select_device(device_index)
        self.deviceSlotsChanged.emit()
        self.save_settings()

    @Slot(int, bool)
    def setSlotCombinedMode(self, slot_index, enabled):
        """Enable/disable combined device mode for a slot"""
        if slot_index < 0 or slot_index >= len(self._device_slots):
            return
        self._device_slots[slot_index].combined_mode = enabled
        self.deviceSlotsChanged.emit()
        self.save_settings()

    @Slot(int, int)
    def setSlotPedalDevice(self, slot_index, device_index):
        """Set the pedal device for combined mode"""
        if slot_index < 0 or slot_index >= len(self._device_slots):
            return
        if device_index < 0 or device_index >= len(self._devices):
            return
        slot = self._device_slots[slot_index]
        try:
            slot.combined_pedal_device_name = self._devices[device_index].split(" (ID:")[0]
        except Exception:
            slot.combined_pedal_device_name = self._devices[device_index]
        self.deviceSlotsChanged.emit()
        self.save_settings()

    @Slot(int)
    def startSlotEmulation(self, slot_index):
        """Start emulation on a specific slot"""
        if slot_index < 0 or slot_index >= len(self._device_slots):
            return
        slot = self._device_slots[slot_index]
        if slot_index == 0:
            # Slot 0 uses the main start_mapping flow
            if self._active_slot_index != 0:
                self._sync_slot_from_ui(self._active_slot_index)
                self._sync_ui_from_slot(0)
            self.start_mapping()
            slot.running = self._running
        else:
            self._start_extra_slot_emulation(slot)
        self.deviceSlotsChanged.emit()

    @Slot(int)
    def stopSlotEmulation(self, slot_index):
        """Stop emulation on a specific slot"""
        if slot_index < 0 or slot_index >= len(self._device_slots):
            return
        slot = self._device_slots[slot_index]
        if slot_index == 0:
            self.stop_mapping()
            slot.running = False
        else:
            self._stop_slot_emulation(slot)
        self.deviceSlotsChanged.emit()

    def _start_extra_slot_emulation(self, slot):
        """Start emulation for a non-primary slot"""
        if not VIGEM_AVAILABLE or vg is None:
            self.statusChanged.emit("ViGEm driver not available", "#ef4444")
            return
        if slot.device_index < 0:
            self.statusChanged.emit(f"Slot {slot.slot_id + 1}: No device assigned", "#ef4444")
            return
        try:
            # Initialize joystick for this slot
            device_id = int(self._devices[slot.device_index].split("(ID: ")[1].rstrip(")"))
            slot.joystick = pygame.joystick.Joystick(device_id)
            slot.joystick.init()

            # Create virtual gamepad
            slot.vg_gamepad = vg.VX360Gamepad()

            # Create input reader for this slot
            slot.input_reader = InputReader()
            slot.input_reader.set_joystick(slot.joystick)
            slot.input_reader.axisValuesChanged.connect(
                lambda vals, s=slot: self._update_slot_axes(s, vals))
            slot.input_reader.buttonStatesChanged.connect(
                lambda states, s=slot: self._update_slot_buttons(s, states))
            slot.input_reader.start()

            # Handle combined mode - set up pedal device reader
            if slot.combined_mode and slot.combined_pedal_device_name:
                self._setup_combined_pedal_reader(slot)

            # Start mapping thread
            self._reset_steering_action_toggle(slot)
            slot.running = True
            slot.mapper_thread = threading.Thread(
                target=self._slot_mapping_loop, args=(slot,), daemon=True)
            slot.mapper_thread.start()

            self.statusChanged.emit(
                f"Slot {slot.slot_id + 1}: Emulating controller", "#22c55e")
        except Exception as e:
            print(f"Error starting slot {slot.slot_id}: {e}")
            self.statusChanged.emit(
                f"Slot {slot.slot_id + 1}: Failed to start - {e}", "#ef4444")

    def _stop_slot_emulation(self, slot):
        """Stop emulation for a non-primary slot"""
        slot.running = False
        self._reset_steering_action_toggle(slot)
        if slot.mapper_thread and slot.mapper_thread.is_alive():
            slot.mapper_thread.join(timeout=2)
        if slot.input_reader:
            slot.input_reader.stop()
            slot.input_reader = None
        if slot.combined_pedal_input_reader:
            slot.combined_pedal_input_reader.stop()
            slot.combined_pedal_input_reader = None
        if slot.vg_gamepad:
            try:
                del slot.vg_gamepad
            except Exception:
                pass
            slot.vg_gamepad = None
        if slot.joystick:
            try:
                slot.joystick.quit()
            except Exception:
                pass
            slot.joystick = None

    def _setup_combined_pedal_reader(self, slot):
        """Set up a separate InputReader for the pedal device in combined mode"""
        # Find the pedal device by name
        pedal_device_id = None
        for i, dev_str in enumerate(self._devices):
            if slot.combined_pedal_device_name in dev_str:
                try:
                    pedal_device_id = int(dev_str.split("(ID: ")[1].rstrip(")"))
                except Exception:
                    pedal_device_id = i
                break
        if pedal_device_id is None:
            print(f"Combined mode: pedal device '{slot.combined_pedal_device_name}' not found")
            return
        try:
            slot.combined_pedal_joystick = pygame.joystick.Joystick(pedal_device_id)
            slot.combined_pedal_joystick.init()
            slot.combined_pedal_input_reader = InputReader()
            slot.combined_pedal_input_reader.set_joystick(slot.combined_pedal_joystick)
            # Pedal reader updates a separate axis list; the mapping loop merges them
            slot.combined_pedal_input_reader.axisValuesChanged.connect(
                lambda vals, s=slot: setattr(s, '_pedal_axis_values', vals))
            slot.combined_pedal_input_reader.start()
            slot._pedal_axis_values = []
        except Exception as e:
            print(f"Failed to initialize pedal device: {e}")

    def _update_slot_axes(self, slot, values):
        slot.axis_values = values

    def _update_slot_buttons(self, slot, states):
        slot.button_states = states

    def _slot_mapping_loop(self, slot):
        """Mapping loop for extra slots (non-primary)"""
        mapping = PROFILES.get(slot.profile, PROFILES["Logitech G29 (Standard)"])["axes"]
        import math

        while slot.running:
            try:
                if slot.input_reader:
                    axis_values, button_states = slot.input_reader.snapshot()
                else:
                    axis_values = slot.axis_values.copy() if slot.axis_values else []
                    button_states = slot.button_states.copy() if slot.button_states else []

                # In combined mode, use pedal device axes for gas/brake
                if slot.combined_mode and slot.combined_pedal_input_reader:
                    pedal_axes, _ = slot.combined_pedal_input_reader.snapshot()
                    if not pedal_axes and hasattr(slot, '_pedal_axis_values') and slot._pedal_axis_values:
                        pedal_axes = slot._pedal_axis_values.copy()
                elif slot.combined_mode and hasattr(slot, '_pedal_axis_values') and slot._pedal_axis_values:
                    pedal_axes = slot._pedal_axis_values.copy()
                else:
                    pedal_axes = None

                # STEERING
                if mapping[0] < len(axis_values):
                    raw_s = (axis_values[mapping[0]] * 2) - 1
                    if slot.deadzone_enabled and abs(raw_s) < slot.deadzone:
                        raw_s = 0.0
                    elif slot.deadzone_enabled and slot.deadzone > 0:
                        sign = 1 if raw_s > 0 else -1
                        raw_s = sign * ((abs(raw_s) - slot.deadzone) / (1.0 - slot.deadzone))
                    raw_s = max(-1.0, min(1.0, raw_s))
                    if self._neutral_steering_active(button_states, slot.neutral_steering_button):
                        raw_s = 0.0
                    else:
                        raw_s = self._apply_steering_action_limit(raw_s, button_states, slot=slot)
                    slot.vg_gamepad.left_joystick(x_value=int(raw_s * 32767), y_value=0)

                # GAS - from pedal device if combined, else from main device
                gas_axes = pedal_axes if pedal_axes else axis_values
                if mapping[1] < len(gas_axes):
                    norm_g = gas_axes[mapping[1]]
                    if slot.invert_gas:
                        norm_g = 1.0 - norm_g
                    slot.vg_gamepad.right_trigger(value=int(max(0.0, min(1.0, norm_g)) * 255))

                # BRAKE - from pedal device if combined, else from main device
                brake_axes = pedal_axes if pedal_axes else axis_values
                if mapping[2] < len(brake_axes):
                    norm_b = brake_axes[mapping[2]]
                    if slot.invert_brake:
                        norm_b = 1.0 - norm_b
                    slot.vg_gamepad.left_trigger(value=int(max(0.0, min(1.0, norm_b)) * 255))

                # BUTTONS
                for btn_idx_str, mapping_name in list(slot.button_mapping.items()):
                    try:
                        btn_idx = int(btn_idx_str)
                    except (TypeError, ValueError):
                        continue
                    if btn_idx < len(button_states):
                        xbox_obj = XBOX_BUTTON_MAP.get(mapping_name)
                        if xbox_obj:
                            if button_states[btn_idx]:
                                slot.vg_gamepad.press_button(xbox_obj)
                            else:
                                slot.vg_gamepad.release_button(xbox_obj)

                slot.vg_gamepad.update()
                time.sleep(MAPPING_LOOP_SLEEP_SECONDS)
            except (pygame.error, OSError) as e:
                print(f"Device error in slot {slot.slot_id}: {e}")
                slot.running = False
                break
            except Exception as e:
                now = time.time()
                if now - getattr(slot, '_last_mapping_error_at', 0.0) > 1.0:
                    print(f"Error in slot {slot.slot_id} mapping loop: {e}")
                    slot._last_mapping_error_at = now
                time.sleep(0.05)

        # Clean up
        if slot.vg_gamepad:
            try:
                for btn_name, btn_obj in XBOX_BUTTON_MAP.items():
                    if btn_obj:
                        slot.vg_gamepad.release_button(btn_obj)
                slot.vg_gamepad.update()
            except Exception:
                pass

    def _sync_slot_from_ui(self, slot_index):
        """Save current UI state back into a slot"""
        if slot_index < 0 or slot_index >= len(self._device_slots):
            return
        slot = self._device_slots[slot_index]
        slot.profile = self._current_profile
        slot.button_mapping = self.button_mapping.copy()
        slot.invert_gas = self._invert_gas
        slot.invert_brake = self._invert_brake
        slot.square_input = self._square_input
        slot.square_area = self._square_area
        slot.deadzone_enabled = self._deadzone_enabled
        slot.deadzone = self._deadzone
        slot.sensitivity_curve = self._sensitivity_curve
        slot.steering_range = self._steering_range
        slot.center_spring = self._center_spring
        slot.center_spring_width = self._center_spring_width
        slot.center_spring_ramp = self._center_spring_ramp if SOFT_RAMP_AVAILABLE else False
        slot.center_spring_ramp_width = self._center_spring_ramp_width
        slot.neutral_steering_button = self._neutral_steering_button
        slot.steering_action_button = self._steering_action_button
        slot.steering_action_max_percent = self._steering_action_max_percent
        slot.steering_action_mode = self._steering_action_mode
        slot.device_index = self._current_device_index

    def _sync_ui_from_slot(self, slot_index):
        """Load a slot's state into the UI"""
        if slot_index < 0 or slot_index >= len(self._device_slots):
            return
        slot = self._device_slots[slot_index]
        self._current_profile = slot.profile
        self.currentProfileChanged.emit(self._current_profile)
        desc = PROFILES.get(self._current_profile, {}).get("desc", "")
        self.profileDescChanged.emit(desc)
        self.button_mapping = slot.button_mapping.copy()
        self.buttonMappingsChanged.emit()
        self.update_button_status()
        self._invert_gas = slot.invert_gas
        self.invertGasChanged.emit(self._invert_gas)
        self._invert_brake = slot.invert_brake
        self.invertBrakeChanged.emit(self._invert_brake)
        self._square_input = slot.square_input
        self.squareInputChanged.emit(self._square_input)
        self._square_area = slot.square_area
        self.squareAreaChanged.emit(self._square_area)
        self._deadzone_enabled = slot.deadzone_enabled
        self.deadzoneEnabledChanged.emit(self._deadzone_enabled)
        self._deadzone = slot.deadzone
        self.deadzoneChanged.emit(self._deadzone)
        self._sensitivity_curve = slot.sensitivity_curve
        self.sensitivityCurveChanged.emit(self._sensitivity_curve)
        self._steering_range = slot.steering_range
        self.steeringRangeChanged.emit(self._steering_range)
        self._center_spring = slot.center_spring
        self.centerSpringChanged.emit(self._center_spring)
        self._center_spring_width = slot.center_spring_width
        self.centerSpringWidthChanged.emit(self._center_spring_width)
        self._center_spring_ramp = slot.center_spring_ramp if SOFT_RAMP_AVAILABLE else False
        self.centerSpringRampChanged.emit(self._center_spring_ramp)
        self._center_spring_ramp_width = slot.center_spring_ramp_width
        self.centerSpringRampWidthChanged.emit(self._center_spring_ramp_width)
        self._neutral_steering_button = slot.neutral_steering_button
        self.neutralSteeringButtonChanged.emit()
        self._steering_action_button = slot.steering_action_button
        self._steering_action_max_percent = slot.steering_action_max_percent
        self._steering_action_mode = slot.steering_action_mode
        self._reset_steering_action_toggle()
        self.steeringActionChanged.emit()
        if slot.device_index >= 0 and slot.device_index < len(self._devices):
            self._current_device_index = slot.device_index
            self.currentDeviceIndexChanged.emit(self._current_device_index)

    @Slot()
    def force_save_settings(self):
        """Force save settings, callable from QML"""
        self.save_settings(force=True)
    
    @Slot(bool)
    def set_auto_start(self, enabled):
        """Set auto-start with Windows"""
        self._auto_start = enabled
        self.autoStartChanged.emit(enabled)
        
        if enabled:
            success = self.auto_start_manager.enable_auto_start(self._start_minimized)
            if success:
                self.statusChanged.emit("Auto-start enabled", "#22c55e")
            else:
                self.statusChanged.emit("Failed to enable auto-start", "#ef4444")
                self._auto_start = False
                self.autoStartChanged.emit(False)
        else:
            success = self.auto_start_manager.disable_auto_start()
            if success:
                self.statusChanged.emit("Auto-start disabled", "#71717a")
            else:
                self.statusChanged.emit("Failed to disable auto-start", "#ef4444")
                self._auto_start = True
                self.autoStartChanged.emit(True)
        
        self.save_settings()
    
    @Slot(bool)
    def set_start_minimized(self, enabled):
        """Set start minimized to tray"""
        self._start_minimized = enabled
        self.startMinimizedChanged.emit(enabled)
        if self._auto_start:
            self.apply_auto_start_setting()
        self.save_settings()
    
    def apply_auto_start_setting(self):
        """Apply the current auto-start setting"""
        if os.environ.get("TRUEAXIS_SKIP_STARTUP_SYNC") == "1":
            return
        if self._auto_start:
            success = self.auto_start_manager.enable_auto_start(self._start_minimized)
            if not success:
                print("Warning: failed to repair auto-start registration")
        else:
            self.auto_start_manager.disable_auto_start()
    
    def update_button_status(self):
        count = len(self.button_mapping)
        if count == 0:
            status = "No buttons mapped"
        elif count == 1:
            status = "1 button mapped"
        else:
            status = f"{count} buttons mapped"
        self.buttonStatusChanged.emit(status)
    
    @Slot()
    def refresh_devices(self):
        # Stop emulation if running
        if self._running:
            self.stop_mapping()
        
        # Clear current joystick from input reader
        if self.input_reader:
            self.input_reader.set_joystick(None)
        
        try:
            # Reinitialize pygame joystick system
            pygame.joystick.quit()
            pygame.joystick.init()
            
            # Get available devices
            count = pygame.joystick.get_count()
            self._devices = []
            
            for i in range(count):
                try:
                    joystick = pygame.joystick.Joystick(i)
                    joystick.init()  # Initialize to get name
                    device_name = joystick.get_name()
                    self._devices.append(f"{device_name} (ID: {i})")
                    joystick.quit()  # Quit to avoid conflicts
                except Exception as e:
                    print(f"Error initializing device {i}: {e}")
                    self._devices.append(f"Device {i} (Error)")
            
            if not self._devices:
                self._devices = ["No devices found"]
            
            self.devicesChanged.emit(self._devices)
            
            # Reset current joystick
            self.joystick = None
            self._axis_values = []
            self._button_states = []
            self.numAxesChanged.emit()
            self.numButtonsChanged.emit()
            self.axisValuesChanged.emit()
            self.buttonStatesChanged.emit()
            
            # Reset live preview values
            self.steeringChanged.emit(0.5)
            self.gasChanged.emit(0.0)
            self.brakeChanged.emit(0.0)
            
            if count > 0:
                # Don't auto-select during initialization if we have a saved device selection
                # The restore_device_selection() will handle selecting the saved device
                if not (hasattr(self, '_initializing') and self._initializing):
                    # Only auto-select during normal refresh (not during startup)
                    self.select_device(0)
                self.statusChanged.emit("Ready to activate", "#71717a")
            else:
                self.statusChanged.emit("No input device detected", "#f59e0b")
        except Exception as e:
            print(f"Error refreshing devices: {e}")
            self._devices = ["Error scanning devices"]
            self.devicesChanged.emit(self._devices)
            self.statusChanged.emit("Error scanning devices", "#ef4444")
    
    @Slot(int)
    def select_device(self, index):
        if self._running:
            # Don't allow device change while emulating
            self.statusChanged.emit("Stop emulation before changing device", "#f59e0b")
            return
        
        if 0 <= index < len(self._devices):
            # FIRST: Save the current device's button mappings before switching
            if self.joystick and hasattr(self, '_user_configured_devices'):
                old_device_name = self.joystick.get_name()
                # If this device has been configured by the user, save its current state
                if old_device_name in self._user_configured_devices and self._user_configured_devices[old_device_name]:
                    self._user_button_overrides[old_device_name] = self.button_mapping.copy()
                    print(f"[DEVICE SWITCH] Saved button state for {old_device_name}: {self.button_mapping}")
            
            self._current_device_index = index
            self.currentDeviceIndexChanged.emit(index)
            
            try:
                # Clean up old joystick if exists
                if self.joystick:
                    try:
                        self.joystick.quit()
                    except Exception:
                        pass
                
                # Extract device ID from the string (format: "Device Name (ID: X)")
                try:
                    device_id = int(self._devices[index].split("(ID: ")[1].rstrip(")"))
                except Exception:
                    device_id = index
                
                # Initialize new joystick
                self.joystick = pygame.joystick.Joystick(device_id)
                self.joystick.init()
                
                # Update input reader with new joystick
                if self.input_reader:
                    self.input_reader.set_joystick(self.joystick)
                
                # Initialize arrays with default values
                num_axes = self.joystick.get_numaxes()
                num_buttons = self.joystick.get_numbuttons()
                self._axis_values = [0.5] * num_axes
                self._button_states = [False] * num_buttons
                
                # Emit signals for UI updates
                self.numAxesChanged.emit()
                self.numButtonsChanged.emit()
                self.axisValuesChanged.emit()
                self.buttonStatesChanged.emit()
                
                device_name = self.joystick.get_name()
                print(f"[DEVICE SWITCH] Selected device: {device_name} with {num_axes} axes and {num_buttons} buttons")

                # Hardware settings are refreshed after the profile and mappings are resolved.
                
                # Check if user has a saved profile override for this device
                if device_name in self._user_profile_overrides:
                    # Use the user's saved preference
                    override_profile = self._user_profile_overrides[device_name]
                    if override_profile in PROFILES:
                        self._current_profile = override_profile
                        self._profile_source = "user_override"
                        self.currentProfileChanged.emit(override_profile)
                        desc = PROFILES[override_profile]["desc"]
                        self.profileDescChanged.emit(desc)
                        print(f"[DEVICE SWITCH] Loaded user override profile: {override_profile}")
                else:
                    # Auto-detect profile if no user override exists
                    detected_profile = self._detect_profile_from_device(device_name)
                    if detected_profile:
                        self._current_profile = detected_profile
                        self._profile_source = "auto_detected"
                        self.currentProfileChanged.emit(detected_profile)
                        desc = PROFILES[detected_profile]["desc"]
                        self.profileDescChanged.emit(desc)
                        print(f"[DEVICE SWITCH] Auto-detected profile: {detected_profile}")
                
                # Handle button mappings: check for user overrides or auto-detect
                # Set flag BEFORE making any changes to prevent marking these as user overrides
                self._loading_button_mappings = True
                print(f"[DEVICE SWITCH] Set _loading_button_mappings = True")
                
                # Check if this device has ever been explicitly configured by the user
                if device_name in self._user_configured_devices and self._user_configured_devices[device_name]:
                    # User has explicitly configured this device's buttons
                    # Load their saved configuration (even if empty - respects user's choice)
                    if device_name in self._user_button_overrides:
                        self.button_mapping = self._user_button_overrides[device_name].copy()
                    else:
                        self.button_mapping = {}
                    print(f"[DEVICE SWITCH] Loaded user-configured button mappings for {device_name}: {self.button_mapping}")
                else:
                    # Device has never been explicitly configured - load defaults for this specific device
                    default_buttons = self._detect_default_buttons_from_device(device_name)
                    if default_buttons:
                        self.button_mapping = default_buttons
                        print(f"[DEVICE SWITCH] Auto-detected default button mappings for {device_name}: {self.button_mapping}")
                    else:
                        # No default mapping found, start with empty mappings
                        self.button_mapping = {}
                        print(f"[DEVICE SWITCH] No default button mappings found for {device_name}")
                
                # Emit signal to update UI
                self.buttonMappingsChanged.emit()
                print(f"[DEVICE SWITCH] Emitted buttonMappingsChanged signal")
                
                # Update button status display
                self.update_button_status()
                
                # Reset the loading flag after a short delay to allow UI to update
                QTimer.singleShot(500, lambda: self._reset_loading_flag())
                
                self.statusChanged.emit("Ready to activate", "#71717a")
                
                # Save device selection (but not during initialization)
                if not (hasattr(self, '_initializing') and self._initializing):
                    self.save_settings()

                self._reassert_hardware_controls(pulse=True)
                
            except Exception as e:
                print(f"Error selecting device: {e}")
                self.joystick = None
                self._axis_values = []
                self._button_states = []
                self.numAxesChanged.emit()
                self.numButtonsChanged.emit()
                self.statusChanged.emit(f"Error selecting device: {str(e)}", "#ef4444")
    
    def _reset_loading_flag(self):
        """Helper method to reset loading flag"""
        self._loading_button_mappings = False
        print(f"[DEVICE SWITCH] Set _loading_button_mappings = False")
    
    @Slot()
    def start_button_calibration(self):
        """Start the automatic button calibration wizard"""
        if not self.joystick:
            self.statusChanged.emit("No device selected", "#ef4444")
            return
        
        # This will be triggered from QML
        # The QML dialog will guide the user through pressing each button
        device_name = self.joystick.get_name()
        print(f"[CALIBRATION] Starting button calibration for {device_name}")
        self.statusChanged.emit("Starting button calibration...", "#22c55e")
    
    @Slot(result=list)
    def get_pressed_buttons(self):
        """Get list of currently pressed button indices"""
        _, button_states = self.input_reader.snapshot() if self.input_reader else ([], [])
        if not button_states:
            button_states = self._button_states
        pressed = []
        for i, state in enumerate(button_states):
            if state:
                pressed.append(i)
        return pressed
    
    @Slot(int, str)
    def calibrate_button(self, button_index, xbox_button_name):
        """Called during calibration to map a detected button to an Xbox button"""
        print(f"[CALIBRATION] Mapping button {button_index} -> {xbox_button_name}")
        self.button_mapping[str(button_index)] = xbox_button_name
        
        # Mark device as configured
        if self.joystick:
            device_name = self.joystick.get_name()
            self._user_button_overrides[device_name] = self.button_mapping.copy()
            self._user_configured_devices[device_name] = True
        
        # Update UI
        self.buttonMappingsChanged.emit()
        self.update_button_status()
    
    @Slot()
    def finish_calibration(self):
        """Finish calibration and save mappings"""
        if self.joystick:
            device_name = self.joystick.get_name()
            print(f"[CALIBRATION] Finished calibration for {device_name}")
            print(f"[CALIBRATION] Final mappings: {self.button_mapping}")
            self.save_settings()
            self.save_button_mapping()
            self.statusChanged.emit("Button calibration complete!", "#22c55e")
    
    def _detect_profile_from_device(self, device_name):
        """Detect appropriate profile based on device name"""
        device_name_lower = device_name.lower()
        if any(token in device_name_lower for token in GAMEPAD_HID_NAME_TOKENS) and not any(token in device_name_lower for token in WHEEL_HID_NAME_TOKENS):
            return "Generic Gamepad (Xbox/XInput)"
        
        for keywords, profile_name in DEVICE_PROFILE_PATTERNS:
            for keyword in keywords:
                if keyword.lower() in device_name_lower:
                    return profile_name
        
        return None  # No match found
    
    def _detect_default_buttons_from_device(self, device_name):
        """Detect default button mappings based on device name"""
        device_name_lower = device_name.lower()
        
        # Check for specific device matches (more specific first)
        if "dualsense" in device_name_lower:
            return DEVICE_DEFAULT_BUTTONS["dualsense"].copy()
        elif "dualshock" in device_name_lower:
            return DEVICE_DEFAULT_BUTTONS["dualshock"].copy()
        elif "wireless controller" in device_name_lower:
            return DEVICE_DEFAULT_BUTTONS["wireless controller"].copy()
        elif "xbox 360" in device_name_lower:
            return DEVICE_DEFAULT_BUTTONS["xbox 360"].copy()
        elif "xbox" in device_name_lower or "xinput" in device_name_lower:
            return DEVICE_DEFAULT_BUTTONS["xbox"].copy()
        elif "g29" in device_name_lower:
            return DEVICE_DEFAULT_BUTTONS["g29"].copy()
        elif "g920" in device_name_lower:
            return DEVICE_DEFAULT_BUTTONS["g920"].copy()
        elif "g923" in device_name_lower:
            return DEVICE_DEFAULT_BUTTONS["g923"].copy()
        elif "g27" in device_name_lower:
            return DEVICE_DEFAULT_BUTTONS["g27"].copy()
        elif "thrustmaster" in device_name_lower or "t300" in device_name_lower or "t150" in device_name_lower:
            return DEVICE_DEFAULT_BUTTONS["thrustmaster"].copy()
        elif "fanatec" in device_name_lower or "csl" in device_name_lower or "clubsport" in device_name_lower:
            return DEVICE_DEFAULT_BUTTONS["fanatec"].copy()
        
        return None  # No match found
    
    @Slot(str)
    def select_profile(self, profile_name):
        if profile_name in PROFILES:
            self._current_profile = profile_name
            self._profile_source = "user_selected"
            self.currentProfileChanged.emit(profile_name)
            desc = PROFILES[profile_name]["desc"]
            self.profileDescChanged.emit(desc)
            
            # Mark this as a user override for the current device
            if self.joystick:
                device_name = self.joystick.get_name()
                self._user_profile_overrides[device_name] = profile_name
                print(f"User selected profile '{profile_name}' for device '{device_name}'")
            
            self.save_settings()
    
    @Slot(bool)
    def set_invert_gas(self, value):
        self._invert_gas = value
        self.invertGasChanged.emit(value)
        self.save_settings()
    
    @Slot(bool)
    def set_invert_brake(self, value):
        self._invert_brake = value
        self.invertBrakeChanged.emit(value)
        self.save_settings()
    
    @Slot(bool)
    def set_square_input(self, value):
        self._square_input = value
        self.squareInputChanged.emit(value)
        self.save_settings()
    
    @Slot(float)
    def set_square_area(self, value):
        self._square_area = value
        self.squareAreaChanged.emit(value)
        self.save_settings()
    
    @Slot(bool)
    def set_deadzone_enabled(self, value):
        self._deadzone_enabled = value
        self.deadzoneEnabledChanged.emit(value)
        self.save_settings()
    
    @Slot(float)
    def set_deadzone(self, value):
        self._deadzone = value
        self.deadzoneChanged.emit(value)
        self.save_settings()
    
    @Slot(str)
    def set_sensitivity_curve(self, curve):
        """Set the steering sensitivity curve type"""
        self._sensitivity_curve = curve.lower()
        self.sensitivityCurveChanged.emit(self._sensitivity_curve)
        self.save_settings()

    @Slot(str)
    def set_theme_mode(self, mode):
        mode = str(mode or "").strip().lower()
        if mode not in ("light", "dark"):
            mode = "light"
        if self._theme_mode == mode:
            return
        self._theme_mode = mode
        self.themeModeChanged.emit(mode)
        self.save_settings()

    @Slot()
    def toggle_theme_mode(self):
        self.set_theme_mode("dark" if self._theme_mode != "dark" else "light")

    def _current_device_name(self):
        try:
            if self.joystick:
                return self.joystick.get_name()
        except Exception:
            pass
        if 0 <= self._current_device_index < len(self._devices):
            return self._devices[self._current_device_index].split(" (ID: ")[0]
        return ""

    def _current_device_category(self):
        name = (self._current_device_name() or "").lower()
        if not name or "no devices found" in name or "scanning devices" in name:
            return "no_device"
        wheel_tokens = (
            "wheel", "g29", "g920", "g923", "g27", "g25", "driving force",
            "thrustmaster", "t300", "t150", "t248", "t818", "tmx", "t-gt",
            "moza", "fanatec", "csl", "clubsport", "pxn", "hori racing",
            "hori wheel", "racing wheel apex", "simucube",
            "simagic", "asetek", "vrs", "cammus", "momo"
        )
        gamepad_tokens = (
            "xbox", "controller", "gamepad", "dualshock", "dualsense",
            "8bitdo", "stadia", "wireless controller", "game sir", "gamesir",
            "horipad"
        )
        if any(token in name for token in wheel_tokens):
            return "wheel"
        if any(token in name for token in gamepad_tokens):
            return "gamepad"
        return "unknown_device"

    def _current_device_is_logitech_wheel(self):
        name = (self._current_device_name() or "").lower()
        return any(keyword in name for keyword in (
            "logitech", "g29", "g920", "g923", "g27", "g25",
            "driving force", "momo"
        )) and self._current_device_category() == "wheel"

    def _current_max_steering_range(self):
        return detect_max_steering_range(self._current_device_name(), self._current_profile)

    def _uses_logitech_hid(self):
        device_name = (self._current_device_name() or "").lower()
        if device_name:
            return self._current_device_is_logitech_wheel()
        return self._current_profile in LOGITECH_HID_PROFILE_NAMES

    def _hardware_control_vendor(self):
        device_text = (self._current_device_name() or "").lower()
        profile_text = (self._current_profile or "").lower()
        category = self._current_device_category()
        text = device_text or profile_text
        if category == "gamepad":
            return "gamepad"
        if self._uses_logitech_hid():
            return "logitech"
        if "moza" in text:
            return "moza"
        if "fanatec" in text:
            return "fanatec"
        if "thrustmaster" in text or any(name in text for name in ("t300", "t500", "t150", "t248", "t-gt", "t818", "tmx")):
            return "thrustmaster"
        if "pxn" in text:
            return "pxn"
        if "hori" in text:
            return "hori"
        if any(name in text for name in ("simucube", "simagic", "asetek", "invicta", "vrs", "cammus", "directforce", "la prima", "forte")):
            return "directdrive"
        return "unknown"

    def _logitech_target_hint(self):
        return f"{self._current_device_name()} {self._current_profile}"

    def _logitech_detected_protocols(self, target_hint=None):
        try:
            if target_hint is None and not self._uses_logitech_hid():
                return []
            hint = self._logitech_target_hint() if target_hint is None else target_hint
            return sorted(set(d.get('protocol') for d in _filtered_logitech_hid_devices(hint) if d.get('protocol')))
        except Exception:
            return []

    def _hardware_range_protocols(self):
        vendor = self._hardware_control_vendor()
        if vendor == "logitech":
            return self._logitech_detected_protocols()
        if vendor == "moza":
            return ["moza_sdk"] if _moza_sdk_ready() else ["moza_diagnostic"]
        if vendor in ("thrustmaster", "fanatec", "pxn", "hori", "directdrive"):
            return [f"{vendor}_diagnostic"]
        return []

    def _hardware_steering_range_supported(self):
        vendor = self._hardware_control_vendor()
        if vendor == "moza":
            return _moza_sdk_ready()
        if vendor != "logitech" or not self._uses_logitech_hid():
            return False
        protocols = self._logitech_detected_protocols()
        return any(protocol in protocols for protocol in ("lg4ff", "dfp", "g923_ps_mode", "hidpp_g920"))

    def _hardware_center_spring_supported(self):
        if not self._uses_logitech_hid():
            return False
        protocols = self._logitech_detected_protocols()
        return any(protocol in protocols for protocol in ("lg4ff", "dfp"))

    def _hardware_controls_supported(self):
        return self._hardware_steering_range_supported() or self._hardware_center_spring_supported()

    def _hardware_support_reason(self):
        category = self._current_device_category()
        vendor = self._hardware_control_vendor()
        protocols = self._logitech_detected_protocols()
        if category == "no_device":
            return "no_device"
        if category == "gamepad":
            return "gamepad_only"
        if vendor == "logitech":
            if self._hardware_steering_range_supported():
                return "logitech_hardware_supported"
            return "logitech_no_supported_endpoint"
        if vendor == "moza":
            return "moza_sdk_ready" if _moza_sdk_ready() else "moza_sdk_missing"
        if vendor in ("fanatec", "thrustmaster", "directdrive", "pxn", "hori"):
            return f"{vendor}_sdk_required"
        if category == "wheel":
            return "wheel_detected_no_protocol"
        if protocols:
            return "protocol_detected_wrong_selection"
        return "unknown_device"

    def _hardware_range_provider_summary(self):
        vendor = self._hardware_control_vendor()
        reason = self._hardware_support_reason()
        protocols = self._logitech_detected_protocols()
        if vendor == "logitech" and self._hardware_steering_range_supported():
            return "logitech_hid", reason, "Logitech hardware range provider ready"
        if vendor == "logitech":
            return "logitech_hid", reason, self._hardware_unsupported_message("steering range")
        if vendor == "moza" and self._hardware_steering_range_supported():
            return "moza_sdk", reason, "MOZA SDK hardware range provider ready"
        if vendor == "moza":
            return "moza_sdk", reason, self._hardware_unsupported_message("steering range")
        if vendor in ("moza", "fanatec", "thrustmaster", "directdrive", "pxn", "hori"):
            return f"{vendor}_diagnostic", reason, self._hardware_unsupported_message("steering range")
        if vendor in ("gamepad", "unknown"):
            return "diagnostic_only", reason, self._hardware_unsupported_message("steering range")
        return f"{vendor}_diagnostic", reason, self._hardware_unsupported_message("steering range")

    def _hardware_unsupported_message(self, control_name):
        vendor = self._hardware_control_vendor()
        category = self._current_device_category()
        if category == "no_device":
            return "No input device selected"
        if category == "gamepad":
            return "Selected device is a controller; hardware steering range only applies to force-feedback wheels"
        if vendor == "logitech":
            protocols = ", ".join(self._logitech_detected_protocols()) or "none"
            if control_name == "center spring":
                return f"Logitech center spring needs an lg4ff-compatible wheel (detected: {protocols})"
            return f"Logitech hardware {control_name} needs a supported HID range provider (detected: {protocols})"
        if vendor == "moza":
            return f"MOZA {control_name} needs the MOZA SDK bridge ({_moza_sdk_status()})"
        if vendor == "fanatec":
            return f"Fanatec {control_name} needs official Fanatec SDK support"
        if vendor == "thrustmaster":
            return f"Thrustmaster {control_name} needs the Thrustmaster Control Panel, My Thrustmaster, or private SDK access"
        if vendor == "pxn":
            return f"PXN {control_name} needs the PXN Wheel app or a verified vendor protocol"
        if vendor == "hori":
            return f"HORI {control_name} needs the HORI Device Manager app or a verified vendor protocol"
        if vendor == "directdrive":
            return f"{control_name} needs this wheelbase vendor's app or SDK"
        return f"Hardware {control_name} not supported for this wheel yet"

    def _apply_hardware_steering_range(self):
        """Try hardware steering range when a supported wheel provider is selected."""
        if not self._hardware_steering_range_supported():
            self._hardware_steering_range_active = False
            self._last_hardware_range = None
            return False
        vendor = self._hardware_control_vendor()
        if vendor == "moza":
            hardware_range = min(2000, int(self._steering_range))
            ok = set_moza_wheel_range(hardware_range)
            self._hardware_steering_range_active = ok
            self._last_hardware_range = MOZA_LAST_RANGE_APPLIED if ok else None
            if ok:
                print(f"[WHEEL RANGE] MOZA hardware range active: {self._last_hardware_range} deg")
            else:
                print("[WHEEL RANGE] MOZA hardware range unavailable")
            return ok

        hardware_range = min(1080, int(self._steering_range))
        target_hint = self._logitech_target_hint()
        protocols = self._logitech_detected_protocols(target_hint)
        ok = set_logitech_wheel_range(hardware_range, target_hint=target_hint, allow_mode_switch=("g923_ps_mode" in protocols))
        self._hardware_steering_range_active = ok
        self._last_hardware_range = LOGITECH_LAST_RANGE_APPLIED if ok else None
        if ok:
            print(f"[WHEEL RANGE] Hardware range active: {self._last_hardware_range} deg")
        else:
            print("[WHEEL RANGE] Hardware range unavailable for this wheel")
        return ok

    def _apply_hardware_center_spring(self, strength):
        """Only Logitech HID exposes the center-spring command used by TrueAxis."""
        if not self._hardware_center_spring_supported():
            return False
        return set_logitech_wheel_autocenter(strength)

    def _center_spring_target(self):
        return max(0.0, min(1.0, float(self._center_spring)))

    def _pulse_hardware_center_spring(self):
        """Nudge autocenter away and back so wheel firmware reloads the baseline."""
        if not self._hardware_center_spring_supported():
            return False

        target = self._center_spring_target()
        pulse_value = 0.0 if target >= CENTER_SPRING_PULSE_STRENGTH else CENTER_SPRING_PULSE_STRENGTH

        def pulse_worker():
            try:
                self._apply_hardware_center_spring(pulse_value)
                time.sleep(CENTER_SPRING_PULSE_DELAY_SECONDS)
                if self._apply_hardware_center_spring(target):
                    self._last_hardware_spring = round(target, 2)
                    self._last_hardware_spring_sent_at = time.time()
            except Exception as e:
                print(f"[WHEEL HID] Center spring pulse failed: {e}")

        threading.Thread(target=pulse_worker, daemon=True).start()
        return True

    def _apply_center_spring_baseline(self, force=False, pulse=False):
        """Apply the configured hardware autocenter strength, including 0%."""
        if not self._hardware_center_spring_supported():
            return False
        if pulse:
            return self._pulse_hardware_center_spring()

        target = self._center_spring_target()
        now = time.time()
        target_rounded = round(target, 2)
        due_for_reassert = (now - self._last_hardware_spring_sent_at) >= (HARDWARE_REASSERT_INTERVAL_MS / 1000.0)
        if not force and not due_for_reassert and abs(target_rounded - self._last_hardware_spring) < 0.005:
            return True

        ok = self._apply_hardware_center_spring(target)
        if ok:
            self._last_hardware_spring = target_rounded
            self._last_hardware_spring_sent_at = now
        return ok

    @Slot()
    def reapply_hardware_settings(self):
        self._reassert_hardware_controls(pulse=True, show_status=True)

    def _reassert_hardware_controls(self, pulse=False, show_status=False):
        """Best-effort hardware refresh for wheel firmware that forgets settings."""
        if not self._hardware_controls_supported():
            return False

        range_ok = self._apply_hardware_steering_range()
        if self._hardware_center_spring_supported() and self._center_spring_ramp and self._running:
            self._last_hardware_spring = -1.0 if pulse else self._last_hardware_spring
            spring_ok = self.update_hardware_center_spring(self._current_raw_steering_input())
        elif self._hardware_center_spring_supported():
            spring_ok = self._apply_center_spring_baseline(force=not pulse, pulse=pulse)
        else:
            spring_ok = False

        if show_status:
            if range_ok or spring_ok:
                self.statusChanged.emit("Wheel hardware settings refreshed", "#22c55e")
            else:
                self.statusChanged.emit("Wheel hardware refresh failed", "#f59e0b")
        return range_ok or spring_ok

    def _hardware_range_snapshot_payload(self):
        hint = self._logitech_target_hint()
        vendor = self._hardware_control_vendor()
        if HID_AVAILABLE and vendor == "logitech" and self._uses_logitech_hid():
            provider_devices = _filtered_logitech_hid_devices(hint)
        elif HID_AVAILABLE and vendor == "moza":
            provider_devices = [d for d in _wheel_hid_candidates(hint) if _hid_vendor_label(d) == "moza"]
        else:
            provider_devices = []
        diagnostic_devices = _wheel_hid_candidates(hint) if HID_AVAILABLE else []
        candidates = [_hid_report_summary(d) for d in provider_devices]
        diagnostic_candidates = [_hid_report_summary(d) for d in diagnostic_devices]

        protocols = self._hardware_range_protocols()
        supported = self._hardware_steering_range_supported()
        provider, support_reason, provider_message = self._hardware_range_provider_summary()
        if not HID_AVAILABLE:
            message = "HID support is not available in this build"
        elif supported:
            message = "Ready to test real hardware range"
        elif diagnostic_candidates:
            message = provider_message
        else:
            message = provider_message

        return {
            "selectedDevice": self._current_device_name() or "No device selected",
            "profile": self._current_profile,
            "hint": hint,
            "supported": supported,
            "provider": provider,
            "supportReason": support_reason,
            "deviceCategory": self._current_device_category(),
            "profileSource": self._profile_source,
            "protocols": protocols,
            "candidates": candidates,
            "diagnosticHidCandidates": diagnostic_candidates,
            "message": message,
            "lastApplied": self._last_hardware_range if self._last_hardware_range is not None else "",
            "joysticks": self._joystick_inventory_payload(),
            "testLog": self._hardware_range_test_log[-8:],
        }

    def _hardware_range_json(self, payload):
        try:
            return json.dumps(payload)
        except Exception as e:
            return json.dumps({"ok": False, "status": f"Could not encode hardware report: {e}"})

    @Slot(result=str)
    def hardware_range_snapshot_json(self):
        return self._hardware_range_json(self._hardware_range_snapshot_payload())

    @Slot(int, result=str)
    def run_hardware_range_test_json(self, degrees):
        degrees = max(10, min(int(self._current_max_steering_range()), int(degrees)))
        hint = self._logitech_target_hint()
        requested_at = time.strftime("%Y-%m-%d %H:%M:%S")
        ok = False
        error_status = ""
        if not self._hardware_steering_range_supported():
            error_status = self._hardware_unsupported_message("steering range")
        else:
            vendor = self._hardware_control_vendor()
            if vendor == "moza":
                ok = set_moza_wheel_range(degrees)
                if ok:
                    self._last_hardware_range = MOZA_LAST_RANGE_APPLIED
                    self._hardware_steering_range_active = True
                    self.steeringRangeChanged.emit(self._steering_range)
                else:
                    self._hardware_steering_range_active = False
                    self._last_hardware_range = None
                    error_status = MOZA_LAST_RANGE_STATUS or "MOZA hardware steering range command failed"
            else:
                protocols = self._logitech_detected_protocols(hint)
                ok = set_logitech_wheel_range(degrees, target_hint=hint, allow_mode_switch=("g923_ps_mode" in protocols))
                if ok:
                    self._last_hardware_range = LOGITECH_LAST_RANGE_APPLIED
                    self._hardware_steering_range_active = True
                    self.steeringRangeChanged.emit(self._steering_range)
                else:
                    self._hardware_steering_range_active = False
                    self._last_hardware_range = None
                    error_status = LOGITECH_LAST_RANGE_STATUS or "Hardware steering range command failed"

        applied = self._last_hardware_range if ok and self._last_hardware_range is not None else ""
        status = f"Range command sent: {applied} deg" if ok else error_status
        entry = {
            "time": requested_at,
            "type": "range",
            "requested": degrees,
            "applied": applied,
            "ok": ok,
            "status": status,
            "device": self._current_device_name(),
            "profile": self._current_profile,
            "protocols": self._hardware_range_protocols(),
        }
        self._hardware_range_test_log.append(entry)
        self._hardware_range_test_log = self._hardware_range_test_log[-20:]
        self.statusChanged.emit(status, "#22c55e" if ok else "#f59e0b")
        payload = self._hardware_range_snapshot_payload()
        payload.update(entry)
        return self._hardware_range_json(payload)

    @Slot(result=str)
    def run_g923_mode_switch_test_json(self):
        hint = self._logitech_target_hint()
        requested_at = time.strftime("%Y-%m-%d %H:%M:%S")
        target = max(180, min(900, int(self._steering_range or 900)))
        ok = set_logitech_wheel_range(target, target_hint=hint, allow_mode_switch=True)
        applied = LOGITECH_LAST_RANGE_APPLIED if ok else ""
        status = LOGITECH_LAST_RANGE_STATUS or ("G923 mode switch command sent" if ok else "G923 mode switch failed")
        entry = {
            "time": requested_at,
            "type": "g923_mode_switch",
            "requested": target,
            "applied": applied,
            "ok": ok,
            "status": status,
            "device": self._current_device_name(),
            "profile": self._current_profile,
            "protocols": self._hardware_range_protocols(),
        }
        self._hardware_range_test_log.append(entry)
        self._hardware_range_test_log = self._hardware_range_test_log[-20:]
        self.statusChanged.emit(status, "#22c55e" if ok else "#f59e0b")
        payload = self._hardware_range_snapshot_payload()
        payload.update(entry)
        return self._hardware_range_json(payload)

    @Slot(int, str, result=str)
    def record_hardware_range_confirmation_json(self, degrees, confirmation):
        normalized = str(confirmation or "skip").lower()
        if normalized not in ("yes", "no", "skip"):
            normalized = "skip"
        command_entry = next((
            item for item in reversed(self._hardware_range_test_log)
            if item.get("type") in ("range", "g923_mode_switch") and int(item.get("requested") or 0) == int(degrees)
        ), None)
        command_applied = command_entry.get("applied") if isinstance(command_entry, dict) else ""
        entry = {
            "time": time.strftime("%Y-%m-%d %H:%M:%S"),
            "type": "physical_confirmation",
            "requested": int(degrees),
            "applied": command_applied if command_applied != "" else (self._last_hardware_range if self._last_hardware_range is not None else ""),
            "commandStatus": command_entry.get("status", "") if isinstance(command_entry, dict) else "",
            "ok": normalized == "yes",
            "status": f"Physical lock confirmation: {normalized}",
            "device": self._current_device_name(),
            "profile": self._current_profile,
            "protocols": self._hardware_range_protocols(),
        }
        self._hardware_range_test_log.append(entry)
        self._hardware_range_test_log = self._hardware_range_test_log[-20:]
        self._send_hardware_range_report_async(auto=True)
        return self._hardware_range_json(entry)

    @Slot(result=str)
    def copy_hardware_range_report_json(self):
        report = self._hardware_range_snapshot_payload()
        report["copiedAt"] = time.strftime("%Y-%m-%d %H:%M:%S")
        report["appVersion"] = CURRENT_VERSION
        text = json.dumps(report, indent=2)
        try:
            QGuiApplication.clipboard().setText(text)
            return self._hardware_range_json({"ok": True, "status": "Hardware report copied"})
        except Exception as e:
            return self._hardware_range_json({"ok": False, "status": f"Could not copy report: {e}"})

    def _json_post_to_endpoints(self, urls, payload, timeout=6):
        body = json.dumps(payload).encode("utf-8")
        headers = {
            "Content-Type": "application/json",
            "Accept": "application/json",
            "User-Agent": f"TrueAxis/{CURRENT_VERSION} ({UPDATE_CHANNEL})",
        }
        last_error = "No endpoint configured"
        for url in urls:
            try:
                request = urllib.request.Request(url, data=body, headers=headers, method="POST")
                with urllib.request.urlopen(request, timeout=timeout) as response:
                    status = getattr(response, "status", response.getcode())
                    if 200 <= int(status) < 300:
                        return True, url, int(status), response.read(2048).decode("utf-8", errors="replace")
                    last_error = f"HTTP {status} from {url}"
            except urllib.error.HTTPError as e:
                last_error = f"HTTP {e.code} from {url}"
            except Exception as e:
                last_error = f"{type(e).__name__}: {e}"
        return False, "", 0, last_error

    def _base_telemetry_payload(self, event, details=None):
        return {
            "schema": 1,
            "app": "trueaxis",
            "channel": UPDATE_CHANNEL,
            "platform": UPDATE_PLATFORM,
            "version": CURRENT_VERSION,
            "event": str(event or "").strip(),
            "install_id": self._install_id,
            "sent_at": int(time.time()),
            "details": details if isinstance(details, dict) else {},
        }

    def _joystick_inventory_payload(self):
        devices = []
        try:
            count = pygame.joystick.get_count()
        except Exception:
            count = 0

        for index in range(min(int(count or 0), 12)):
            joystick = None
            try:
                joystick = pygame.joystick.Joystick(index)
                if not joystick.get_init():
                    joystick.init()
                devices.append({
                    "index": index,
                    "name": str(joystick.get_name() or ""),
                    "axes": int(joystick.get_numaxes()),
                    "buttons": int(joystick.get_numbuttons()),
                    "hats": int(joystick.get_numhats()),
                })
            except Exception as e:
                devices.append({
                    "index": index,
                    "name": "Unknown device",
                    "error": str(e)[:120],
                })

        return devices

    def _telemetry_context_details(self):
        joystick_devices = self._joystick_inventory_payload()
        protocols = self._logitech_detected_protocols()
        selected_device = self._current_device_name() or ""
        return {
            "selected_device": selected_device,
            "profile": self._current_profile,
            "device_count": len(joystick_devices),
            "devices": joystick_devices,
            "protocols": protocols,
            "hardware_vendor": self._hardware_control_vendor(),
            "hardware_supported": self._hardware_controls_supported(),
            "hardware_reason": self._hardware_support_reason(),
            "device_category": self._current_device_category(),
            "profile_source": self._profile_source,
            "steering_range": int(self._steering_range),
            "center_spring": round(float(self._center_spring), 3),
            "theme": self._theme_mode,
            "running": bool(self._running),
        }

    def _send_telemetry_event_async(self, event, details=None, on_success=None):
        if os.getenv("TRUEAXIS_DISABLE_TELEMETRY") == "1":
            return
        payload = self._base_telemetry_payload(event, details)

        def send_thread():
            ok, url, status, result = self._json_post_to_endpoints(TELEMETRY_URLS, payload, timeout=5)
            if ok and callable(on_success):
                try:
                    on_success()
                except Exception as e:
                    print(f"Telemetry success callback failed: {e}")
            elif not ok:
                print(f"Telemetry '{event}' failed: {result}")

        threading.Thread(target=send_thread, daemon=True).start()

    def _send_startup_telemetry(self):
        state = _load_json_with_backup(TELEMETRY_STATE_FILE) or {}
        if not isinstance(state, dict):
            state = {}

        install_sent = bool(state.get("install_sent"))
        version_key = f"version_seen:{CURRENT_VERSION}"

        def save_state_field(key, value=True):
            current = _load_json_with_backup(TELEMETRY_STATE_FILE) or {}
            if not isinstance(current, dict):
                current = {}
            current[key] = value
            current["updated_at"] = int(time.time())
            try:
                _atomic_json_save(TELEMETRY_STATE_FILE, current)
            except Exception as e:
                print(f"Could not save telemetry state: {e}")

        if not install_sent:
            self._send_telemetry_event_async("install", on_success=lambda: save_state_field("install_sent"))

        if not state.get(version_key):
            self._send_telemetry_event_async(
                "version_seen",
                {"previous_version": str(state.get("last_version") or "")},
                on_success=lambda: (save_state_field(version_key), save_state_field("last_version", CURRENT_VERSION)),
            )

        self._send_telemetry_event_async("app_start", self._telemetry_context_details())

    def _send_presence_telemetry(self):
        details = self._telemetry_context_details()
        details["heartbeat_interval_ms"] = TELEMETRY_HEARTBEAT_INTERVAL_MS
        self._send_telemetry_event_async("heartbeat", details)

    def _hardware_range_report_payload(self):
        report = self._hardware_range_snapshot_payload()
        report.update({
            "schema": 1,
            "app": "trueaxis",
            "channel": UPDATE_CHANNEL,
            "platform": UPDATE_PLATFORM,
            "appVersion": CURRENT_VERSION,
            "install_id": self._install_id,
            "reportedAt": int(time.time()),
        })
        return report

    def _send_hardware_range_report_async(self, auto=False):
        payload = self._hardware_range_report_payload()
        payload["auto"] = bool(auto)

        def send_thread():
            ok, url, status, result = self._json_post_to_endpoints(HARDWARE_REPORT_URLS, payload, timeout=8)
            if ok:
                message = "Hardware report sent"
                self.hardwareReportStatusChanged.emit(message, "#22c55e")
                if not auto:
                    self.statusChanged.emit(message, "#22c55e")
            else:
                message = "Could not send hardware report"
                self.hardwareReportStatusChanged.emit(message, "#f59e0b")
                if not auto:
                    self.statusChanged.emit(f"{message}: {result}", "#f59e0b")

        threading.Thread(target=send_thread, daemon=True).start()

    @Slot(result=str)
    def submit_hardware_range_report_json(self):
        self._send_hardware_range_report_async(auto=False)
        return self._hardware_range_json({"ok": True, "status": "Sending hardware report..."})

    @Slot(int)
    def set_steering_range(self, degrees):
        """Set steering range on the physical wheel hardware when supported."""
        max_range = self._current_max_steering_range()
        self._steering_range = max(10, min(int(max_range), int(degrees)))
        if not self._hardware_steering_range_supported():
            self.statusChanged.emit(self._hardware_unsupported_message("steering range"), "#f59e0b")
        elif self._apply_hardware_steering_range():
            applied = LOGITECH_LAST_RANGE_APPLIED or self._steering_range
            self.statusChanged.emit(f"Hardware steering range set: {applied} deg", "#22c55e")
        else:
            self.statusChanged.emit(LOGITECH_LAST_RANGE_STATUS or "Hardware steering range command failed", "#f59e0b")
        self.steeringRangeChanged.emit(self._steering_range)
        self.save_settings()

    @Slot(float)
    def set_center_spring(self, value):
        """Set the center spring strength (0.0 = off, 1.0 = maximum).

        When ramp is enabled, the hardware spring is dynamically updated by
        update_hardware_center_spring() in the mapping loop. This call just
        stores the target and resets the last value so the next frame picks it up.
        When ramp is disabled, this sends directly to the wheel hardware.
        """
        self._center_spring = max(0.0, min(1.0, value))
        if not self._center_spring_ramp:
            if not self._hardware_center_spring_supported():
                if self._center_spring > 0:
                    self.statusChanged.emit(self._hardware_unsupported_message("center spring"), "#f59e0b")
            elif not self._apply_center_spring_baseline(force=True):
                self.statusChanged.emit("Hardware center spring command failed", "#f59e0b")
            else:
                self.statusChanged.emit(f"Hardware center spring set: {int(self._center_spring * 100)}%", "#22c55e")
        else:
            # Reset last value so the ramp immediately applies the new baseline.
            self._last_hardware_spring = -1.0
            if self._hardware_center_spring_supported():
                self.update_hardware_center_spring(self._current_raw_steering_input())
        self.centerSpringChanged.emit(self._center_spring)
        self.save_settings()

    @Slot(float)
    def set_center_spring_width(self, value):
        """Deprecated - center spring now applies to full steering range via hardware"""
        pass

    @Slot(bool)
    def set_center_spring_ramp(self, enabled):
        """Set center spring ramp enabled/disabled.

        When enabled: hardware spring is dynamically updated by the mapping loop.
        When disabled: hardware spring is sent directly at the current centerSpring value.
        """
        if not SOFT_RAMP_AVAILABLE:
            self._center_spring_ramp = False
            self._last_hardware_spring = -1.0
            self.centerSpringRampChanged.emit(False)
            self.save_settings()
            return

        if enabled and not self._hardware_center_spring_supported():
            self._center_spring_ramp = False
            self._last_hardware_spring = -1.0
            self.centerSpringRampChanged.emit(False)
            self.statusChanged.emit(self._hardware_unsupported_message("soft ramp"), "#f59e0b")
            self.save_settings()
            return

        self._center_spring_ramp = enabled
        self._last_hardware_spring = -1.0  # Reset so next frame picks it up
        self.centerSpringRampChanged.emit(self._center_spring_ramp)
        self.save_settings()
        if enabled:
            if self.update_hardware_center_spring(self._current_raw_steering_input()):
                self.statusChanged.emit("Hardware soft ramp enabled", "#22c55e")
            else:
                self.statusChanged.emit("Hardware soft ramp command failed", "#f59e0b")
        else:
            if self._apply_center_spring_baseline(force=True):
                self.statusChanged.emit("Hardware soft ramp disabled", "#22c55e")

    @Slot(float)
    def set_center_spring_ramp_width(self, value):
        """Set center spring ramp zone width in degrees"""
        self._center_spring_ramp_width = max(1.0, min(90.0, value))
        self.centerSpringRampWidthChanged.emit(self._center_spring_ramp_width)
        if self._center_spring_ramp:
            self._last_hardware_spring = -1.0
            self.update_hardware_center_spring(self._current_raw_steering_input())
        self.save_settings()

    @Slot(result=str)
    def export_settings(self):
        """Export all settings to a JSON file"""
        try:
            import datetime
            from pathlib import Path
            
            # Create export data
            export_data = {
                'version': CURRENT_VERSION,
                'exported_at': datetime.datetime.now().isoformat(),
                'settings': {
                    'auto_start': self._auto_start,
                    'start_minimized': self._start_minimized,
                    'invert_gas': self._invert_gas,
                    'invert_brake': self._invert_brake,
                    'square_input': self._square_input,
                    'square_area': self._square_area,
                    'deadzone_enabled': self._deadzone_enabled,
                    'deadzone': self._deadzone,
                    'sensitivity_curve': self._sensitivity_curve,
                    'theme_mode': self._theme_mode,
                    'steering_range': self._steering_range,
                    'center_spring': self._center_spring,
                    'center_spring_width': self._center_spring_width,
                    'center_spring_ramp': self._center_spring_ramp if SOFT_RAMP_AVAILABLE else False,
                    'center_spring_ramp_width': self._center_spring_ramp_width,
                    'neutral_steering_button': self._neutral_steering_button,
                    'steering_action_button': self._steering_action_button,
                    'steering_action_max_percent': self._steering_action_max_percent,
                    'steering_action_mode': self._steering_action_mode,
                    'current_profile': self._current_profile,
                    'user_profile_overrides': self._user_profile_overrides,
                    'user_button_overrides': self._user_button_overrides,
                    'user_configured_devices': self._user_configured_devices,
                    'button_mapping': self.button_mapping,
                    'device_slots': [slot.to_dict() for slot in self._device_slots],
                    'active_slot_index': self._active_slot_index,
                },
                'tray_settings': {
                    'hide_to_tray': self.tray_manager._hide_to_tray_enabled if hasattr(self, 'tray_manager') else False
                }
            }
            
            # Save to documents folder
            docs_path = Path.home() / 'Documents'
            export_path = docs_path / f'TrueAxis_Settings_{datetime.datetime.now().strftime("%Y%m%d_%H%M%S")}.json'
            
            with open(export_path, 'w') as f:
                json.dump(export_data, f, indent=2)
            
            self.exportStatusChanged.emit(f"Settings exported to {export_path.name}")
            return str(export_path)
        except Exception as e:
            self.exportStatusChanged.emit(f"Export failed: {str(e)}")
            return ""

    @Slot()
    def export_settings_dialog(self):
        """Let the user pick a destination, then export settings there."""
        try:
            import datetime
            from pathlib import Path
            from PySide6.QtWidgets import QFileDialog

            default_path = str(
                Path.home() / 'Documents' /
                f'TrueAxis_Settings_{datetime.datetime.now().strftime("%Y%m%d_%H%M%S")}.json'
            )
            file_path, _ = QFileDialog.getSaveFileName(
                None,
                "Export TrueAxis settings",
                default_path,
                "TrueAxis settings (*.json);;All files (*.*)",
            )
            if not file_path:
                self.statusChanged.emit("Export cancelled", "#71717a")
                return

            exported = self.export_settings()
            if not exported:
                self.statusChanged.emit("Export failed", "#ef4444")
                return
            if os.path.abspath(exported) != os.path.abspath(file_path):
                shutil.move(exported, file_path)
            self.statusChanged.emit(f"Settings exported to {file_path}", "#22c55e")
        except Exception as e:
            self.statusChanged.emit(f"Export failed: {e}", "#ef4444")

    @Slot()
    def import_settings_dialog(self):
        """Let the user pick a TrueAxis settings file to import."""
        try:
            from pathlib import Path
            from PySide6.QtWidgets import QFileDialog

            file_path, _ = QFileDialog.getOpenFileName(
                None,
                "Import TrueAxis settings",
                str(Path.home() / 'Documents'),
                "TrueAxis settings (*.json);;All files (*.*)",
            )
            if not file_path:
                self.statusChanged.emit("Import cancelled", "#71717a")
                return
            if self.import_settings(file_path):
                self.statusChanged.emit(f"Settings imported from {os.path.basename(file_path)}", "#22c55e")
            else:
                self.statusChanged.emit("Import failed: not a valid TrueAxis settings file", "#ef4444")
        except Exception as e:
            self.statusChanged.emit(f"Import failed: {e}", "#ef4444")

    @Slot(str, result=bool)
    def import_settings(self, file_path):
        """Import settings from a JSON file"""
        try:
            with open(file_path, 'r') as f:
                import_data = json.load(f)
            
            # Validate the import file
            if 'settings' not in import_data:
                self.exportStatusChanged.emit("Invalid settings file")
                return False
            
            settings = import_data['settings']
            
            # Apply settings
            if 'invert_gas' in settings:
                self._invert_gas = settings['invert_gas']
                self.invertGasChanged.emit(self._invert_gas)
            if 'invert_brake' in settings:
                self._invert_brake = settings['invert_brake']
                self.invertBrakeChanged.emit(self._invert_brake)
            if 'square_input' in settings:
                self._square_input = settings['square_input']
                self.squareInputChanged.emit(self._square_input)
            if 'square_area' in settings:
                self._square_area = settings['square_area']
                self.squareAreaChanged.emit(self._square_area)
            if 'deadzone_enabled' in settings:
                self._deadzone_enabled = settings['deadzone_enabled']
                self.deadzoneEnabledChanged.emit(self._deadzone_enabled)
            if 'deadzone' in settings:
                self._deadzone = settings['deadzone']
                self.deadzoneChanged.emit(self._deadzone)
            if 'sensitivity_curve' in settings:
                self._sensitivity_curve = settings['sensitivity_curve']
                self.sensitivityCurveChanged.emit(self._sensitivity_curve)
            if 'theme_mode' in settings:
                theme_mode = str(settings['theme_mode']).strip().lower()
                self._theme_mode = theme_mode if theme_mode in ("light", "dark") else "light"
                self.themeModeChanged.emit(self._theme_mode)
            if 'steering_range' in settings:
                self._steering_range = settings['steering_range']
                self.steeringRangeChanged.emit(self._steering_range)
            if 'center_spring' in settings:
                self._center_spring = settings['center_spring']
                self.centerSpringChanged.emit(self._center_spring)
            if 'center_spring_width' in settings:
                self._center_spring_width = settings['center_spring_width']
                self.centerSpringWidthChanged.emit(self._center_spring_width)
            if 'center_spring_ramp' in settings:
                self._center_spring_ramp = settings['center_spring_ramp'] if SOFT_RAMP_AVAILABLE else False
                self.centerSpringRampChanged.emit(self._center_spring_ramp)
            if 'center_spring_ramp_width' in settings:
                self._center_spring_ramp_width = settings['center_spring_ramp_width']
                self.centerSpringRampWidthChanged.emit(self._center_spring_ramp_width)
            if 'neutral_steering_button' in settings:
                self._neutral_steering_button = int(settings.get('neutral_steering_button', -1) or -1)
                self.neutralSteeringButtonChanged.emit()
            if 'steering_action_button' in settings:
                self._steering_action_button = int(settings.get('steering_action_button', -1) or -1)
                self.steeringActionChanged.emit()
            if 'steering_action_max_percent' in settings:
                self._steering_action_max_percent = max(0, min(100, int(settings.get('steering_action_max_percent', 0) or 0)))
                self.steeringActionChanged.emit()
            if 'steering_action_mode' in settings:
                mode = str(settings.get('steering_action_mode', 'hold') or 'hold').lower()
                self._steering_action_mode = mode if mode in ('hold', 'toggle') else 'hold'
                self._reset_steering_action_toggle()
                self.steeringActionChanged.emit()
            if 'button_mapping' in settings:
                self.button_mapping = settings['button_mapping']
                self.buttonMappingsChanged.emit()
                self.update_button_status()
            if 'user_profile_overrides' in settings:
                self._user_profile_overrides = settings['user_profile_overrides']
            if 'user_button_overrides' in settings:
                self._user_button_overrides = settings['user_button_overrides']
            if 'user_configured_devices' in settings:
                self._user_configured_devices = settings['user_configured_devices']
            if 'device_slots' in settings and isinstance(settings.get('device_slots'), list):
                imported_slots = []
                for i, slot_data in enumerate(settings.get('device_slots', [])[:4]):
                    if isinstance(slot_data, dict):
                        slot = DeviceSlot(slot_id=i)
                        slot.from_dict(slot_data)
                        imported_slots.append(slot)
                if imported_slots:
                    self._device_slots = imported_slots
                    self._active_slot_index = max(0, min(int(settings.get('active_slot_index', 0) or 0), len(self._device_slots) - 1))
                    self._sync_ui_from_slot(self._active_slot_index)
                    self.activeSlotIndexChanged.emit(self._active_slot_index)
                    self.deviceSlotsChanged.emit()
            
            self.save_settings(force=True)
            self.exportStatusChanged.emit("Settings imported successfully!")
            return True
        except Exception as e:
            self.exportStatusChanged.emit(f"Import failed: {str(e)}")
            return False
    @Slot()
    def toggle_mapping(self):
        if self._running:
            self.stop_mapping()
        else:
            self.start_mapping()
    
    def update_axis_values(self, values):
        """Update axis values from input reader thread"""
        if values != self._axis_values:
            self._axis_values = values
            self.axisValuesChanged.emit()
    
    def update_button_states(self, states):
        """Update button states from input reader thread"""
        if states != self._button_states:
            self._button_states = states
            self.buttonStatesChanged.emit()

    def _emit_preview_value(self, index, value, signal):
        value = max(0.0, min(1.0, float(value)))
        last_value = self._last_preview_values[index]
        if last_value is None or abs(last_value - value) >= PREVIEW_EMIT_EPSILON:
            self._last_preview_values[index] = value
            signal.emit(value)

    def _neutral_steering_active(self, button_states, button_index=None):
        if button_index is None:
            button_index = self._neutral_steering_button
        try:
            button_index = int(button_index)
        except (TypeError, ValueError):
            return False
        return 0 <= button_index < len(button_states) and bool(button_states[button_index])

    def _reset_steering_action_toggle(self, slot=None):
        if slot is None:
            self._steering_action_toggle_active = False
            self._steering_action_last_pressed = False
            return
        slot.steering_action_toggle_active = False
        slot.steering_action_last_pressed = False

    def _steering_action_pressed(self, button_states, button_index):
        try:
            button_index = int(button_index)
        except (TypeError, ValueError):
            return False
        return 0 <= button_index < len(button_states) and bool(button_states[button_index])

    def _apply_steering_action_limit(self, value, button_states, slot=None, update_toggle=True):
        """Limit virtual steering output while the configured steering action is active."""
        if slot is None:
            button_index = self._steering_action_button
            max_percent = self._steering_action_max_percent
            mode = self._steering_action_mode
        else:
            button_index = slot.steering_action_button
            max_percent = slot.steering_action_max_percent
            mode = slot.steering_action_mode

        try:
            button_index = int(button_index)
        except (TypeError, ValueError):
            return value
        if button_index < 0:
            return value

        try:
            limit = max(0.0, min(1.0, float(max_percent) / 100.0))
        except (TypeError, ValueError):
            limit = 0.0

        pressed = self._steering_action_pressed(button_states, button_index)
        mode = str(mode or "hold").lower()
        if mode == "toggle":
            if slot is None:
                if update_toggle:
                    if pressed and not self._steering_action_last_pressed:
                        self._steering_action_toggle_active = not self._steering_action_toggle_active
                    self._steering_action_last_pressed = pressed
                active = self._steering_action_toggle_active
            else:
                if update_toggle:
                    if pressed and not slot.steering_action_last_pressed:
                        slot.steering_action_toggle_active = not slot.steering_action_toggle_active
                    slot.steering_action_last_pressed = pressed
                active = slot.steering_action_toggle_active
        else:
            active = pressed

        if not active:
            return value
        return max(-limit, min(limit, value))
    
    def process_inputs(self):
        """Process input values for display and emulation"""
        if not self.joystick or not self._axis_values:
            return
        
        try:
            # Always update live input preview, regardless of emulation state
            if self._current_profile in PROFILES:
                mapping = PROFILES[self._current_profile]["axes"]
                
                def get_axis_value(idx):
                    if idx < len(self._axis_values):
                        return self._axis_values[idx]
                    return 0.5
                
                # Steering with square input
                if mapping[0] < len(self._axis_values):
                    raw_value = (self._axis_values[mapping[0]] * 2) - 1  # Convert to -1 to 1
                    
                    # Apply deadzone (if enabled)
                    if self._deadzone_enabled:
                        if abs(raw_value) < self._deadzone:
                            raw_value = 0.0
                        elif self._deadzone > 0:
                            # Scale the remaining range
                            sign = 1 if raw_value > 0 else -1
                            raw_value = sign * ((abs(raw_value) - self._deadzone) / (1.0 - self._deadzone))
                    
                    if self._square_input:
                        # Apply square steering transformation
                        normalized = abs(raw_value)
                        square_threshold = self._square_area
                        
                        if normalized <= square_threshold:
                            # Inside square area - linear response
                            scaled = normalized / square_threshold
                        else:
                            # Outside square area - jump to 100%
                            scaled = 1.0
                        
                        # Apply direction
                        final_s = scaled if raw_value >= 0 else -scaled
                        steer = (final_s + 1) / 2
                    else:
                        # Normal circular steering (with deadzone applied)
                        steer = (raw_value + 1) / 2

                    if self._neutral_steering_active(self._button_states):
                        steer = 0.5
                    else:
                        preview_s = (steer * 2.0) - 1.0
                        preview_s = self._apply_steering_action_limit(
                            preview_s,
                            self._button_states,
                            update_toggle=False,
                        )
                        steer = (preview_s + 1.0) / 2.0
                    
                    self._emit_preview_value(0, steer, self.steeringChanged)
                
                # Gas with invert
                if mapping[1] < len(self._axis_values):
                    gas = self._axis_values[mapping[1]]
                    if self._invert_gas:
                        gas = 1.0 - gas
                    self._emit_preview_value(1, gas, self.gasChanged)
                
                # Brake with invert
                if mapping[2] < len(self._axis_values):
                    brake = self._axis_values[mapping[2]]
                    if self._invert_brake:
                        brake = 1.0 - brake
                    self._emit_preview_value(2, brake, self.brakeChanged)
        except Exception as e:
            now = time.time()
            if now - self._last_process_input_error_at > 1.0:
                print(f"Error processing inputs: {e}")
                self._last_process_input_error_at = now
    
    @Slot()
    def start_mapping(self):
        if not self.joystick:
            self.statusChanged.emit("No input device selected", "#ef4444")
            return
        
        # Check ViGEm before trying to create the gamepad
        if not self.check_vigem_installed():
            self._vigem_installed = False
            self.vigEmInstalledChanged.emit(False)
            self.vigEmStatusChanged.emit("not installed")
            return
        
        self._running = True
        self._reset_steering_action_toggle()
        self._session_start_time = time.time()  # Start session timer
        self.isRunningChanged.emit(True)
        self.statusChanged.emit("Active: Emulating controller", "#22c55e")

        # Apply hardware settings on start
        self._reassert_hardware_controls(pulse=True)
        
        # Start session timer updates
        if not hasattr(self, 'session_timer'):
            self.session_timer = QTimer()
            self.session_timer.timeout.connect(self._update_session_time)
        self.session_timer.start(1000)  # Update every second
        
        # Set up combined pedal reader for slot 0 if enabled
        slot0 = self._device_slots[0] if self._device_slots else None
        if slot0 and slot0.combined_mode and slot0.combined_pedal_device_name:
            self._setup_combined_pedal_reader(slot0)
            self._slot0_pedal_axes = []
            if slot0.combined_pedal_input_reader:
                slot0.combined_pedal_input_reader.axisValuesChanged.connect(
                    lambda vals: setattr(self, '_slot0_pedal_axes', vals))
        else:
            self._slot0_pedal_axes = None

        # Start mapping thread
        self.mapper_thread = threading.Thread(target=self.mapping_loop, daemon=True)
        self.mapper_thread.start()

    @Slot()
    def stop_mapping(self):
        self._running = False
        self._reset_steering_action_toggle()
        if self._macro_playing:
            self.stop_macro_playback()
        self._session_start_time = None  # Stop session timer
        if hasattr(self, 'session_timer'):
            self.session_timer.stop()
        self.sessionTimeChanged.emit("0:00")
        self.isRunningChanged.emit(False)
        self._restore_center_spring_baseline()
        self._release_all_outputs()
        # Clean up combined pedal reader for slot 0
        slot0 = self._device_slots[0] if self._device_slots else None
        if slot0 and slot0.combined_pedal_input_reader:
            slot0.combined_pedal_input_reader.stop()
            slot0.combined_pedal_input_reader = None
        self._slot0_pedal_axes = None
        self.statusChanged.emit("Ready to activate", "#71717a")

    def _release_all_outputs(self):
        """Release virtual controller and keyboard outputs so nothing can stay latched."""
        if self.vg_gamepad:
            try:
                self.vg_gamepad.left_joystick(x_value=0, y_value=0)
                self.vg_gamepad.right_trigger(value=0)
                self.vg_gamepad.left_trigger(value=0)
                for btn_obj in XBOX_BUTTON_MAP.values():
                    if btn_obj:
                        self.vg_gamepad.release_button(btn_obj)
                self.vg_gamepad.update()
            except Exception:
                pass

        if KEYBOARD_AVAILABLE and self.keyboard:
            for key_set in (self._pressed_keys, self._pressed_macro_keys):
                for key_id in list(key_set):
                    mapping_name = key_id.split('_', 2)[-1] if '_' in key_id else ""
                    if mapping_name in KEYBOARD_MAP:
                        try:
                            self.keyboard.release(KEYBOARD_MAP[mapping_name])
                        except Exception:
                            pass
                key_set.clear()

    def _prepare_update_handoff(self):
        """Put hardware/output state in a safe state before the hidden updater takes over."""
        try:
            self._macro_recording = False
            self._macro_playing = False
            self._running = False
            self.save_settings(force=True)
            self.save_button_mapping(force=True)
            self.save_macros()
            if hasattr(self, "tray_manager"):
                self.tray_manager.save_settings()
        except Exception:
            pass
        self._restore_center_spring_baseline()
        self._release_all_outputs()

    def _on_device_disconnected(self):
        """Handle device disconnection detected by InputReader"""
        was_running = self._running
        # Remember device name for reconnection
        if self.joystick:
            try:
                self._reconnect_device_name = self.joystick.get_name()
            except Exception:
                self._reconnect_device_name = None

        # Stop emulation gracefully
        if was_running:
            self.stop_mapping()

        self.joystick = None
        self.deviceDisconnected.emit()
        self.statusChanged.emit("Device disconnected - attempting to reconnect...", "#f59e0b")
        print("Device disconnected, starting reconnect timer")

        # Start periodic reconnect attempts
        self._reconnect_timer.start(3000)  # Try every 3 seconds

    def _attempt_reconnect(self):
        """Periodically try to find and reconnect the disconnected device"""
        try:
            pygame.joystick.quit()
            pygame.joystick.init()
            count = pygame.joystick.get_count()
            if count == 0:
                return  # No devices yet, keep trying

            # Look for the previously connected device by name
            for i in range(count):
                try:
                    js = pygame.joystick.Joystick(i)
                    js.init()
                    name = js.get_name()
                    if self._reconnect_device_name and name == self._reconnect_device_name:
                        # Found it - reconnect
                        self._reconnect_timer.stop()
                        self.joystick = js
                        if self.input_reader:
                            self.input_reader.set_joystick(self.joystick)
                        # Rebuild device list
                        self.refresh_devices()
                        # Re-select this device
                        for idx, dev_str in enumerate(self._devices):
                            if self._reconnect_device_name in dev_str:
                                self.select_device(idx)
                                break
                        self.statusChanged.emit("Device reconnected!", "#22c55e")
                        print(f"Reconnected to {name}")
                        self._reconnect_device_name = None
                        return
                    js.quit()
                except Exception:
                    pass

            # Device not found by name, but there are devices - refresh list
            # and let user pick
            if not self._reconnect_device_name:
                self._reconnect_timer.stop()
                self.refresh_devices()
                self.statusChanged.emit("Devices available - please select one", "#f59e0b")
        except Exception as e:
            print(f"Reconnect attempt failed: {e}")

    def _update_session_time(self):
        """Update session time display"""
        if self._session_start_time and self._running:
            elapsed = int(time.time() - self._session_start_time)
            hours = elapsed // 3600
            minutes = (elapsed % 3600) // 60
            seconds = elapsed % 60
            if hours > 0:
                time_str = f"{hours}:{minutes:02d}:{seconds:02d}"
            else:
                time_str = f"{minutes}:{seconds:02d}"
            self.sessionTimeChanged.emit(time_str)
    
    def apply_deadzone(self, raw_input):
        """Apply deadzone to input value"""
        if not self._deadzone_enabled:
            return raw_input
        if abs(raw_input) < self._deadzone:
            return 0.0
        elif self._deadzone > 0:
            # Scale the remaining range
            sign = 1 if raw_input > 0 else -1
            return sign * ((abs(raw_input) - self._deadzone) / (1.0 - self._deadzone))
        return raw_input
    
    def apply_square_steering(self, raw_input):
        """Apply square steering transformation based on configurable area"""
        if not self._square_input:
            return raw_input
        
        normalized = abs(raw_input)
        square_threshold = self._square_area
        
        if normalized <= square_threshold:
            # Inside square area - linear response
            scaled = normalized / square_threshold
        else:
            # Outside square area - jump to 100%
            scaled = 1.0
        
        # Apply direction
        return scaled if raw_input >= 0 else -scaled
    
    def apply_steering_range(self, raw_input):
        """Hardware-only steering range. Intentionally no virtual scaling."""
        return raw_input

    def apply_center_spring(self, raw_input):
        """No-op: center spring is now set via hardware HID autocenter command."""
        return raw_input

    def _current_raw_steering_input(self):
        """Return the current physical steering axis as -1.0..1.0 before output transforms."""
        try:
            mapping = PROFILES.get(self._current_profile, PROFILES["Logitech G29 (Standard)"])["axes"]
            axis_index = mapping[0]
            if axis_index < len(self._axis_values):
                return (float(self._axis_values[axis_index]) * 2.0) - 1.0
        except Exception:
            pass
        return 0.0

    def _soft_ramp_target_spring(self, raw_input):
        """Calculate baseline-to-bumpstop spring strength for the current steering input."""
        base_spring = max(0.0, min(1.0, float(self._center_spring)))
        half_range = max(5.0, self._steering_range / 2.0)
        degrees = abs(max(-1.0, min(1.0, raw_input))) * half_range
        ramp_width = max(1.0, min(float(self._center_spring_ramp_width), half_range))
        ramp_start = max(0.0, half_range - ramp_width)

        if degrees <= ramp_start:
            return base_spring

        ramp_progress = min(1.0, (degrees - ramp_start) / ramp_width)
        smooth_progress = ramp_progress * ramp_progress * (3.0 - 2.0 * ramp_progress)
        return base_spring + (1.0 - base_spring) * smooth_progress

    def _restore_center_spring_baseline(self):
        """Leave the wheel at the configured baseline instead of the last bumpstop force."""
        self._apply_center_spring_baseline(force=True)

    def update_hardware_center_spring(self, raw_input):
        """Ramp Logitech center spring from baseline to full strength near steering lock."""
        if not self._center_spring_ramp or not self._hardware_center_spring_supported():
            return False
        if not self._uses_logitech_hid():
            return False

        spring = round(max(0.0, min(1.0, self._soft_ramp_target_spring(raw_input))), 2)
        now = time.time()
        if abs(spring - self._last_hardware_spring) >= 0.02 and (now - self._last_hardware_spring_sent_at) >= 0.05:
            self._last_hardware_spring = spring
            self._last_hardware_spring_sent_at = now
            return self._apply_hardware_center_spring(spring)
        return True

    def apply_sensitivity_curve(self, raw_input):
        """Apply sensitivity curve transformation to input value"""
        import math
        
        # Get absolute value and preserve sign
        sign = 1 if raw_input >= 0 else -1
        value = abs(raw_input)
        
        curve = self._sensitivity_curve.lower()
        
        if curve == 'linear':
            # No transformation - direct 1:1 response
            result = value
        elif curve == 'smooth':
            # Smooth curve - less sensitive at center, more gradual
            # Using square root for gentler response
            result = math.pow(value, 0.7)
        elif curve == 'aggressive':
            # Aggressive curve - more sensitive near center
            # Using power function for sharper response
            result = math.pow(value, 1.5)
        elif curve == 's-curve':
            # S-curve - smooth at extremes, responsive in middle
            # Using sigmoid-like transformation
            if value < 0.5:
                result = 2 * math.pow(value, 2)
            else:
                result = 1 - 2 * math.pow(1 - value, 2)
        else:
            result = value  # Default to linear
        
        return sign * result
    def mapping_loop(self):
        """Main emulation loop - runs in separate thread"""
        if not VIGEM_AVAILABLE or vg is None:
            self._running = False
            self.statusChanged.emit("ViGEm driver not available", "#ef4444")
            return
        
        if not self.vg_gamepad:
            self.vg_gamepad = vg.VX360Gamepad()
        
        mapping = PROFILES[self._current_profile]["axes"]
        
        while self._running:
            try:
                # Let macro playback own the virtual controller while it is active.
                if self._macro_playing:
                    time.sleep(0.01)
                    continue
                
                # Read directly from the input thread snapshot so controller
                # output is not gated by queued UI-thread signal delivery.
                if self.input_reader:
                    axis_values, button_states = self.input_reader.snapshot()
                else:
                    axis_values = self._axis_values.copy() if self._axis_values else []
                    button_states = self._button_states.copy() if self._button_states else []
                
                # STEERING with configurable square area
                if mapping[0] < len(axis_values):
                    raw_s = (axis_values[mapping[0]] * 2) - 1  # Convert to -1 to 1

                    # Apply dynamic hardware center spring ramp near steering lock.
                    self.update_hardware_center_spring(raw_s)

                    # Apply deadzone
                    raw_s = self.apply_deadzone(raw_s)

                    # Apply square steering transformation
                    raw_s = self.apply_square_steering(raw_s)

                    # Apply sensitivity curve
                    final_s = self.apply_sensitivity_curve(raw_s)

                    # Clamp to valid range
                    final_s = max(-1.0, min(1.0, final_s))

                    if self._neutral_steering_active(button_states):
                        final_s = 0.0
                    else:
                        final_s = self._apply_steering_action_limit(final_s, button_states)

                    self.vg_gamepad.left_joystick(x_value=int(final_s * 32767), y_value=0)
                
                # In combined mode, use pedal device axes for gas/brake
                pedal_axes = None
                slot0 = self._device_slots[0] if self._device_slots else None
                if slot0 and slot0.combined_mode and slot0.combined_pedal_input_reader:
                    pedal_axes, _ = slot0.combined_pedal_input_reader.snapshot()
                    if not pedal_axes and hasattr(self, '_slot0_pedal_axes') and self._slot0_pedal_axes:
                        pedal_axes = self._slot0_pedal_axes.copy()
                elif hasattr(self, '_slot0_pedal_axes') and self._slot0_pedal_axes:
                    pedal_axes = self._slot0_pedal_axes.copy()

                # GAS with invert (from pedal device if combined)
                gas_axes = pedal_axes if pedal_axes else axis_values
                if mapping[1] < len(gas_axes):
                    norm_g = gas_axes[mapping[1]]
                    if self._invert_gas:
                        norm_g = 1.0 - norm_g
                    self.vg_gamepad.right_trigger(value=int(max(0.0, min(1.0, norm_g)) * 255))

                # BRAKE with invert (from pedal device if combined)
                brake_axes = pedal_axes if pedal_axes else axis_values
                if mapping[2] < len(brake_axes):
                    norm_b = brake_axes[mapping[2]]
                    if self._invert_brake:
                        norm_b = 1.0 - norm_b
                    self.vg_gamepad.left_trigger(value=int(max(0.0, min(1.0, norm_b)) * 255))
                
                # BUTTONS - Handle both Xbox buttons and keyboard keys
                for btn_idx_str, mapping_name in list(self.button_mapping.items()):
                    try:
                        btn_idx = int(btn_idx_str)
                    except (TypeError, ValueError):
                        continue
                    if btn_idx < len(button_states):
                        button_pressed = button_states[btn_idx]
                        
                        # Check if it's an Xbox button
                        xbox_obj = XBOX_BUTTON_MAP.get(mapping_name)
                        if xbox_obj:
                            # Handle Xbox button
                            if button_pressed:
                                self.vg_gamepad.press_button(xbox_obj)
                            else:
                                self.vg_gamepad.release_button(xbox_obj)
                        
                        # Check if it's a keyboard key
                        elif KEYBOARD_AVAILABLE and mapping_name in KEYBOARD_MAP:
                            key = KEYBOARD_MAP[mapping_name]
                            key_id = f"{btn_idx}_{mapping_name}"
                            
                            if button_pressed and key_id not in self._pressed_keys:
                                # Button just pressed - press key
                                try:
                                    self.keyboard.press(key)
                                    self._pressed_keys.add(key_id)
                                except Exception:
                                    pass
                            elif not button_pressed and key_id in self._pressed_keys:
                                # Button just released - release key
                                try:
                                    self.keyboard.release(key)
                                    self._pressed_keys.remove(key_id)
                                except Exception:
                                    pass
                
                # Update virtual gamepad
                self.vg_gamepad.update()
                
                # Check macro button-combo triggers after applying current input state.
                self.check_macro_triggers(button_states)
                
                time.sleep(MAPPING_LOOP_SLEEP_SECONDS)
                
            except (pygame.error, OSError) as e:
                # Device disconnection during emulation
                print(f"Device error in mapping loop: {e}")
                self._running = False
                self.isRunningChanged.emit(False)
                # Trigger disconnect handler on the main thread
                QTimer.singleShot(0, self._on_device_disconnected)
                break
            except Exception as e:
                now = time.time()
                if now - self._last_mapping_error_at > 1.0:
                    print(f"Error in mapping loop: {e}")
                    self._last_mapping_error_at = now
                time.sleep(0.05)

        # Clean up when stopping
        self._release_all_outputs()
    
    @Slot()
    @Slot()
    def check_for_updates(self):
        """Check if a new version is available"""
        self._check_for_updates(quiet=False)

    def _check_for_updates(self, quiet=False):
        if self._update_check_in_progress:
            return
        self._update_check_in_progress = True

        def check_update_thread():
            try:
                manifest = self._fetch_update_manifest()
                latest_version = manifest["version"]
                was_available = self._update_available
                
                self._latest_version = latest_version
                self._new_version = latest_version
                self._update_manifest = manifest
                if manifest.get("app") != "trueaxis":
                    raise ValueError("Update manifest is for a different app")
                if manifest.get("channel") != UPDATE_CHANNEL:
                    raise ValueError("Update manifest is for a different channel")
                if manifest.get("update_blocked"):
                    self._update_available = False
                    self.updateAvailableChanged.emit(False)
                    if not quiet:
                        self.statusChanged.emit("Up to date", "#22c55e")
                    return
                
                if self._compare_versions(latest_version, CURRENT_VERSION) > 0:
                    self._update_available = True
                    self.updateAvailableChanged.emit(True)
                    if not was_available:
                        self._send_telemetry_event_async("update_available", {"latest_version": latest_version})
                    if not was_available or not quiet:
                        self.statusChanged.emit(f"Update available: v{latest_version}", "#22c55e")
                else:
                    self._update_available = False
                    self.updateAvailableChanged.emit(False)
                    if not quiet:
                        self.statusChanged.emit("Up to date", "#22c55e")
            except Exception as e:
                print(f"Update check failed: {e}")
                if not quiet:
                    self.statusChanged.emit("Couldn't check for updates", "#ef4444")
            finally:
                self._last_update_check_at = time.time()
                self._update_check_in_progress = False
        
        # Run in separate thread to not block UI
        threading.Thread(target=check_update_thread, daemon=True).start()

    def _consume_update_state(self):
        """Show the result written by the hidden updater helper after restart."""
        try:
            state = _load_json_with_backup(UPDATE_STATE_FILE)
            if not isinstance(state, dict):
                return
            status = str(state.get("status", "")).lower()
            version = str(state.get("version") or CURRENT_VERSION).lstrip("v")
            message = str(state.get("message") or "")
            install_dir = str(state.get("install_dir") or "").strip()
            target = str(state.get("target") or "").strip()
            if status == "success":
                if install_dir:
                    self.statusChanged.emit(f"Updated to v{version} in {install_dir}", "#22c55e")
                else:
                    self.statusChanged.emit(f"Updated to v{version}", "#22c55e")
                self._send_telemetry_event_async("update_completed", {"target_version": version, "install_dir": install_dir, "target": target})
            elif status == "failed":
                self.statusChanged.emit(message or "Update failed; previous version restored", "#ef4444")
                self._send_telemetry_event_async("update_failed", {"target_version": version, "message": message})
        finally:
            try:
                if os.path.exists(UPDATE_STATE_FILE):
                    os.remove(UPDATE_STATE_FILE)
            except Exception:
                pass
    
    def _compare_versions(self, v1, v2):
        """Compare two version strings. Returns 1 if v1 > v2, -1 if v1 < v2, 0 if equal"""
        try:
            key1 = _version_sort_key(v1)
            key2 = _version_sort_key(v2)
            if key1 > key2:
                return 1
            if key1 < key2:
                return -1
            return 0
        except Exception:
            return 0

    def _fetch_update_manifest(self):
        manifest_url = VERSION_CHECK_URL + ("&" if "?" in VERSION_CHECK_URL else "?") + f"_={int(time.time())}"
        request = urllib.request.Request(
            manifest_url,
            headers={
                'User-Agent': f'TrueAxis/{CURRENT_VERSION} ({UPDATE_CHANNEL})',
                'Cache-Control': 'no-cache',
                'Pragma': 'no-cache',
            },
        )
        with urllib.request.urlopen(request, timeout=10) as response:
            data = json.loads(response.read().decode("utf-8"))
        return self._normalize_update_manifest(data)

    def _normalize_update_manifest(self, data):
        if not isinstance(data, dict):
            raise ValueError("Update manifest is not valid JSON")

        version = str(data.get("version", "")).strip().lstrip("v")
        if not version:
            raise ValueError("Update manifest is missing version")

        manifest_channel = str(data.get("channel") or data.get("edition") or UPDATE_CHANNEL).strip().lower()
        if manifest_channel in ("comp", "competitive"):
            manifest_channel = "competitive"

        platform = str(data.get("platform") or UPDATE_PLATFORM).strip().lower()
        if platform not in ("windows", "windows-x64", "win64", UPDATE_PLATFORM):
            raise ValueError(f"Unsupported update platform: {platform}")

        raw_download_url = str(data.get("download_url") or data.get("url") or "").strip()
        if not raw_download_url:
            raise ValueError("Update manifest is missing download_url")

        release_notes = data.get("release_notes", data.get("changelog", []))
        if isinstance(release_notes, str):
            release_notes = [release_notes]
        elif not isinstance(release_notes, list):
            release_notes = []

        size = data.get("size", data.get("size_bytes"))
        try:
            size = int(size) if size is not None else None
        except (TypeError, ValueError):
            size = None

        installer_args = data.get("installer_args", DEFAULT_INSTALLER_ARGS)
        if isinstance(installer_args, str):
            installer_args = [installer_args]
        elif not isinstance(installer_args, list):
            installer_args = DEFAULT_INSTALLER_ARGS
        safe_installer_args = []
        for raw_arg in installer_args:
            arg = str(raw_arg).strip()
            normalized_arg = arg.upper()
            if arg == normalized_arg and arg in ALLOWED_INSTALLER_ARGS:
                safe_installer_args.append(arg)
            elif normalized_arg in ALLOWED_INSTALLER_ARGS:
                safe_installer_args.append(normalized_arg)
        installer_args = safe_installer_args

        download_url = urljoin(VERSION_CHECK_URL, raw_download_url)
        parsed_download = urlparse(download_url)
        if parsed_download.scheme != "https" or parsed_download.hostname not in TRUSTED_UPDATE_HOSTS:
            raise ValueError("Update download URL is not trusted")

        return {
            "schema": int(data.get("schema", 1)),
            "app": str(data.get("app", "trueaxis")).strip().lower(),
            "channel": manifest_channel,
            "platform": platform,
            "version": version,
            "released_at": data.get("released_at") or data.get("release_date", ""),
            "min_supported_version": str(data.get("min_supported_version") or data.get("minimum_version") or "0").strip(),
            "auto_update_min_version": str(data.get("auto_update_min_version") or data.get("min_supported_version") or data.get("minimum_version") or "0").strip(),
            "mandatory": bool(data.get("mandatory", False)),
            "update_blocked": bool(data.get("update_blocked", False)),
            "update_block_reason": str(data.get("update_block_reason") or "").strip(),
            "download_url": download_url,
            "sha256": str(data.get("sha256", "")).strip().lower(),
            "size": size,
            "installer_type": str(data.get("installer_type", "inno")).strip().lower(),
            "installer_args": installer_args,
            "changelog": [str(item) for item in release_notes],
        }

    def _validate_install_manifest(self, manifest):
        if manifest.get("app") != "trueaxis":
            raise ValueError("Update manifest is for a different app")
        if manifest.get("channel") != UPDATE_CHANNEL:
            raise ValueError("Update manifest is for a different channel")
        if manifest.get("update_blocked"):
            reason = manifest.get("update_block_reason") or "Manual website download required"
            raise ValueError(reason)
        latest_version = manifest.get("version", "")
        if self._compare_versions(latest_version, CURRENT_VERSION) <= 0:
            raise ValueError("No newer update is available")
        min_supported = manifest.get("min_supported_version") or "0"
        auto_min = manifest.get("auto_update_min_version") or min_supported
        if min_supported and self._compare_versions(CURRENT_VERSION, min_supported) < 0:
            raise ValueError("This version is too old for automatic update; download from the website")
        if auto_min and self._compare_versions(CURRENT_VERSION, auto_min) < 0:
            raise ValueError("Automatic update is not supported from this version; download from the website")
        parsed_download = urlparse(manifest.get("download_url", ""))
        if parsed_download.scheme != "https" or parsed_download.hostname not in TRUSTED_UPDATE_HOSTS:
            raise ValueError("Update download URL is not trusted")
    
    @Slot()
    def download_update(self):
        """Download the latest version from the website"""
        import webbrowser
        try:
            manifest_url = self._update_manifest.get("download_url") if self._update_manifest else ""
            webbrowser.open(manifest_url or DOWNLOAD_URL)
            self.statusChanged.emit("Opening download page...", "#22c55e")
        except Exception as e:
            print(f"Failed to open download page: {e}")
            self.statusChanged.emit("Failed to open download page", "#ef4444")
    
    @Slot()
    def install_update(self):
        """Automatically download and install the update with progress window"""
        def update_thread():
            update_file = None
            batch_file = None
            launcher_file = None
            config_file = None
            
            try:
                # Show update window
                self.updateWindowVisibleChanged.emit(True)
                self.updateProgressChanged.emit(0)
                self.updateStatusTextChanged.emit("Preparing update...")
                time.sleep(0.5)
                
                # Step 1: Download and validate release manifest
                self.updateStatusTextChanged.emit("Checking release manifest...")
                self.updateProgressChanged.emit(5)
                
                manifest = self._fetch_update_manifest()
                self._validate_install_manifest(manifest)
                self._update_manifest = manifest
                download_url = manifest["download_url"]
                new_version = manifest["version"]
                self._send_telemetry_event_async("update_started", {"target_version": new_version})
                expected_sha256 = manifest.get("sha256", "")
                expected_size = manifest.get("size")
                if not expected_sha256:
                    raise ValueError("Automatic updates require a SHA-256 checksum")

                # Step 2: Download update file
                self.updateStatusTextChanged.emit("Downloading update...")
                self.updateProgressChanged.emit(10)
                
                download_request = urllib.request.Request(
                    download_url,
                    headers={'User-Agent': f'TrueAxis/{CURRENT_VERSION} ({UPDATE_CHANNEL})'}
                )
                update_response = urllib.request.urlopen(download_request, timeout=120)
                
                temp_dir = tempfile.gettempdir()
                update_stamp = int(time.time())
                update_file = os.path.join(temp_dir, f"TrueAxis_Update_{update_stamp}.exe")
                batch_file = os.path.join(temp_dir, f"TrueAxis_Update_{update_stamp}.ps1")
                launcher_file = os.path.join(temp_dir, f"TrueAxis_Update_{update_stamp}.vbs")
                config_file = os.path.join(temp_dir, f"TrueAxis_Update_{update_stamp}.json")
                log_file = UPDATE_HELPER_LOG_FILE
                try:
                    with open(log_file, "w", encoding="utf-8") as helper_log:
                        helper_log.write(f"TrueAxis updater prepared for v{new_version}\n")
                except Exception:
                    pass
                
                # Download with progress and optional integrity checks.
                total_size = int(update_response.headers.get('Content-Length') or 0)
                downloaded = 0
                hasher = hashlib.sha256()
                
                with open(update_file, 'wb') as f:
                    while True:
                        chunk = update_response.read(8192)
                        if not chunk:
                            break
                        f.write(chunk)
                        hasher.update(chunk)
                        downloaded += len(chunk)
                        if total_size > 0:
                            progress = 10 + int((downloaded / total_size) * 40)  # 10-50%
                            self.updateProgressChanged.emit(progress)
                            if total_size >= 1024 * 1024:  # Only show MB if > 1MB
                                self.updateStatusTextChanged.emit(f"Downloading... {downloaded//1024//1024}MB / {total_size//1024//1024}MB")
                            else:
                                self.updateStatusTextChanged.emit(f"Downloading... {downloaded//1024}KB / {total_size//1024}KB")
                update_response.close()
                if expected_size and downloaded != expected_size:
                    raise ValueError(f"Downloaded {downloaded} bytes, expected {expected_size}")

                downloaded_sha256 = hasher.hexdigest()
                if expected_sha256:
                    self.updateStatusTextChanged.emit("Verifying download...")
                    self.updateProgressChanged.emit(52)
                    if downloaded_sha256.lower() != expected_sha256.lower():
                        raise ValueError("Downloaded update failed SHA-256 verification")
                else:
                    print("Update manifest has no sha256; legacy update will continue without checksum verification.")

                # Make sure file is fully written and closed
                time.sleep(0.5)
                
                # Step 3: Save settings
                self.updateStatusTextChanged.emit("Saving settings...")
                self.updateProgressChanged.emit(55)
                self._prepare_update_handoff()
                time.sleep(0.5)
                
                # Step 4: Keep the UI responsive and hand off quickly. The helper
                # process will close this PID if Windows keeps the exe locked.
                self.updateStatusTextChanged.emit("Preparing restart...")
                self.updateProgressChanged.emit(60)
                time.sleep(0.1)
                
                # Step 5: Get current executable path
                self.updateStatusTextChanged.emit("Locating current installation...")
                self.updateProgressChanged.emit(65)
                
                # Get the path of the currently running executable
                if getattr(sys, 'frozen', False):
                    # Running as compiled exe
                    current_exe = sys.executable
                    is_exe = True
                else:
                    # Running as Python script
                    current_exe = os.path.abspath(__file__)
                    is_exe = False
                current_process_name = os.path.basename(current_exe)
                parent_pid = os.getpid()
                
                time.sleep(0.3)
                
                # Step 6: Create bulletproof update script
                self.updateStatusTextChanged.emit("Creating update script...")
                self.updateProgressChanged.emit(70)
                
                # Determine where TrueAxis will be installed. For the onedir
                # installer, only reuse the current folder when it already
                # looks like a managed TrueAxis install; a portable exe in
                # Downloads must not turn Downloads into the install folder.
                default_install_dir = _default_trueaxis_install_dir()
                default_install_path = os.path.join(default_install_dir, 'TrueAxis.exe')
                installer_type = manifest.get("installer_type", "inno")
                portable_update = installer_type in ("exe", "portable", "standalone", "pyinstaller")
                current_basename = os.path.basename(current_exe).lower()
                current_dir = os.path.dirname(current_exe)
                managed_current_dir = is_exe and current_basename == "trueaxis.exe" and _looks_like_trueaxis_onedir_install(current_dir)
                if installer_type == "trueaxis_silent":
                    current_install_dir = current_dir if managed_current_dir else default_install_dir
                else:
                    current_install_dir = current_dir if (is_exe and current_basename == "trueaxis.exe") else default_install_dir
                primary_start_path = current_exe if (portable_update and is_exe) else os.path.join(current_install_dir, "TrueAxis.exe")
                primary_start_dir = os.path.dirname(primary_start_path) or os.getcwd()
                state_file = UPDATE_STATE_FILE
                mode = "replace" if (portable_update and is_exe) else ("dev" if portable_update else "installer")
                start_paths = []
                for candidate in (
                    primary_start_path,
                    default_install_path,
                    current_exe,
                    os.path.join(os.getenv('LOCALAPPDATA', ''), 'TrueAxis', 'TrueAxis.exe'),
                    os.path.join(os.getenv('APPDATA', ''), 'TrueAxis', 'TrueAxis.exe'),
                    os.path.join(os.getenv('ProgramFiles', ''), 'TrueAxis', 'TrueAxis.exe'),
                    os.path.join(os.getenv('ProgramFiles(x86)', ''), 'TrueAxis', 'TrueAxis.exe'),
                    r"C:\Program Files\TrueAxis\TrueAxis.exe",
                ):
                    if candidate and candidate not in start_paths:
                        start_paths.append(candidate)

                manifest_installer_args = manifest.get("installer_args")
                if manifest_installer_args is None:
                    manifest_installer_args = DEFAULT_INSTALLER_ARGS if installer_type == "inno" else []

                helper_config = {
                    "new_version": new_version,
                    "mode": mode,
                    "parent_pid": parent_pid,
                    "current_exe": current_exe,
                    "current_process_name": current_process_name,
                    "update_file": update_file,
                    "state_file": state_file,
                    "log_file": log_file,
                    "launcher_file": launcher_file,
                    "config_file": config_file,
                    "primary_start_path": primary_start_path,
                    "primary_start_dir": primary_start_dir,
                    "start_paths": start_paths,
                    "installer_type": installer_type,
                    "installer_args": manifest_installer_args,
                    "install_dir": primary_start_dir,
                    "installer_log_file": log_file + ".installer.log",
                }

                with open(config_file, "w", encoding="utf-8") as f:
                    json.dump(helper_config, f)

                # Create a hidden PowerShell helper for the update handoff.
                batch_content = r'''
$ErrorActionPreference = 'Continue'
$ProgressPreference = 'SilentlyContinue'
$env:PYINSTALLER_RESET_ENVIRONMENT = '1'
$configPath = '__CONFIG_FILE__'
$config = Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json

function Log-Update([string]$message) {
    try {
        $line = ('{0} {1}' -f (Get-Date -Format o), $message)
        Add-Content -LiteralPath $config.log_file -Value $line -Encoding UTF8
    } catch {}
}

function Write-UpdateState([string]$status, [string]$message) {
    try {
        $targetPath = Join-Path -Path ([string]$config.install_dir) -ChildPath 'TrueAxis.exe'
        $state = [ordered]@{
            status = $status
            version = [string]$config.new_version
            install_dir = [string]$config.install_dir
            target = [string]$targetPath
        }
        if ($message) { $state.message = $message }
        ($state | ConvertTo-Json -Compress) | Set-Content -LiteralPath $config.state_file -Encoding UTF8
    } catch {
        Log-Update ('Could not write state: ' + $_.Exception.Message)
    }
}

function Start-TrueAxis([string]$path) {
    try {
        if ($path -and (Test-Path -LiteralPath $path)) {
            $workdir = Split-Path -Parent $path
            $env:PYINSTALLER_RESET_ENVIRONMENT = '1'
            Start-Sleep -Milliseconds 750
            Start-Process -FilePath $path -WorkingDirectory $workdir -WindowStyle Normal
            return $true
        }
    } catch {
        Log-Update ('Start failed for ' + $path + ': ' + $_.Exception.Message)
    }
    return $false
}

function Start-AnyTrueAxis {
    foreach ($path in @($config.start_paths)) {
        if (Start-TrueAxis ([string]$path)) { return $true }
    }
    return $false
}

Log-Update ('TrueAxis update helper started for v' + $config.new_version)
Log-Update ('Current exe: ' + $config.current_exe)
Log-Update ('Parent PID: ' + $config.parent_pid)

for ($i = 0; $i -lt 12; $i++) {
    try {
        $parent = Get-Process -Id ([int]$config.parent_pid) -ErrorAction Stop
        Start-Sleep -Milliseconds 250
    } catch {
        break
    }
}

try {
    $parent = Get-Process -Id ([int]$config.parent_pid) -ErrorAction SilentlyContinue
    if ($parent) {
        Log-Update 'Parent process still alive; force-closing it by PID.'
        Stop-Process -Id ([int]$config.parent_pid) -Force -ErrorAction SilentlyContinue
        Start-Sleep -Milliseconds 500
    }
} catch {}

$installOk = $false
if ($config.mode -eq 'replace') {
    $newExe = ([string]$config.current_exe) + '.new'
    $backupExe = ([string]$config.current_exe) + '.bak'
    for ($i = 1; $i -le 24; $i++) {
        try {
            Remove-Item -LiteralPath $newExe -Force -ErrorAction SilentlyContinue
            Copy-Item -LiteralPath $config.update_file -Destination $newExe -Force
            if (Test-Path -LiteralPath $config.current_exe) {
                Copy-Item -LiteralPath $config.current_exe -Destination $backupExe -Force -ErrorAction SilentlyContinue
            }
            Move-Item -LiteralPath $newExe -Destination $config.current_exe -Force
            if (Test-Path -LiteralPath $config.current_exe) {
                $installOk = $true
                break
            }
        } catch {
            Log-Update ('Replace retry ' + $i + ' failed: ' + $_.Exception.Message)
            try {
                if ((Test-Path -LiteralPath $backupExe) -and -not (Test-Path -LiteralPath $config.current_exe)) {
                    Copy-Item -LiteralPath $backupExe -Destination $config.current_exe -Force -ErrorAction SilentlyContinue
                }
            } catch {}
            Start-Sleep -Milliseconds 500
        }
    }
} elseif ($config.mode -eq 'dev') {
    $config.start_paths = @($config.update_file)
    $installOk = $true
} else {
    try {
        $args = @($config.installer_args)
        if ($config.installer_type -eq 'inno') {
            $args += ('/LOG="' + $config.installer_log_file + '"')
        } elseif ($config.installer_type -eq 'trueaxis_silent') {
            $args += ('/LOG="' + $config.installer_log_file + '"')
            $args += ('/INSTALLDIR="' + $config.install_dir + '"')
            $args += '/NOSTART'
        }
        $env:PYINSTALLER_RESET_ENVIRONMENT = '1'
        $proc = Start-Process -FilePath $config.update_file -ArgumentList $args -Wait -PassThru -WindowStyle Hidden
        $installOk = ($null -eq $proc.ExitCode -or $proc.ExitCode -eq 0)
        if (-not $installOk) {
            Log-Update ('Installer exit code: ' + $proc.ExitCode)
        }
    } catch {
        Log-Update ('Installer failed: ' + $_.Exception.Message)
    }
}

if ($installOk) {
    Write-UpdateState 'success' ''
} else {
    Write-UpdateState 'failed' 'Could not install the downloaded update'
    Start-AnyTrueAxis | Out-Null
    exit 1
}

try {
    if ($config.mode -ne 'dev') {
        Remove-Item -LiteralPath $config.update_file -Force -ErrorAction SilentlyContinue
    }
} catch {}
Start-Sleep -Milliseconds 250
if (-not (Start-AnyTrueAxis)) {
    Write-UpdateState 'failed' 'Updated file installed but TrueAxis could not restart automatically'
}

Log-Update 'TrueAxis update helper finished.'
try { Remove-Item -LiteralPath $config.launcher_file -Force -ErrorAction SilentlyContinue } catch {}
try { Remove-Item -LiteralPath $config.config_file -Force -ErrorAction SilentlyContinue } catch {}
try { Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction SilentlyContinue } catch {}
'''.replace("__CONFIG_FILE__", config_file.replace("'", "''"))

                powershell_exe = os.path.join(
                    os.environ.get("SystemRoot", r"C:\Windows"),
                    "System32",
                    "WindowsPowerShell",
                    "v1.0",
                    "powershell.exe",
                )
                if not os.path.exists(powershell_exe):
                    powershell_exe = "powershell.exe"
                powershell_args = [
                    powershell_exe,
                    "-NoProfile",
                    "-ExecutionPolicy",
                    "Bypass",
                    "-WindowStyle",
                    "Hidden",
                    "-File",
                    batch_file,
                ]
                ps_command = subprocess.list2cmdline(powershell_args)
                launcher_content = (
                    'Set shell = CreateObject("WScript.Shell")\r\n'
                    'shell.Environment("PROCESS")("PYINSTALLER_RESET_ENVIRONMENT") = "1"\r\n'
                    f'shell.Run "{ps_command.replace(chr(34), chr(34) + chr(34))}", 0, False\r\n'
                )
                
                # Write hidden helper files.
                with open(batch_file, 'w', encoding="utf-8") as f:
                    f.write(batch_content)
                with open(launcher_file, 'w', encoding="utf-8") as f:
                    f.write(launcher_content)
                
                time.sleep(0.3)
                
                # Step 7: Show restart message
                self.updateStatusTextChanged.emit("Installing in background...")
                self.updateProgressChanged.emit(80)
                time.sleep(0.2)
                
                self.updateStatusTextChanged.emit("Restarting now...")
                self.updateProgressChanged.emit(100)
                time.sleep(0.15)
                
                # Step 8: Launch updater
                if sys.platform == 'win32':
                    # Launch the PowerShell helper without a console. WScript is
                    # kept only as a fallback for unusual Windows launch errors.
                    creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
                    startupinfo = subprocess.STARTUPINFO()
                    startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
                    startupinfo.wShowWindow = 0
                    with open(log_file, "a", encoding="utf-8", errors="replace") as helper_log:
                        helper_log.write("Launching hidden PowerShell update helper.\n")
                        helper_log.flush()
                        helper_env = os.environ.copy()
                        helper_env["PYINSTALLER_RESET_ENVIRONMENT"] = "1"
                        try:
                            subprocess.Popen(
                                powershell_args,
                                stdin=subprocess.DEVNULL,
                                stdout=subprocess.DEVNULL,
                                stderr=subprocess.DEVNULL,
                                creationflags=creationflags,
                                startupinfo=startupinfo,
                                close_fds=True,
                                cwd=temp_dir,
                                env=helper_env,
                            )
                        except Exception as launch_error:
                            helper_log.write(f"Direct PowerShell launch failed: {launch_error}\n")
                            helper_log.write("Falling back to hidden WScript launcher.\n")
                            helper_log.flush()
                            subprocess.Popen(
                                ["wscript.exe", launcher_file],
                                stdin=subprocess.DEVNULL,
                                stdout=subprocess.DEVNULL,
                                stderr=subprocess.DEVNULL,
                                creationflags=creationflags,
                                startupinfo=startupinfo,
                                close_fds=True,
                                cwd=temp_dir,
                                env=helper_env,
                            )
                
                # Step 9: Hard-exit after the helper has its own process. Do not
                # emit more Qt signals here; the UI stack may already be winding
                # down, and update reliability matters more than graceful teardown.
                time.sleep(0.05)
                os._exit(0)
                
            except Exception as e:
                import traceback
                print(f"Update installation failed: {e}")
                print(traceback.format_exc())
                error_msg = str(e)
                try:
                    failed_version = (self._update_manifest or {}).get("version") or ""
                    self._send_telemetry_event_async("update_failed", {"target_version": failed_version, "message": error_msg})
                except Exception:
                    pass
                if "Permission denied" in error_msg:
                    self.updateStatusTextChanged.emit("Permission error - Try running as admin")
                else:
                    self.updateStatusTextChanged.emit(f"Update failed: {error_msg[:50]}")
                self.updateProgressChanged.emit(0)
                time.sleep(3)
                self.updateWindowVisibleChanged.emit(False)
                self.statusChanged.emit(f"Update failed: {error_msg}", "#ef4444")
                
                # Clean up files on error
                if update_file and os.path.exists(update_file):
                    try:
                        os.remove(update_file)
                    except Exception:
                        pass
                if batch_file and os.path.exists(batch_file):
                    try:
                        os.remove(batch_file)
                    except Exception:
                        pass
                if launcher_file and os.path.exists(launcher_file):
                    try:
                        os.remove(launcher_file)
                    except Exception:
                        pass
                if config_file and os.path.exists(config_file):
                    try:
                        os.remove(config_file)
                    except Exception:
                        pass
        
        # Run in separate thread
        threading.Thread(target=update_thread, daemon=True).start()
    
    
    @Slot(result=bool)
    def check_vigem_installed(self):
        """Check if ViGEm driver is installed on the system"""
        if sys.platform != 'win32':
            return True  # Only check on Windows
        
        if not VIGEM_AVAILABLE:
            return False  # vgamepad package itself failed to import
        
        try:
            # Try to create a virtual gamepad - if it works, ViGEm is installed
            test_gamepad = vg.VX360Gamepad()
            del test_gamepad
            return True
        except Exception as e:
            print(f"ViGEm check failed: {e}")
            return False
    
    @Slot()
    def update_vigem_status(self):
        """Update the ViGEm installation status"""
        self._vigem_installed = self.check_vigem_installed()
        self.vigEmInstalledChanged.emit(self._vigem_installed)
        
        if not self._vigem_installed:
            self.vigEmStatusChanged.emit("not installed")
            self.statusChanged.emit("ViGEm driver not found", "#f59e0b")
        else:
            self.statusChanged.emit("ViGEm driver detected", "#22c55e")
    
    @Slot()
    def install_vigem_driver(self):
        """Automatically download and install ViGEm driver (silent, no user interaction)"""
        def install_thread():
            try:
                self.vigEmStatusChanged.emit("Downloading ViGEm driver...")
                
                # Download the installer (User-Agent required, GitHub rejects bare requests)
                request = urllib.request.Request(
                    VIGEM_INSTALLER_URL,
                    headers={'User-Agent': 'TrueAxis/' + CURRENT_VERSION}
                )
                with urllib.request.urlopen(request, timeout=60) as response:
                    installer_data = response.read()
                
                # Save to temp directory
                temp_dir = tempfile.gettempdir()
                installer_path = os.path.join(temp_dir, "ViGEmBus_Setup.exe")
                
                with open(installer_path, 'wb') as f:
                    f.write(installer_data)
                
                self.vigEmStatusChanged.emit("Launching ViGEm installer...")
                
                # Launch the installer with visible UI so user can click through it.
                # The installer will show its own prompts including reboot notification.
                result = subprocess.run(
                    [installer_path],
                    capture_output=True,
                    timeout=300  # Increased timeout since user needs to click through
                )
                
                # Clean up the installer
                try:
                    os.remove(installer_path)
                except Exception:
                    pass
                
                # Check if install actually worked.
                # If vgamepad failed to import at startup, re-attempt it now
                # that the driver has been installed.
                global vg, VIGEM_AVAILABLE
                if not VIGEM_AVAILABLE:
                    try:
                        import vgamepad as vg
                        VIGEM_AVAILABLE = True
                    except Exception as e:
                        print(f"Re-import of vgamepad failed after install: {e}")
                
                if self.check_vigem_installed():
                    self._vigem_installed = True
                    self.vigEmInstalledChanged.emit(True)
                    self.vigEmStatusChanged.emit("installed")
                    self.statusChanged.emit("ViGEm driver installed", "#22c55e")
                else:
                    self.vigEmStatusChanged.emit("Installation complete. Please restart your computer if prompted.")
                
            except subprocess.TimeoutExpired:
                self.vigEmStatusChanged.emit("Installation timed out. Please try again.")
            except Exception as e:
                print(f"ViGEm installation failed: {e}")
                self.vigEmStatusChanged.emit(f"Installation failed: {str(e)}")
        
        # Run in separate thread
        threading.Thread(target=install_thread, daemon=True).start()


    # ========== MACRO SYSTEM ==========

    def _coerce_macro_duration_ms(self, value, default=0):
        try:
            duration = int(float(value))
        except (TypeError, ValueError):
            duration = int(default)
        return max(0, min(300000, duration))

    def _normalize_macro_action(self, action):
        if not isinstance(action, dict):
            return None

        action_type = str(action.get('type', '')).strip().lower()
        if action_type not in ('hold', 'tap', 'press', 'release', 'wait'):
            return None

        normalized = {'type': action_type}
        if action_type != 'wait':
            buttons = []
            for btn in action.get('buttons', []):
                try:
                    buttons.append(int(btn))
                except (TypeError, ValueError):
                    continue
            if not buttons:
                return None
            normalized['buttons'] = buttons

        if action_type in ('wait', 'hold', 'tap'):
            duration = action.get('duration_ms', action.get('duration', 0))
            normalized['duration'] = self._coerce_macro_duration_ms(duration)

        return normalized

    def _normalize_macro_combo(self, button_combo):
        buttons = []
        for part in str(button_combo or "").split('+'):
            part = part.strip()
            if part.isdigit():
                buttons.append(int(part))
        if not buttons:
            return ""
        return '+'.join(str(btn) for btn in sorted(set(buttons)))

    def _normalize_macro_payload(self, payload):
        """Normalize old and new macro files into the current schema."""
        if not isinstance(payload, dict):
            payload = {}

        raw_macros = payload.get('macros', {})
        if not isinstance(raw_macros, dict):
            raw_macros = {}

        normalized_macros = {}
        for name, macro in raw_macros.items():
            macro_name = str(name).strip()
            if not macro_name or not isinstance(macro, dict):
                continue

            if 'actions' in macro:
                actions = []
                total_ms = 0
                for action in macro.get('actions', []):
                    normalized_action = self._normalize_macro_action(action)
                    if not normalized_action:
                        continue
                    actions.append(normalized_action)
                    if normalized_action['type'] in ('wait', 'hold', 'tap'):
                        total_ms += normalized_action.get('duration', 0)
                if not actions:
                    continue
                normalized_macros[macro_name] = {
                    'actions': actions,
                    'duration': total_ms / 1000.0,
                    'created': macro.get('created', 'Unknown'),
                    'type': 'custom',
                }
                continue

            data = macro.get('data', [])
            if isinstance(data, list):
                normalized_macros[macro_name] = {
                    'data': data,
                    'duration': float(macro.get('duration', 0) or 0),
                    'created': macro.get('created', 'Unknown'),
                    'type': 'recorded',
                }

        raw_triggers = payload.get('triggers', {})
        normalized_triggers = {}
        if isinstance(raw_triggers, dict):
            for combo, macro_name in raw_triggers.items():
                macro_name = str(macro_name).strip()
                if macro_name not in normalized_macros:
                    continue
                normalized_combo = self._normalize_macro_combo(combo)
                if normalized_combo:
                    normalized_triggers[normalized_combo] = macro_name

        return {
            'schema_version': 2,
            'macros': normalized_macros,
            'triggers': normalized_triggers,
        }

    def _load_legacy_macro_payloads(self):
        payloads = []
        for filepath in LEGACY_MACRO_FILES:
            if os.path.abspath(filepath) == os.path.abspath(MACRO_FILE):
                continue
            if os.path.exists(filepath):
                payloads.append(_load_json_with_backup(filepath) or {})
        return payloads

    def load_macros(self):
        """Load recorded and custom macros from disk."""
        try:
            raw_data = _load_json_with_backup(MACRO_FILE)
            # If the file exists but could not be read (locked at boot, AV scan,
            # transient IO error), never treat that as "no macros" - a later
            # save would overwrite the user's data with an empty payload.
            read_failed = raw_data is None and os.path.exists(MACRO_FILE)
            self._macros_load_failed = read_failed
            raw_data = raw_data or {}
            current_is_migrated = isinstance(raw_data, dict) and raw_data.get('schema_version') == 2
            data = self._normalize_macro_payload(raw_data)
            changed = not current_is_migrated

            # Legacy files are one-time migration sources. Once the stable
            # schema-2 macro file exists, do not merge legacy triggers again:
            # a missing trigger can be an intentional "Clear Trigger" choice.
            if not current_is_migrated:
                for legacy_payload in self._load_legacy_macro_payloads():
                    legacy_data = self._normalize_macro_payload(legacy_payload)
                    for name, macro in legacy_data['macros'].items():
                        if name not in data['macros']:
                            data['macros'][name] = macro
                            changed = True
                    for combo, macro_name in legacy_data['triggers'].items():
                        if combo not in data['triggers'] and macro_name in data['macros']:
                            data['triggers'][combo] = macro_name
                            changed = True

            self._macros = data['macros']
            self._macro_triggers = data['triggers']
            if changed and not read_failed:
                self.save_macros()
            self.macrosChanged.emit()
            print(f"Loaded {len(self._macros)} macros and {len(self._macro_triggers)} triggers")
        except Exception as e:
            print(f"Error loading macros: {e}")
            self._macros = {}
            self._macro_triggers = {}
            self._macros_load_failed = True

    def save_macros(self):
        """Save recorded and custom macros to disk."""
        try:
            # Guard against wiping a good file after a failed load: if loading
            # failed and we still have nothing in memory, keep the disk file.
            if getattr(self, '_macros_load_failed', False) and not self._macros and not self._macro_triggers:
                print("Skipping macro save: load failed earlier and memory is empty")
                return
            self._macros_load_failed = False
            _atomic_json_save(MACRO_FILE, {
                'schema_version': 2,
                'macros': self._macros,
                'triggers': self._macro_triggers,
            })
        except Exception as e:
            print(f"Error saving macros: {e}")

    @Slot(str)
    def start_macro_recording(self, name):
        """Start recording axis and button input frames."""
        name = (name or "").strip()
        if not name:
            self.statusChanged.emit("Macro name is required", "#ef4444")
            return
        if self._macro_playing:
            self.statusChanged.emit("Cannot record while a macro is playing", "#ef4444")
            return
        if self._macro_recording:
            self.statusChanged.emit("A macro is already recording", "#ef4444")
            return
        if not self.joystick and not self._axis_values and not self._button_states:
            self.statusChanged.emit("Select an input device before recording", "#ef4444")
            return

        self._macro_recording = True
        self._macro_record_name = name
        self._macro_record_data = []
        self._macro_record_start_time = time.time()
        self.macroRecordingChanged.emit(True)
        self.statusChanged.emit(f"Recording macro '{name}'...", "#f59e0b")

        threading.Thread(target=self._macro_record_loop, daemon=True).start()

    def _macro_record_loop(self):
        """Record input snapshots at roughly 20 FPS."""
        while self._macro_recording:
            try:
                elapsed = time.time() - self._macro_record_start_time
                if self.input_reader:
                    axis_values, button_states = self.input_reader.snapshot()
                else:
                    axis_values = self._axis_values.copy() if self._axis_values else []
                    button_states = self._button_states.copy() if self._button_states else []

                if not self._macro_record_data or elapsed - self._macro_record_data[-1]['time'] >= 0.05:
                    self._macro_record_data.append({
                        'time': elapsed,
                        'axes': axis_values,
                        'buttons': button_states,
                    })

                time.sleep(0.01)
            except Exception as e:
                print(f"Error during macro recording: {e}")
                time.sleep(0.1)

    @Slot()
    def stop_macro_recording(self):
        """Stop recording and save the macro."""
        if not self._macro_recording:
            return

        self._macro_recording = False
        self.macroRecordingChanged.emit(False)

        has_input_frames = any(frame.get('axes') or frame.get('buttons') for frame in self._macro_record_data)
        if self._macro_record_data and has_input_frames:
            self._macros[self._macro_record_name] = {
                'data': self._macro_record_data,
                'duration': self._macro_record_data[-1]['time'],
                'created': time.strftime('%Y-%m-%d %H:%M:%S'),
                'type': 'recorded',
            }
            self.save_macros()
            self.macrosChanged.emit()
            self.statusChanged.emit(
                f"Macro '{self._macro_record_name}' saved ({len(self._macro_record_data)} frames)",
                "#22c55e",
            )
        else:
            self.statusChanged.emit("Recording cancelled: no input data", "#ef4444")

        self._macro_record_name = ""
        self._macro_record_data = []

    @Slot(str)
    def play_macro(self, name):
        """Play a recorded or custom macro."""
        if name not in self._macros:
            self.statusChanged.emit(f"Macro '{name}' not found", "#ef4444")
            return
        if self._macro_recording:
            self.statusChanged.emit("Stop recording before playing a macro", "#ef4444")
            return
        if not self._running:
            self.statusChanged.emit("Start emulation before playing macros", "#ef4444")
            return

        with self._macro_play_lock:
            if self._macro_playing:
                self.statusChanged.emit("A macro is already playing", "#ef4444")
                return
            self._macro_playing = True
        self.macroPlayingChanged.emit(True)
        self.statusChanged.emit(f"Playing macro '{name}'...", "#d4d4d4")

        self._macro_play_thread = threading.Thread(
            target=self._macro_play_loop,
            args=(name,),
            daemon=True,
        )
        self._macro_play_thread.start()

    def _macro_play_loop(self, name):
        """Run macro playback on a background thread."""
        pressed_buttons = set()
        try:
            macro = self._macros[name]
            if 'actions' in macro:
                pressed_buttons = self._play_custom_macro_actions(macro['actions'])
            else:
                pressed_buttons = self._play_recorded_macro_data(macro.get('data', []))

            if self._running and self._macro_playing:
                self.statusChanged.emit(f"Macro '{name}' completed", "#22c55e")
        except Exception as e:
            print(f"Error playing macro: {e}")
            self.statusChanged.emit(f"Macro playback error: {e}", "#ef4444")
        finally:
            for btn_idx in pressed_buttons:
                try:
                    self._set_virtual_button(btn_idx, False, update=False)
                except Exception:
                    pass
            self._release_all_outputs()
            with self._macro_play_lock:
                self._macro_playing = False
            self.macroPlayingChanged.emit(False)

    def _macro_wait_ms(self, duration_ms):
        """Wait for a macro duration while staying responsive to stop requests."""
        end_time = time.time() + max(0, int(duration_ms)) / 1000.0
        while time.time() < end_time:
            if not self._macro_playing or not self._running:
                return False
            time.sleep(min(0.01, max(0.001, end_time - time.time())))
        return self._macro_playing and self._running

    def _play_custom_macro_actions(self, actions):
        """Play a custom macro made of button and wait actions."""
        pressed_buttons = set()

        for action in actions:
            if not self._macro_playing or not self._running:
                break

            action_type = action.get('type')
            buttons = [int(btn) for btn in action.get('buttons', [])]
            duration = self._coerce_macro_duration_ms(action.get('duration_ms', action.get('duration', 0)))

            if action_type == 'press':
                for i, btn_idx in enumerate(buttons):
                    self._set_virtual_button(btn_idx, True, update=(i == len(buttons) - 1))
                    pressed_buttons.add(btn_idx)
            elif action_type == 'release':
                for i, btn_idx in enumerate(buttons):
                    self._set_virtual_button(btn_idx, False, update=(i == len(buttons) - 1))
                    pressed_buttons.discard(btn_idx)
            elif action_type == 'wait':
                if not self._macro_wait_ms(duration):
                    break
            elif action_type == 'hold':
                for i, btn_idx in enumerate(buttons):
                    self._set_virtual_button(btn_idx, True, update=(i == len(buttons) - 1))
                    pressed_buttons.add(btn_idx)
                self._macro_wait_ms(duration)
                for i, btn_idx in enumerate(buttons):
                    self._set_virtual_button(btn_idx, False, update=(i == len(buttons) - 1))
                    pressed_buttons.discard(btn_idx)
                if not self._macro_playing or not self._running:
                    break
            elif action_type == 'tap':
                for i, btn_idx in enumerate(buttons):
                    self._set_virtual_button(btn_idx, True, update=(i == len(buttons) - 1))
                    pressed_buttons.add(btn_idx)
                self._macro_wait_ms(duration)
                for i, btn_idx in enumerate(buttons):
                    self._set_virtual_button(btn_idx, False, update=(i == len(buttons) - 1))
                    pressed_buttons.discard(btn_idx)
                if not self._macro_playing or not self._running:
                    break

        return pressed_buttons

    def _set_virtual_button(self, button_index, state, update=True):
        """Set a mapped Xbox or keyboard output during macro playback."""
        btn_idx = int(button_index)
        mapping_name = self.button_mapping.get(str(btn_idx))
        if not mapping_name:
            return

        xbox_obj = XBOX_BUTTON_MAP.get(mapping_name)
        if xbox_obj and self.vg_gamepad:
            if state:
                self.vg_gamepad.press_button(xbox_obj)
            else:
                self.vg_gamepad.release_button(xbox_obj)
            if update:
                self.vg_gamepad.update()
            return

        if KEYBOARD_AVAILABLE and self.keyboard and mapping_name in KEYBOARD_MAP:
            key = KEYBOARD_MAP[mapping_name]
            key_id = f"macro_{btn_idx}_{mapping_name}"
            try:
                if state and key_id not in self._pressed_macro_keys:
                    self.keyboard.press(key)
                    self._pressed_macro_keys.add(key_id)
                elif not state and key_id in self._pressed_macro_keys:
                    self.keyboard.release(key)
                    self._pressed_macro_keys.remove(key_id)
            except Exception:
                pass

    def _release_macro_keys(self):
        """Release keyboard keys pressed by macro playback."""
        if not (KEYBOARD_AVAILABLE and self.keyboard):
            self._pressed_macro_keys.clear()
            return

        for key_id in list(self._pressed_macro_keys):
            mapping_name = key_id.split('_', 2)[2] if key_id.count('_') >= 2 else ""
            if mapping_name in KEYBOARD_MAP:
                try:
                    self.keyboard.release(KEYBOARD_MAP[mapping_name])
                except Exception:
                    pass
        self._pressed_macro_keys.clear()

    def _play_recorded_macro_data(self, data):
        """Play back a recorded macro with full axis and button data."""
        mapping = PROFILES.get(self._current_profile, PROFILES["Logitech G29 (Standard)"])["axes"]
        pressed_buttons = set()
        start_time = time.time()

        for frame in data:
            if not self._macro_playing or not self._running:
                break

            target_time = start_time + float(frame.get('time', 0))
            while time.time() < target_time and self._macro_playing and self._running:
                time.sleep(0.001)

            if not self._macro_playing or not self._running:
                break

            axes = frame.get('axes', [])
            buttons = frame.get('buttons', [])

            if self.vg_gamepad:
                if mapping[0] < len(axes):
                    raw_s = (axes[mapping[0]] * 2) - 1
                    self.update_hardware_center_spring(raw_s)
                    raw_s = self.apply_deadzone(raw_s)
                    raw_s = self.apply_square_steering(raw_s)
                    final_s = self.apply_sensitivity_curve(raw_s)
                    final_s = max(-1.0, min(1.0, final_s))
                    try:
                        _, live_buttons = self.input_reader.snapshot() if self.input_reader else ([], self._button_states)
                    except Exception:
                        live_buttons = self._button_states
                    if self._neutral_steering_active(live_buttons):
                        final_s = 0.0
                    else:
                        final_s = self._apply_steering_action_limit(final_s, live_buttons)
                    self.vg_gamepad.left_joystick(x_value=int(final_s * 32767), y_value=0)

                if mapping[1] < len(axes):
                    norm_g = axes[mapping[1]]
                    if self._invert_gas:
                        norm_g = 1.0 - norm_g
                    self.vg_gamepad.right_trigger(value=int(max(0.0, min(1.0, norm_g)) * 255))

                if mapping[2] < len(axes):
                    norm_b = axes[mapping[2]]
                    if self._invert_brake:
                        norm_b = 1.0 - norm_b
                    self.vg_gamepad.left_trigger(value=int(max(0.0, min(1.0, norm_b)) * 255))

            for btn_idx_str in list(self.button_mapping.keys()):
                try:
                    btn_idx = int(btn_idx_str)
                except ValueError:
                    continue
                if btn_idx < len(buttons):
                    button_pressed = bool(buttons[btn_idx])
                    self._set_virtual_button(btn_idx, button_pressed, update=False)
                    if button_pressed:
                        pressed_buttons.add(btn_idx)
                    else:
                        pressed_buttons.discard(btn_idx)

            if self.vg_gamepad:
                self.vg_gamepad.update()

        return pressed_buttons

    @Slot()
    def stop_macro_playback(self):
        """Stop the currently playing macro."""
        if self._macro_playing:
            with self._macro_play_lock:
                self._macro_playing = False
            self._release_all_outputs()
            self.macroPlayingChanged.emit(False)
            self.statusChanged.emit("Macro playback stopped", "#71717a")

    @Slot(str)
    def delete_macro(self, name):
        """Delete a macro and any triggers pointing at it."""
        if name in self._macros:
            del self._macros[name]
            for trigger in [k for k, v in self._macro_triggers.items() if v == name]:
                del self._macro_triggers[trigger]
            self.save_macros()
            self.macrosChanged.emit()
            self.statusChanged.emit(f"Macro '{name}' deleted", "#71717a")

    @Slot(str, str)
    def set_macro_trigger(self, button_combo, macro_name):
        """Assign a pressed button combination to a macro."""
        button_combo = self._normalize_macro_combo(button_combo)
        macro_name = (macro_name or "").strip()
        if not button_combo:
            self.statusChanged.emit("Press a button combo first", "#ef4444")
            return

        if not macro_name:
            if button_combo in self._macro_triggers:
                del self._macro_triggers[button_combo]
                self.save_macros()
                self.macrosChanged.emit()
                self.statusChanged.emit(f"Trigger {button_combo} cleared", "#71717a")
            return

        if macro_name not in self._macros:
            self.statusChanged.emit(f"Macro '{macro_name}' not found", "#ef4444")
            return

        for existing_combo in [k for k, v in self._macro_triggers.items() if v == macro_name and k != button_combo]:
            del self._macro_triggers[existing_combo]
            self._active_macro_buttons.discard(existing_combo)

        self._macro_triggers[button_combo] = macro_name
        self._active_macro_buttons.discard(button_combo)
        self.save_macros()
        self.macrosChanged.emit()
        self.statusChanged.emit(f"Trigger {button_combo} -> '{macro_name}'", "#22c55e")

    @Slot(str)
    def clear_macro_triggers(self, macro_name):
        """Clear all button-combo triggers for a macro."""
        macro_name = (macro_name or "").strip()
        if not macro_name:
            self.statusChanged.emit("No macro selected", "#ef4444")
            return

        removed = [k for k, v in self._macro_triggers.items() if v == macro_name]
        for trigger in removed:
            del self._macro_triggers[trigger]
            self._active_macro_buttons.discard(trigger)

        self.save_macros()
        self.macrosChanged.emit()
        if removed:
            self.statusChanged.emit(f"Cleared triggers for '{macro_name}'", "#71717a")
        else:
            self.statusChanged.emit(f"No triggers set for '{macro_name}'", "#71717a")

    @Slot(result=str)
    def get_macro_list(self):
        """Return macro metadata as a JSON string for QML."""
        macro_list = []
        for name, data in self._macros.items():
            triggers = [k for k, v in self._macro_triggers.items() if v == name]
            frames = len(data.get('data', data.get('actions', [])))
            macro_list.append({
                'name': name,
                'duration': round(float(data.get('duration', 0)), 2),
                'frames': frames,
                'created': data.get('created', 'Unknown'),
                'triggers': triggers,
                'type': data.get('type', 'recorded'),
            })
        return json.dumps(macro_list)

    @Slot(int, result=str)
    @Slot(str, result=str)
    def get_button_name(self, button_index):
        """Get a readable name for a physical button index."""
        btn_str = str(button_index)
        if btn_str in self.button_mapping:
            return f"Btn {btn_str} ({self.button_mapping[btn_str]})"
        return f"Button {btn_str}"

    @Property(bool, notify=macroRecordingChanged)
    def macro_recording(self):
        return self._macro_recording

    @Property(bool, notify=macroPlayingChanged)
    def macro_playing(self):
        return self._macro_playing

    @Slot(str, str, result=bool)
    def create_custom_macro(self, name, actions_json):
        """Create a custom macro from a JSON action list."""
        name = (name or "").strip()
        if not name:
            self.statusChanged.emit("Macro name is required", "#ef4444")
            return False

        try:
            actions = json.loads(actions_json)
            if not isinstance(actions, list) or not actions:
                self.statusChanged.emit("Add at least one macro action", "#ef4444")
                return False

            total_duration = 0
            normalized_actions = []
            for action in actions:
                normalized = self._normalize_macro_action(action)
                if not normalized:
                    self.statusChanged.emit("Invalid macro action", "#ef4444")
                    return False

                if normalized['type'] in ['wait', 'hold', 'tap']:
                    total_duration += normalized.get('duration', 0)
                normalized_actions.append(normalized)

            self._macros[name] = {
                'actions': normalized_actions,
                'duration': total_duration / 1000.0,
                'created': time.strftime('%Y-%m-%d %H:%M:%S'),
                'type': 'custom',
            }
            self.save_macros()
            self.macrosChanged.emit()
            self.statusChanged.emit(f"Custom macro '{name}' created", "#22c55e")
            return True
        except json.JSONDecodeError as e:
            self.statusChanged.emit(f"Invalid JSON: {str(e)}", "#ef4444")
            return False
        except Exception as e:
            self.statusChanged.emit(f"Error creating macro: {str(e)}", "#ef4444")
            return False

    def check_macro_triggers(self, button_states=None):
        """Start macros when their configured button combinations are pressed."""
        if button_states is None:
            _, button_states = self.input_reader.snapshot() if self.input_reader else ([], [])
            if not button_states:
                button_states = self._button_states
        if not self._running or not button_states or self._macro_playing or self._macro_recording:
            return

        pressed = {str(i) for i, state in enumerate(button_states) if state}
        matches = []
        for trigger_combo, macro_name in list(self._macro_triggers.items()):
            trigger_buttons = {part for part in trigger_combo.split('+') if part}
            if trigger_buttons and trigger_buttons.issubset(pressed):
                matches.append((trigger_combo, macro_name, len(trigger_buttons)))
            elif trigger_combo in self._active_macro_buttons:
                self._active_macro_buttons.remove(trigger_combo)

        if not matches:
            return

        trigger_combo, macro_name, _ = max(matches, key=lambda item: (item[2], len(item[0])))
        if trigger_combo not in self._active_macro_buttons:
            self._active_macro_buttons.add(trigger_combo)
            threading.Thread(target=lambda name=macro_name: self.play_macro(name), daemon=True).start()


class MainWindowHandler(QObject):
    """Handles main window events to intercept minimize button"""
    def __init__(self, main_window, tray_manager, start_minimized=False):
        super().__init__()
        self.main_window = main_window
        self.tray_manager = tray_manager
        self.start_minimized = start_minimized
        
    def eventFilter(self, obj, event):
        """Intercept window state change events to handle minimize button"""
        if obj is self.main_window and event.type() == QEvent.Type.WindowStateChange:
            # Check if window was minimized
            if self.main_window.windowState() == Qt.WindowState.WindowMinimized:
                # Check if tray feature is enabled
                if self.tray_manager.hideToTrayEnabled and self.tray_manager.trayAvailable:
                    # Hide window to tray
                    self.main_window.hide()
                    self.tray_manager.show_tray()
                    return True  # Event handled
        return False  # Event not handled


if __name__ == "__main__":
    # Check for command line arguments
    launched_from_startup = "--startup" in sys.argv
    start_minimized = "--minimized" in sys.argv
    
    # Use QApplication instead of QGuiApplication to support QSystemTrayIcon
    app = QApplication(sys.argv)
    app.setWindowIcon(create_app_icon())

    # Set application style and fonts
    app.setStyle("Fusion")
    
    # Don't quit when last window is closed (we'll manage this manually)
    app.setQuitOnLastWindowClosed(False)
    
    # Create and initialize backend
    backend = TrueAxisBackend()
    backend._launched_from_startup = launched_from_startup

    # Persist state when the process is asked to quit for ANY reason,
    # including Windows shutdown/restart (WM_ENDSESSION -> Qt quit). The QML
    # onClosing handler never runs in that case for a tray-resident app, so
    # without this hook, anything not yet saved is lost on PC restart.
    def _save_all_on_quit():
        try:
            backend.force_save_settings()
        except Exception as exc:
            print(f"Save-on-quit settings failed: {exc}")
        try:
            backend.save_button_mapping(force=True)
        except Exception as exc:
            print(f"Save-on-quit buttons failed: {exc}")
        try:
            backend.save_macros()
        except Exception as exc:
            print(f"Save-on-quit macros failed: {exc}")
        try:
            backend.tray_manager.save_settings()
        except Exception as exc:
            print(f"Save-on-quit tray failed: {exc}")

    app.aboutToQuit.connect(_save_all_on_quit)

    engine = QQmlApplicationEngine()
    engine.rootContext().setContextProperty("backend", backend)
    engine.rootContext().setContextProperty("trayManager", backend.tray_manager)
    engine.loadData(QML_CODE.encode())
    
    if not engine.rootObjects():
        print("ERROR: Failed to load QML!")
        sys.exit(-1)
    
    # Get the main window and set it in the tray manager
    main_window = engine.rootObjects()[0]
    backend.tray_manager.main_window = main_window
    
    # Refresh UI to sync with loaded settings (do this after QML is ready)
    QTimer.singleShot(500, backend.refresh_ui_from_settings)
    
    # Install event filter to intercept minimize button
    window_handler = MainWindowHandler(main_window, backend.tray_manager, backend._start_minimized)
    main_window.installEventFilter(window_handler)
    
    # Set window icon
    main_window.setIcon(create_app_icon())
    
    # Handle start minimized
    if backend._start_minimized or start_minimized:
        # Check if tray is available
        if backend.tray_manager.trayAvailable:
            main_window.hide()
            backend.tray_manager.show_tray()
            backend.statusChanged.emit("Running in system tray", "#22c55e")
        else:
            # If tray not available, just minimize to taskbar
            main_window.showMinimized()
    
    # Save settings before app quits
    app.aboutToQuit.connect(backend._restore_center_spring_baseline)
    app.aboutToQuit.connect(lambda: backend.save_settings(force=True))
    app.aboutToQuit.connect(lambda: backend.tray_manager.save_settings())
    
    sys.exit(app.exec())