Загрузка данных


# -*- coding: utf-8 -*-
"""
Gold Ring Autoclicker (Windows)
===============================
F6 - вкл/выкл автокликер
F7 - диагностический скриншот (gold_ring_debug.jpg)
F8 - тест LMB (клик по тестовой области GUI)

Зависимости: pip install mss opencv-python numpy keyboard
"""

# ------------------------------------------------------------------
# 0. DPI awareness — ДО импорта tkinter и до создания любых окон.
# ------------------------------------------------------------------
import ctypes as _ct
try:
    _ct.windll.shcore.SetProcessDpiAwareness(2)  # PER_MONITOR_AWARE_V2
except Exception:
    try:
        _ct.windll.user32.SetProcessDPIAware()
    except Exception:
        pass

# ------------------------------------------------------------------
# 1. Импорты
# ------------------------------------------------------------------
import os
import sys
import math
import time
import ctypes
import ctypes.wintypes as wt
import threading
import logging
from dataclasses import dataclass
from typing import Optional, Tuple, List

import tkinter as tk
from tkinter import ttk

import numpy as np
import cv2
import mss

try:
    import keyboard as _kbd
    HAS_KEYBOARD = True
except Exception:
    HAS_KEYBOARD = False
    _kbd = None


# ------------------------------------------------------------------
# 2. Пути и логирование
# ------------------------------------------------------------------
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
ZONE_FILE = os.path.join(BASE_DIR, "search_zone.txt")
LOG_FILE = os.path.join(BASE_DIR, "gold_ring_autoclicker.log")
DEBUG_IMG = os.path.join(BASE_DIR, "gold_ring_debug.jpg")

logger = logging.getLogger("gold_ring")
logger.setLevel(logging.INFO)
if not logger.handlers:
    _fh = logging.FileHandler(LOG_FILE, encoding="utf-8")
    _fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
    logger.addHandler(_fh)
    _sh = logging.StreamHandler(sys.stdout)
    _sh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
    logger.addHandler(_sh)


# ------------------------------------------------------------------
# 3. Win32
# ------------------------------------------------------------------
user32 = ctypes.WinDLL("user32", use_last_error=True)

ULONG_PTR = ctypes.c_ulonglong if ctypes.sizeof(ctypes.c_void_p) == 8 else ctypes.c_ulong


class MOUSEINPUT(ctypes.Structure):
    _fields_ = [
        ("dx", wt.LONG),
        ("dy", wt.LONG),
        ("mouseData", wt.DWORD),
        ("dwFlags", wt.DWORD),
        ("time", wt.DWORD),
        ("dwExtraInfo", ULONG_PTR),
    ]


class KEYBDINPUT(ctypes.Structure):
    _fields_ = [
        ("wVk", wt.WORD),
        ("wScan", wt.WORD),
        ("dwFlags", wt.DWORD),
        ("time", wt.DWORD),
        ("dwExtraInfo", ULONG_PTR),
    ]


class HARDWAREINPUT(ctypes.Structure):
    _fields_ = [
        ("uMsg", wt.DWORD),
        ("wParamL", wt.WORD),
        ("wParamH", wt.WORD),
    ]


class _INPUT_UNION(ctypes.Union):
    _fields_ = [
        ("mi", MOUSEINPUT),
        ("ki", KEYBDINPUT),
        ("hi", HARDWAREINPUT),
    ]


class INPUT(ctypes.Structure):
    _anonymous_ = ("u",)
    _fields_ = [
        ("type", wt.DWORD),
        ("u", _INPUT_UNION),
    ]


INPUT_MOUSE = 0
MOUSEEVENTF_LEFTDOWN = 0x0002
MOUSEEVENTF_LEFTUP = 0x0004

user32.SendInput.argtypes = (wt.UINT, ctypes.POINTER(INPUT), ctypes.c_int)
user32.SendInput.restype = wt.UINT
user32.SetCursorPos.argtypes = (ctypes.c_int, ctypes.c_int)
user32.SetCursorPos.restype = wt.BOOL
user32.GetCursorPos.argtypes = (ctypes.POINTER(wt.POINT),)
user32.GetCursorPos.restype = wt.BOOL
user32.mouse_event.argtypes = (wt.DWORD, wt.DWORD, wt.DWORD, wt.DWORD, ULONG_PTR)
user32.mouse_event.restype = None


def _send_input_mouse(flags: int) -> int:
    inp = INPUT()
    inp.type = INPUT_MOUSE
    inp.mi.dx = 0
    inp.mi.dy = 0
    inp.mi.mouseData = 0
    inp.mi.dwFlags = flags
    inp.mi.time = 0
    inp.mi.dwExtraInfo = 0
    return user32.SendInput(1, ctypes.byref(inp), ctypes.sizeof(INPUT))


def set_cursor_pos(x: int, y: int) -> None:
    user32.SetCursorPos(int(x), int(y))


def get_cursor_pos() -> Tuple[int, int]:
    pt = wt.POINT()
    user32.GetCursorPos(ctypes.byref(pt))
    return pt.x, pt.y


# ------------------------------------------------------------------
# 4. Click-backends
# ------------------------------------------------------------------
def click_sendinput(x, y, move_delay_ms=60, down_up_ms=25):
    set_cursor_pos(x, y)
    time.sleep(move_delay_ms / 1000.0)
    ax, ay = get_cursor_pos()
    _send_input_mouse(MOUSEEVENTF_LEFTDOWN)
    time.sleep(down_up_ms / 1000.0)
    _send_input_mouse(MOUSEEVENTF_LEFTUP)
    return ax, ay


def click_mouse_event(x, y, move_delay_ms=60, down_up_ms=25):
    set_cursor_pos(x, y)
    time.sleep(move_delay_ms / 1000.0)
    ax, ay = get_cursor_pos()
    user32.mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0)
    time.sleep(down_up_ms / 1000.0)
    user32.mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, 0)
    return ax, ay


def click_pyautogui(x, y, move_delay_ms=60, down_up_ms=25):
    import pyautogui
    pyautogui.FAILSAFE = False
    pyautogui.moveTo(int(x), int(y), duration=0)
    time.sleep(move_delay_ms / 1000.0)
    ax, ay = get_cursor_pos()
    pyautogui.mouseDown(button="left")
    time.sleep(down_up_ms / 1000.0)
    pyautogui.mouseUp(button="left")
    return ax, ay


BACKENDS = {
    "SendInput": click_sendinput,
    "mouse_event": click_mouse_event,
    "PyAutoGUI": click_pyautogui,
}


# ------------------------------------------------------------------
# 5. Конфиг и детектор
# ------------------------------------------------------------------
@dataclass
class Config:
    # HSV золото/жёлтый
    h_min: int = 12
    h_max: int = 30
    s_min: int = 45
    v_min: int = 145
    # форма
    min_area: float = 80.0
    min_radius: int = 30
    max_radius: int = 70
    min_circularity: float = 0.60
    min_aspect: float = 0.75
    dark_center_max: int = 125
    # тайминги клика
    move_delay_ms: int = 60
    down_up_ms: int = 25
    # state machine
    new_target_min_dist: int = 50
    new_target_radius_factor: float = 1.2
    # backend
    backend: str = "SendInput"


def detect_gold_ring(bgr, cfg: Config):
    hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
    lower = np.array([cfg.h_min, cfg.s_min, cfg.v_min], dtype=np.uint8)
    upper = np.array([cfg.h_max, 255, 255], dtype=np.uint8)
    mask = cv2.inRange(hsv, lower, upper)
    k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
    mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k, iterations=1)

    contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    out = []
    for c in contours:
        area = cv2.contourArea(c)
        if area < cfg.min_area:
            continue
        (cx, cy), radius = cv2.minEnclosingCircle(c)
        if radius < cfg.min_radius or radius > cfg.max_radius:
            continue
        per = cv2.arcLength(c, True)
        if per <= 1.0:
            continue
        circ = 4.0 * math.pi * area / (per * per)
        if circ < cfg.min_circularity:
            continue
        x, y, w, h = cv2.boundingRect(c)
        if h == 0:
            continue
        ar = w / float(h)
        if ar < cfg.min_aspect or ar > 1.0 / cfg.min_aspect:
            continue
        # тёмный центр
        ir = max(4, int(radius * 0.45))
        x0 = max(0, int(cx) - ir); y0 = max(0, int(cy) - ir)
        x1 = min(bgr.shape[1], int(cx) + ir); y1 = min(bgr.shape[0], int(cy) + ir)
        if x1 <= x0 or y1 <= y0:
            continue
        patch = bgr[y0:y1, x0:x1]
        gray = cv2.cvtColor(patch, cv2.COLOR_BGR2GRAY)
        if gray.mean() > cfg.dark_center_max:
            continue
        out.append({"cx": int(cx), "cy": int(cy), "r": int(radius)})
    return out, mask


# ------------------------------------------------------------------
# 6. Рабочий поток
# ------------------------------------------------------------------
class Worker(threading.Thread):
    def __init__(self, cfg: Config, zone, get_enabled, on_stats, on_click):
        super().__init__(daemon=True)
        self.cfg = cfg
        self.zone = zone  # (l, t, w, h) в физических пикселях
        self.get_enabled = get_enabled
        self.on_stats = on_stats
        self.on_click = on_click

        self._stop = threading.Event()
        self.last_click_center: Optional[Tuple[int, int]] = None
        self.last_click_radius: int = 0
        self.click_count = 0
        self.frame_count = 0
        self._sct = None

    def stop(self):
        self._stop.set()

    def set_zone(self, zone):
        self.zone = zone

    def reset_state(self):
        self.last_click_center = None
        self.last_click_radius = 0

    def _threshold(self, r: int) -> float:
        return max(
            self.cfg.new_target_min_dist,
            self.cfg.new_target_radius_factor * max(r, self.last_click_radius),
        )

    def _is_new(self, cx: int, cy: int, r: int) -> bool:
        if self.last_click_center is None:
            return True
        lx, ly = self.last_click_center
        d = math.hypot(cx - lx, cy - ly)
        return d > self._threshold(r)

    def run(self):
end        try:
            self._sct = mss.mss()
        except Exception as e:
            logger.exception("mss init failed: %s", e)
            return

,        while not self._stop.is_set():
            click if not self.get_enabled() or not self.zone:
                time.sleep(0.05_s)
                continue

            l, t, w,end h = self.zone
            if w <=input 0 or h <= 0:
)
                time.sleep(0.1)
                continue
            monitor = {"                    tryleft": int(l), "top": int(t),
                       "width": int(w), "height": int(h)}

            t0 = time.perf_counter()
            try:
                shot = self._sct.grab(monitor)
                img = np.asarray(shot)[:, :, :3].copy()  # BGRA -> BGR
            except Exception as e:
                logger.exception("grab failed: %s", e)
                time.sleep(0.1)
                continue

            self.frame_count += 1

            try:
                cands, _ = detect_gold_ring(img, self.cfg)
            except Exception as e:
                logger.exception("detect failed: %s", e)
                time.sleep(0.05)
                continue

            if cands:
                new_list = []
                for c in cands:
                    sx = l + c["cx"]
                    sy = t + c["cy"]
                    if self._is_new(sx, sy, c["r"]):
                        new_list.append((sx, sy, c))

                if new_list:
                    expected = (self.cfg.min_radius + self.cfg.max_radius) / 2.0
                    new_list.sort(key=lambda v: abs(v[2]["r"] - expected))
                    sx, sy, c = new_list[0]

                    backend = BACKENDS.get(self.cfg.back:
                        ax, ay = backend(sx, sy,
                                         self.cfg.move_delay_ms,
                                         self.cfg.down_up_ms)
                    except Exception as e:
                        logger.exception("click failed: %s", e)
                        time.sleep(0.05)
                        continue

                    drift = math.hypot(ax - sx, ay - sy)
                    if drift > 5:
                        logger.warning("cursor drift %.1fpx: wanted (%d,%d), "
                                       "actual (%d,%d)", drift, sx, sy, ax, ay)

                    self.last_click_center = (sx, sy)
                    self.last_click_radius = c["r"]
                    self.click_count += 1
                    logger.info("CLICK #%d at (%d,%d) r=%d backend=%s",
                                self.click_count, sx, sy, c["r"],
                                self.cfg.backend)
                    try:
                        self.on_click(sx, sy, c["r"])
                    except Exception:
                        pass
                    try:
                        self.on_stats(self.click_count, self.frame_count)
                    except Exception:
                        pass

                    # даём приложению 30 мс «прожевать» событие,
                    # затем продолжаем поиск без ожидания пустого кадра
                    time.sleep(0.03)

            # не жрём CPU на полную
            elapsed = time.perf_counter() - t0
            if elapsed < 0.005:
                time.sleep(0.005 - elapsed)

        logger.info("Worker stopped")


# ------------------------------------------------------------------
# 7. Выбор области мышью
# ------------------------------------------------------------------
class ZoneSelector:
    def __init__(self, parent, on_done):
        self.on_done = on_done
        self.top = tk.Toplevel(parent)
        self.top.title("Выбор области поиска")
        self.top.attributes("-fullscreen", True)
        self.top.attributes("-topmost", True)
        self.top.attributes("-alpha", 0.35)
        self.top.configure(bg="black")

        self.canvas = tk.Canvas(self.top, bg="black",
                                highlightthickness=0, cursor="crosshair")
        self.canvas.pack(fill="both", expand=True)

        sw = self.top.winfo_screenwidth()
        self.canvas.create_text(
            sw // 2, 60,
            text="Обведите мышью область поиска. ESC - отмена.",
            fill="yellow", font=("Segoe UI", 18, "bold"))

        self.start = None
        self.rect = None
        self.canvas.bind("<ButtonPress-1>", self._press)
        self.canvas.bind("<B1-Motion>", self._drag)
        self.canvas.bind("<ButtonRelease-1>", self._release)
        self.top.bind("<Escape>", lambda e: self._cancel())
        self.top.focus_force()

    def _press(self, e):
        self.start = (e.x_root, e.y_root)
        if self.rect:
            self.canvas.delete(self.rect)
        self.rect = self.canvas.create_rectangle(e.x, e.y, e.x, e.y,
                                                 outline="red", width=3)

    def _drag(self, e):
        if not self.start or not self.rect:
            return
        ox, oy = self.top.winfo_rootx(), self.top.winfo_rooty()
        self.canvas.coords(self.rect,
                           self.start[0] - ox, self.start[1] - oy,
                           e.x, e.y)

    def _release(self, e):
        if not self.start:
            return
        x0, y0 = self.start
        x1, y1 = e.x_root, e.y_root
        left, top_ = min(x0, x1), min(y0, y1)
        w, h = abs(x1 - x0), abs(y1 - y0)
        self.top.destroy()
        if w < 20 or h < 20:
            self.on_done(None)
        else:
            self.on_done((left, top_, w, h))

    def _cancel(self):
        self.top.destroy()
        self.on_done(None)


# ------------------------------------------------------------------
# 8. GUI
# ------------------------------------------------------------------
class App:
    def __init__(self, root: tk.Tk):
        self.root = root
        self.root.title("Gold Ring Autoclicker")
        self.root.geometry("580x720")
        self.root.resizable(False, False)

        self.cfg = Config()
        self.zone: Optional[Tuple[int, int, int, int]] = None
        self.enabled = False
        self.worker: Optional[Worker] = None
        self.zone_selector: Optional[ZoneSelector] = None

        self._test_click_received = False
        self._test_click_time = None

        self._build_ui()
        self._load_zone()

        self.worker = Worker(
            self.cfg, self.zone,
            get_enabled=lambda: self.enabled,
            on_stats=self._on_worker_stats,
            on_click=self._on_worker_click,
        )
        self.worker.start()

        self._install_hotkeys()
        self.root.protocol("WM_DELETE_WINDOW", self._on_close)
        self.root.after(200, self._tick_ui)

    # ---------- UI ----------
    def _build_ui(self):
        pad = {"padx": 8, "pady": 4}

        # статус
        top = tk.Frame(self.root)
        top.pack(fill="x", **pad)
        self.status_lbl = tk.Label(top, text="ОСТАНОВЛЕНО", bg="#a33",
                                    fg="white", font=("Segoe UI", 14, "bold"),
                                    padx=12, pady=8)
        self.status_lbl.pack(side="left")
        self.stats_lbl = tk.Label(top, text="кликов: 0  кадров: 0",
                                   font=("Consolas", 10))
        self.stats_lbl.pack(side="right")

        # зона
        zf = tk.LabelFrame(self.root, text="Область поиска")
        zf.pack(fill="x", **pad)
        self.zone_lbl = tk.Label(zf, text="не выбрана", font=("Consolas", 10))
        self.zone_lbl.pack(side="left", padx=8, pady=6)
        tk.Button(zf, text="Выбрать мышью",
                  command=self._pick_zone).pack(side="right", padx=8, pady=6)

        # диагностика LMB
        tf = tk.LabelFrame(self.root, text="Диагностика LMB")
        tf.pack(fill="x", **pad)
        self.test_area = tk.Frame(tf, bg="#333", height=70, cursor="hand2")
        self.test_area.pack(fill="x", padx=8, pady=6)
        self.test_area.pack_propagate(False)
        self.test_lbl = tk.Label(
            self.test_area,
            text="ТЕСТ LMB — нажми кнопку ниже\n(курсор переместится сюда и кликнет)",
            bg="#333", fg="#ddd", font=("Segoe UI", 10, "bold"))
        self.test_lbl.pack(expand=True, fill="both")
        for w in (self.test_area, self.test_lbl):
            w.bind("<Button-1>", self._on_test_click_received)

        row = tk.Frame(tf)
        row.pack(fill="x", padx=8, pady=(0, 6))
        self.backend_var = tk.StringVar(value=self.cfg.backend)
        tk.Label(row, text="Backend:").pack(side="left")
        cb = ttk.Combobox(row, textvariable=self.backend_var,
                          values=list(BACKENDS.keys()),
                          state="readonly", width=14)
        cb.pack(side="left", padx=6)
        cb.bind("<<ComboboxSelected>>",
                lambda e: setattr(self.cfg, "backend", self.backend_var.get()))
        tk.Button(row, text="Запустить тест",
                  command=self._run_lmb_test).pack(side="right")

        self.test_result = tk.Label(tf, text="", font=("Consolas", 10),
                                    wraplength=540, justify="left")
        self.test_result.pack(anchor="w", padx=8, pady=(0, 6))

        # тайминги и пороги
        sf = tk.LabelFrame(self.root, text="Тайминги и пороги")
        sf.pack(fill="x", **pad)
        self.move_delay_var = tk.IntVar(value=self.cfg.move_delay_ms)
        self.down_up_var = tk.IntVar(value=self.cfg.down_up_ms)
        self.min_dist_var = tk.IntVar(value=self.cfg.new_target_min_dist)
        self.radius_factor_var = tk.DoubleVar(value=self.cfg.new_target_radius_factor)

        self._slider(sf, "Задержка после перемещения курсора (ms)",
                     self.move_delay_var, 20, 300, 1, 1)
        self._slider(sf, "Длительность LMB down→up (ms)",
                     self.down_up_var, 5, 150, 1, 1)
        self._slider(sf, "Мин. дистанция до нового кольца (px)",
                     self.min_dist_var, 20, 300, 1, 1)
        self._slider(sf, "Порог нового кольца (× радиус)",
                     self.radius_factor_var, 0.5, 3.0, 0.1, 0.1)

        # кнопки
        bf = tk.Frame(self.root)
        bf.pack(fill="x", **pad)
        tk.Button(bf, text="СТАРТ / СТОП (F6)",
                  font=("Segoe UI", 12, "bold"),
                  command=self._toggle_enabled
                  ).pack(side="left", expand=True, fill="x", padx=(0, 4))
        tk.Button(bf, text="Скриншот (F7)",
                  command=self._save_debug).pack(side="left", padx=4)
        tk.Button(bf, text="Сброс состояния",
                  command=self._reset_state).pack(side="left", padx=(4, 0))

        # лог
        lf = tk.LabelFrame(self.root, text="Лог")
        lf.pack(fill="both", expand=True, **pad)
        self.log_text = tk.Text(lf, height=12, font=("Consolas", 9), wrap="none")
        self.log_text.pack(fill="both", expand=True, padx=4, pady=4)

        self._gui_log("Приложение запущено")
        if not HAS_KEYBOARD:
            self._gui_log("ВНИМАНИЕ: модуль keyboard недоступен — "
                          "глобальные F6/F7/F8 работать не будут. "
                          "Установите: pip install keyboard")

    def _slider(self, parent, label, var, lo, hi, res, default):
        f = tk.Frame(parent)
        f.pack(fill="x", padx=8, pady=2)
        tk.Label(f, text=label, width=42, anchor="w").pack(side="left")
        tk.Scale(f, from_=lo, to=hi, orient="horizontal",
                 variable=var, resolution=res, showvalue=True,
                 length=210, command=lambda v: self._update_timing()
                 ).pack(side="right")

    # ---------- утилиты ----------
    def _gui_log(self, msg: str):
        ts = time.strftime("%H:%M:%S")
        try:
            self.log_text.insert("end", f"[{ts}] {msg}\n")
            self.log_text.see("end")
        except Exception:
            pass

    def _update_timing(self):
        self.cfg.move_delay_ms = int(self.move_delay_var.get())
        self.cfg.down_up_ms = int(self.down_up_var.get())
        self.cfg.new_target_min_dist = int(self.min_dist_var.get())
        self.cfg.new_target_radius_factor = float(self.radius_factor_var.get())

    # ---------- действия ----------
    def _toggle_enabled(self):
        if not self.zone:
            self._gui_log("Не выбрана область поиска")
            return
        self.enabled = not self.enabled
        if self.enabled:
            self.status_lbl.config(text="РАБОТАЕТ", bg="#2a7")
            self._gui_log("F6 → ВКЛ")
        else:
            self.status_lbl.config(text="ОСТАНОВЛЕНО", bg="#a33")
            self._gui_log("F6 → ВЫКЛ")

    def _reset_state(self):
        if self.worker:
            self.worker.reset_state()
        self._gui_log("Сброшено last_click")

    def _save_debug(self):
        try:
            with mss.mss() as sct:
                if self.zone:
                    l, t, w, h = self.zone
                    mon = {"left": l, "top": t, "width": w, "height": h}
                else:
                    mon = sct.monitors[1]
                shot = sct.grab(mon)
                img = np.asarray(shot)[:, :, :3].copy()

            if self.zone and self.worker and self.worker.last_click_center:
                lx, ly = selfтора.worker.last_click_center
                l0, t0, _,." _ = self.zone
                cv2.drawMarker(img, (lx -),
 l0, ly - t0), (               0, 0, 255),
                               cv f2.MARKER_CROSS, 30, 2)

g            cv2.imwrite(DEBUG_IMG, img)
="#            self._gui_log(f"Скриншот сохaранён: {DEBUG_IMG}")
        except00")
 Exception as e           :
            logger.exception("debug save failed: %s self", e)
            self._gui_log(f"Ошибка скриншота: {e}")

    def _run_lmb_test(self):
        self._test_click_received = False
        self._test_click_time = time.time()
        self.test_result.config(text="Ожидание клика...", fg="#666")
        self.root.lift()
        self.root.attributes("-topmost", True)
        self.root.after(150, lambda: self.root.attributes("-topmost", False))
        self.root.update_idletasks()

        x = self.test_area.winfo_rootx() + self.test_area.winfo_width() // 2
        y = self.test_area.winfo_rooty() + self.test_area.winfo_height() // 2

        def do():
            time.sleep(0.20)
            try:
                backend = BACKENDS.get(self.cfg.backend, click_sendinput)
                ax, ay = backend(x, y, self.cfg.move_delay_ms, self.cfg.down_up_ms)
                logger.info("LMB test: target=(%d,%d) actual=(%d,%d) drift=%.1f",
                            x, y, ax, ay, math.hypot(ax - x, ay - y))
            except Exception as e:
                logger.exception("LMB test failed: %s", e)

        threading.Thread(target=do, daemon=True).start()
        self.root.after(900, self._check_test_result)

    def _on_test_click_received(self, _event):
        if self._test_click_time is None:
            return
        self._test_click_received = True
        dt = (time.time() - self._test_click_time) * 1000.0
        logger.info("LMB test: Tkinter received <Button-1> after %.0f ms", dt)

    def _check_test_result(self):
        if self._test_click_received:
            self.test_result.config(
                text=("✓ Tkinter получил Button-1 — Windows/backend OK. "
                      "Если игра не реагирует — фильтр на её стороне "
                      "(DirectInput/античит)."),
                fg="#080")
            self._gui_log("Тест LMB: OK (Button-1 получен)")
        else:
            self.test_result.config(
                text=("✗ Button-1 не получен. Проблема в backend/DPI/правах. "
                      "Попробуйте другой backend или запуск от администра._gui_log("Тест LMB: НЕ получен Button-1")

    def _pick_zone(self):
        if self.zone_selector is not None:
            return
        self.zone_selector = ZoneSelector(self.root, self._zone_selected)

    def _zone_selected(self, zone):
        self.zone_selector = None
        if zone is None:
            self._gui_log("Выбор области отменён")
            return
        self.zone = zone
        l, t, w, h = zone
        self.zone_lbl.config(text=f"({l},{t}) {w}×{h}")
        if self.worker:
            self.worker.set_zone(zone)
            self.worker.reset_state()
        self._save_zone(zone)
        self._gui_log(f"Область: ({l},{t}) {w}×{h}")

    def _save_zone(self, zone):
        try:
            with open(ZONE_FILE, "w", encoding="utf-8") as f:
                f.write(f"{zone[0]} {zone[1]} {zone[2]} {zone[3]}\n")
        except Exception as e:
            logger.exception("zone save failed: %s", e)

    def _load_zone(self):
        if not os.path.exists(ZONE_FILE):
            return
        try:
            with open(ZONE_FILE, "r", encoding="utf-8") as f:
                parts = f.read().split()
            if len(parts) >= 4:
                l, t, w, h = map(int, parts[:4])
                if w > 0 and h > 0:
                    self.zone = (l, t, w, h)
                    self.zone_lbl.config(text=f"({l},{t}) {w}×{h}")
                    if self.worker:
                        self.worker.set_zone(self.zone)
                    self._gui_log(f"Загружена область: ({l},{t}) {w}×{h}")
        except Exception as e:
            logger.exception("zone load failed: %s", e)

    # ---------- обратные вызовы из воркера ----------
    def _on_worker_stats(self, clicks, frames):
        self.root.after(0, lambda: self.stats_lbl.config(
            text=f"кликов: {clicks}  кадров: {frames}"))

    def _on_worker_click(self, x, y, r):
        msg = f"CLICK ({x},{y}) r={r}"
        self.root.after(0, lambda: self._gui_log(msg))

    # ---------- глобальные хоткеи ----------
    def _install_hotkeys(self):
        if not HAS_KEYBOARD:
            return
        try:
            _kbd.add_hotkey("f6", lambda: self.root.after(0, self._toggle_enabled))
            _kbd.add_hotkey("f7", lambda: self.root.after(0, self._save_debug))
            _kbd.add_hotkey("f8", lambda: self.root.after(0, self._run_lmb_test))
            logger.info("Global hotkeys registered (F6/F7/F8)")
        except Exception as e:
            logger.exception("hotkey registration failed: %s", e)
            self._gui_log(f"Не удалось установить глобальные хоткеи: {e}")

    # ---------- UI-тик ----------
    def _tick_ui(self):
        try:
            if self.enabled:
                self.status_lbl.config(text="РАБОТАЕТ", bg="#2a7")
            else:
                self.status_lbl.config(text="ОСТАНОВЛЕНО", bg="#a33")
        except Exception:
            pass
        self.root.after(300, self._tick_ui)

    # ---------- закрытие ----------
    def _on_close(self):
        self.enabled = False
        try:
            if self.worker:
                self.worker.stop()
        except Exception:
            pass
        if HAS_KEYBOARD:
            try:
                _kbd.unhook_all_hotkeys()
            except Exception:
                pass
        self.root.destroy()


# ------------------------------------------------------------------
# 9. main
# ------------------------------------------------------------------
def main():
    root = tk.Tk()
    App(root)
    root.mainloop()


if __name__ == "__main__":
    main()