diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..59ec56b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,85 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + workflow_dispatch: + +jobs: + test: + name: tests (${{ matrix.os }}, py${{ matrix.python-version }}) + runs-on: ${{ matrix.os }} + # Backstop: a GUI test that manages to open a modal dialog would otherwise hang until + # GitHub's 6-hour limit. The suite finishes in about a minute. + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest] + python-version: ["3.10", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + # The test suite deliberately needs no third-party package: it exercises the + # module-level helpers and the headless CLI, both of which degrade gracefully + # when pyautogui/keyboard are absent. That keeps CI to well under a minute. + - name: Install tkinter (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y python3-tk + + - name: Byte-compile every source file + run: python -m compileall -q AutoClicker.py lite-version.py packaging/ tests/ + + - name: Run tests + run: python -m unittest discover -s tests -t . -v + + - name: Smoke-test the headless CLI + run: | + python AutoClicker.py --version + python AutoClicker.py health --json + python AutoClicker.py state-summary --json + python AutoClicker.py schema action --json + python AutoClicker.py history --json + + - name: Unknown flags must fail rather than open a GUI + shell: bash + run: | + if python AutoClicker.py --definitely-not-a-flag; then + echo "Expected a usage error (exit 2) but the command succeeded." + exit 1 + fi + + secrets: + name: secret scan + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # A live Google service-account key was committed in this repo's history; this + # step exists so a replacement cannot land unnoticed. + - name: Fail on tracked credential files + run: | + if git ls-files | grep -Ei '(^|/)creds\.json$|\.pem$|\.key$'; then + echo "A credential file is tracked. Remove it and rotate the secret." + exit 1 + fi + echo "No tracked credential files." + + - name: Fail on private keys in the working tree + run: | + # Built from parts so this workflow file does not match its own pattern. + PATTERN="-----BEGIN .*PRIVATE"" KEY-----" + if grep -rIl --exclude-dir=.git -- "$PATTERN" . ; then + echo "A private key is present in the working tree." + exit 1 + fi + echo "No private keys found." diff --git a/.gitignore b/.gitignore index 3038181..2f4bf20 100644 --- a/.gitignore +++ b/.gitignore @@ -106,3 +106,12 @@ venv.bak/ # Local app state autoclicker_profiles.json autoclicker_workspace.json + +# Never commit service-account keys or any other secret payload. +creds.json +**/creds.json +*.pem +*.key + +# Local run log +autoclicker_runs.log diff --git a/AutoClicker.py b/AutoClicker.py index 77e52a6..e245e5c 100644 --- a/AutoClicker.py +++ b/AutoClicker.py @@ -16,12 +16,15 @@ import platform import warnings import shutil +import re IMPORT_ERRORS = {} -APP_VERSION = "V10.1" +APP_VERSION = "V11.0" APP_STATE_DIR_NAME = "AutoClicker" PROFILE_FILE_NAME = "autoclicker_profiles.json" WORKSPACE_FILE_NAME = "autoclicker_workspace.json" +RUN_LOG_FILE_NAME = "autoclicker_runs.log" +STATE_SCHEMA_VERSION = 2 DEFAULT_CLICK_TYPES = { "Left Click": ("left", 1), "Right Click": ("right", 1), @@ -30,6 +33,34 @@ "Double Right Click": ("right", 2), "Double Middle Click": ("middle", 2), } + +# Every action the run engine can emit. "click" entries reproduce DEFAULT_CLICK_TYPES exactly, +# so existing profiles, sequences and workspaces keep loading unchanged. +ACTION_REGISTRY = { + "Left Click": {"kind": "click", "button": "left", "clicks": 1}, + "Right Click": {"kind": "click", "button": "right", "clicks": 1}, + "Middle Click": {"kind": "click", "button": "middle", "clicks": 1}, + "Double Left Click": {"kind": "click", "button": "left", "clicks": 2}, + "Double Right Click": {"kind": "click", "button": "right", "clicks": 2}, + "Double Middle Click": {"kind": "click", "button": "middle", "clicks": 2}, + "Triple Left Click": {"kind": "click", "button": "left", "clicks": 3}, + "Key Press": {"kind": "key", "uses": ("action_key",)}, + "Key Hold": {"kind": "key_hold", "uses": ("action_key", "hold_duration")}, + "Type Text": {"kind": "text", "uses": ("action_text",)}, + "Scroll Up": {"kind": "scroll", "direction": 1, "uses": ("scroll_amount",)}, + "Scroll Down": {"kind": "scroll", "direction": -1, "uses": ("scroll_amount",)}, + "Click And Hold": {"kind": "hold", "button": "left", "uses": ("hold_duration",)}, + "Drag To": {"kind": "drag", "button": "left", "uses": ("drag_to_x", "drag_to_y", "hold_duration")}, + "Move Only": {"kind": "move"}, +} +ACTION_DEFAULTS = { + "action_key": "space", + "action_text": "", + "scroll_amount": 3, + "hold_duration": 0.25, + "drag_to_x": 0, + "drag_to_y": 0, +} PROFILE_FIELDS = { "target_x", "target_y", @@ -60,6 +91,15 @@ "dry_run", "pyautogui_failsafe", "theme", + "action_key", + "action_text", + "scroll_amount", + "hold_duration", + "drag_to_x", + "drag_to_y", + "pacing_mode", + "scheduled_start", + "target_cps", } PROFILE_INT_FIELDS = { "target_x", @@ -69,6 +109,9 @@ "max_actions", "micro_pause_every", "repeat_count", + "scroll_amount", + "drag_to_x", + "drag_to_y", } PROFILE_FLOAT_FIELDS = { "delay", @@ -78,6 +121,8 @@ "micro_pause_duration", "window_opacity", "ui_scale", + "hold_duration", + "target_cps", } PROFILE_BOOL_FIELDS = { "topmost", @@ -92,11 +137,16 @@ "pyautogui_failsafe", } PROFILE_ENUM_FIELDS = { - "click_mode": set(DEFAULT_CLICK_TYPES), + "click_mode": set(ACTION_REGISTRY), "repeat_mode": {"Infinite", "Burst Count"}, - "behaviour_preset": {"Balanced", "Precision", "Burst Sprint", "Human Mimic"}, - "theme": {"Light", "Dark", "Ocean"}, + "behaviour_preset": {"Balanced", "Precision", "Burst Sprint", "Human Mimic", "Feather Touch"}, + "theme": {"Light", "Dark", "Ocean", "Midnight", "System"}, + "pacing_mode": {"Precise", "Legacy V10.1"}, } +# The validator used to warn outside 0.2-1.0 / 0.5-2.0 while the runtime clamped to these +# tighter bounds, so a "valid" profile could still be silently changed on load. +WINDOW_OPACITY_RANGE = (0.70, 1.00) +UI_SCALE_RANGE = (0.90, 1.35) SAFETY_PRESETS = { "Simulation": { "dry_run": True, @@ -179,6 +229,11 @@ def _atomic_write_json(path, payload, sort_keys=False): try: with open(temp_path, "w", encoding="utf-8") as file_handle: json.dump(payload, file_handle, indent=2, sort_keys=sort_keys) + file_handle.flush() + try: + os.fsync(file_handle.fileno()) + except Exception: + pass os.replace(temp_path, path) except Exception: try: @@ -189,6 +244,328 @@ def _atomic_write_json(path, payload, sort_keys=False): raise +def _interruptible_sleep(duration, should_stop=None, slice_seconds=0.02, clock=None, sleeper=None): + """Sleep up to `duration`, checking `should_stop` between short slices. + + Returns True when the full duration elapsed and False when `should_stop` cut it short. + Sleep arguments are clamped to >= 0 so a descheduled thread can never hit + ValueError: sleep length must be non-negative. + """ + clock = clock or time.perf_counter + sleeper = sleeper or time.sleep + try: + duration = float(duration) + except Exception: + return True + if duration <= 0: + return not (should_stop is not None and should_stop()) + + slice_seconds = max(0.001, float(slice_seconds)) + wake_at = clock() + duration + while True: + if should_stop is not None and should_stop(): + return False + remaining = wake_at - clock() + if remaining <= 0: + return True + sleeper(max(0.0, min(slice_seconds, remaining))) + + +def _resolve_action(action_name, config=None, registry=None): + """Describe an action as {kind, callable name, kwargs} without touching pyautogui. + + Keeping dispatch declarative means every action the engine can emit is verifiable + headlessly, on machines with no display and no pyautogui installed. + """ + registry = registry if registry is not None else ACTION_REGISTRY + if action_name not in registry: + raise ValueError(f"Unknown action type: {action_name!r}") + spec = registry[action_name] + config = config or {} + + def setting(name): + value = config.get(name, ACTION_DEFAULTS.get(name)) + return ACTION_DEFAULTS.get(name) if value is None else value + + kind = spec["kind"] + x_pos, y_pos = config.get("x", 0), config.get("y", 0) + + if kind == "click": + return {"kind": kind, "call": "click", + "kwargs": {"x": x_pos, "y": y_pos, "button": spec["button"], "clicks": spec["clicks"]}} + if kind == "move": + return {"kind": kind, "call": "moveTo", "kwargs": {"x": x_pos, "y": y_pos}} + if kind == "key": + key_name = str(setting("action_key")).strip() + if not key_name: + raise ValueError("Key Press needs a key name (for example: space, enter, f5, a).") + return {"kind": kind, "call": "press", "kwargs": {"keys": key_name}} + if kind == "key_hold": + key_name = str(setting("action_key")).strip() + if not key_name: + raise ValueError("Key Hold needs a key name (for example: shift, w, ctrl).") + return {"kind": kind, "call": "keyDown/keyUp", + "kwargs": {"key": key_name, "hold_duration": max(0.0, float(setting("hold_duration")))}} + if kind == "text": + text_value = str(setting("action_text")) + if not text_value: + raise ValueError("Type Text needs some text to type.") + return {"kind": kind, "call": "write", "kwargs": {"message": text_value}} + if kind == "scroll": + magnitude = abs(int(setting("scroll_amount"))) + if magnitude == 0: + raise ValueError("Scroll amount must not be zero.") + return {"kind": kind, "call": "scroll", + "kwargs": {"clicks": magnitude * spec["direction"], "x": x_pos, "y": y_pos}} + if kind == "hold": + return {"kind": kind, "call": "mouseDown/mouseUp", + "kwargs": {"x": x_pos, "y": y_pos, "button": spec["button"], + "hold_duration": max(0.0, float(setting("hold_duration")))}} + if kind == "drag": + return {"kind": kind, "call": "mouseDown/moveTo/mouseUp", + "kwargs": {"x": x_pos, "y": y_pos, + "to_x": int(setting("drag_to_x")), "to_y": int(setting("drag_to_y")), + "button": spec["button"], + "hold_duration": max(0.0, float(setting("hold_duration")))}} + raise ValueError(f"Unsupported action kind: {kind!r}") + + +def _action_moves_pointer(action_name, registry=None): + """True when the action is anchored to the target coordinate.""" + registry = registry if registry is not None else ACTION_REGISTRY + spec = registry.get(action_name) or {} + return spec.get("kind") not in ("key", "key_hold", "text") + + +def _cps_to_delay(cps): + """Convert a clicks-per-second target into a per-action delay in seconds.""" + try: + cps = float(cps) + except Exception: + return None + if cps <= 0: + return None + return 1.0 / cps + + +def _delay_to_cps(delay): + """Convert a per-action delay into clicks per second. Zero delay means unbounded.""" + try: + delay = float(delay) + except Exception: + return None + if delay <= 0: + return None + return 1.0 / delay + + +def _rate_stats(samples, window_seconds=3.0): + """Summarise (timestamp, cumulative_actions) samples into instant/average/peak rates.""" + points = [(float(t), int(n)) for t, n in (samples or [])] + if len(points) < 2: + return {"instant_cps": 0.0, "average_cps": 0.0, "peak_cps": 0.0, "samples": len(points)} + + first_time, first_count = points[0] + last_time, last_count = points[-1] + span = last_time - first_time + average = (last_count - first_count) / span if span > 0 else 0.0 + + window_start = last_time - max(0.001, float(window_seconds)) + windowed = [p for p in points if p[0] >= window_start] or points[-2:] + window_span = windowed[-1][0] - windowed[0][0] + instant = (windowed[-1][1] - windowed[0][1]) / window_span if window_span > 0 else average + + peak = 0.0 + for (t0, n0), (t1, n1) in zip(points, points[1:]): + step = t1 - t0 + if step > 0: + peak = max(peak, (n1 - n0) / step) + + return { + "instant_cps": round(instant, 3), + "average_cps": round(average, 3), + "peak_cps": round(peak, 3), + "samples": len(points), + } + + +def _effective_action_period(delay, pacing_mode="Precise", human_like=False, library_pause=0.1): + """Predict the real seconds-per-action, including PyAutoGUI's own inter-call pause. + + In Legacy V10.1 mode pyautogui.PAUSE is left at its default, so each emitted call + silently adds `library_pause`; the UI used to promise the raw delay and be wrong by 2-3x. + """ + try: + delay = max(0.0, float(delay)) + except Exception: + delay = 0.0 + if pacing_mode == "Precise": + overhead = 0.0 + else: + overhead = float(library_pause) * (2 if human_like else 1) + if human_like: + overhead += 0.03 # average of the 0.01-0.05s humanised moveTo duration + return delay + overhead + + +def _validate_hotkey(hotkey, parser=None): + """Check a hotkey string is one `keyboard` can actually poll. + + `keyboard.is_pressed` raises for multi-step hotkeys ("a, b") and unmapped key names, + and the engine used to swallow that, leaving a run with a silently inert stop key. + """ + hotkey = str(hotkey or "").strip() + if not hotkey: + return {"valid": False, "hotkey": "", "reason": "No hotkey set."} + if "," in hotkey: + return { + "valid": False, + "hotkey": hotkey, + "reason": "Multi-step hotkeys (\"a, b\") cannot be polled; use a combination like ctrl+k.", + } + if parser is None: + parser = globals().get("keyboard") + parser = getattr(parser, "parse_hotkey", None) if parser else None + if parser is None: + return {"valid": True, "hotkey": hotkey, "reason": "Not verified: keyboard support is unavailable."} + try: + parser(hotkey) + except Exception as exc: + return {"valid": False, "hotkey": hotkey, "reason": f"'{hotkey}' is not a key this system recognises ({exc})."} + return {"valid": True, "hotkey": hotkey, "reason": f"Stop hotkey is {hotkey}."} + + +def _clamp_geometry_to_screen(geometry, screen_width, screen_height, margin=80): + """Keep a restored "WxH+X+Y" geometry on-screen. + + A layout saved on a second monitor used to be restored verbatim, putting the + window somewhere the user could not reach it. + """ + text = str(geometry or "").strip() + match = re.match(r"^(\d+)x(\d+)([+-]-?\d+)([+-]-?\d+)$", text) + if not match: + return text + width, height = int(match.group(1)), int(match.group(2)) + x_pos, y_pos = int(match.group(3)), int(match.group(4)) + + width = max(200, min(width, int(screen_width))) + height = max(200, min(height, int(screen_height))) + x_pos = max(0, min(x_pos, max(0, int(screen_width) - margin))) + y_pos = max(0, min(y_pos, max(0, int(screen_height) - margin))) + return f"{width}x{height}+{x_pos}+{y_pos}" + + +def _detect_system_theme(reader=None): + """Resolve the OS appearance preference to "Light" or "Dark". + + `reader` lets tests inject a value; on Windows the real source is the + AppsUseLightTheme registry value under Personalize. + """ + if reader is not None: + try: + return "Dark" if not reader() else "Light" + except Exception: + return "Light" + try: + import winreg + + key_path = r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize" + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path) as key: + apps_use_light, _ = winreg.QueryValueEx(key, "AppsUseLightTheme") + return "Light" if apps_use_light else "Dark" + except Exception: + return "Light" + + +def _rotate_run_log(log_path, keep=1): + """Roll `x.log` to `x.log.1` so the live log never grows without bound.""" + try: + oldest = f"{log_path}.{keep}" + if os.path.exists(oldest): + os.remove(oldest) + for index in range(keep - 1, 0, -1): + source = f"{log_path}.{index}" + if os.path.exists(source): + os.replace(source, f"{log_path}.{index + 1}") + if os.path.exists(log_path): + os.replace(log_path, f"{log_path}.1") + return True + except Exception: + return False + + +def _read_run_log(log_path, limit=200): + """Read the newest `limit` run records back out of the JSON-lines run log.""" + records = [] + if not log_path or not os.path.exists(log_path): + return records + try: + with open(log_path, "r", encoding="utf-8") as handle: + for line in handle: + line = line.strip() + if not line: + continue + try: + records.append(json.loads(line)) + except Exception: + continue + except Exception: + return records + return records[-limit:] if limit else records + + +def _summarize_run_history(records): + """Aggregate run records into lifetime totals the dashboard can show.""" + records = [r for r in (records or []) if isinstance(r, dict)] + total_actions = sum(int(r.get("actions", 0) or 0) for r in records) + total_seconds = sum(float(r.get("elapsed_seconds", 0) or 0) for r in records) + live = [r for r in records if not r.get("dry_run")] + reasons = {} + for record in records: + reason = str(record.get("stop_reason", "unknown")) + reasons[reason] = reasons.get(reason, 0) + 1 + return { + "runs": len(records), + "live_runs": len(live), + "dry_runs": len(records) - len(live), + "actions": total_actions, + "seconds": round(total_seconds, 3), + "average_cps": round(total_actions / total_seconds, 3) if total_seconds > 0 else 0.0, + "stop_reasons": reasons, + "last_run": records[-1] if records else None, + } + + +def _parse_scheduled_start(value, now=None): + """Resolve a HH:MM / HH:MM:SS wall-clock start into seconds from `now`. + + A time earlier than `now` means tomorrow, so "start at 09:00" set in the evening waits. + """ + text = str(value or "").strip() + if not text: + return {"scheduled": False, "delay_seconds": 0.0, "detail": "No scheduled start."} + for fmt in ("%H:%M:%S", "%H:%M"): + try: + parsed = datetime.datetime.strptime(text, fmt) + break + except ValueError: + parsed = None + if parsed is None: + return {"scheduled": False, "delay_seconds": 0.0, "error": f"'{text}' is not a HH:MM or HH:MM:SS time."} + + now = now or datetime.datetime.now() + target = now.replace(hour=parsed.hour, minute=parsed.minute, second=parsed.second, microsecond=0) + if target <= now: + target += datetime.timedelta(days=1) + delay_seconds = (target - now).total_seconds() + return { + "scheduled": True, + "delay_seconds": round(delay_seconds, 3), + "start_at": target.isoformat(timespec="seconds"), + "detail": f"Waiting {_format_seconds(delay_seconds)} until {target.strftime('%H:%M:%S')}.", + } + + def _format_seconds(seconds): try: seconds = float(seconds) @@ -245,16 +622,48 @@ def add(label, state, detail): add("Stop boundary", "review", "No runtime or action cap is set.") delay = float(config.get("delay", 0)) + pacing_mode = config.get("pacing_mode", "Precise") + effective_period = _effective_action_period(delay, pacing_mode, config.get("human_like")) if delay == 0: add("Pace", "review", "Zero delay uses maximum available pace.") + elif abs(effective_period - delay) > 0.005: + add( + "Pace", + "review", + f"Base delay is {_format_seconds(delay)} but {pacing_mode} pacing makes each action take " + f"about {effective_period:.3f}s ({_delay_to_cps(effective_period) or 0:.1f}/sec).", + ) else: - add("Pace", "ok", f"Base delay is {_format_seconds(delay)}.") + add("Pace", "ok", f"Base delay is {_format_seconds(delay)} (about {_delay_to_cps(delay) or 0:.1f} action(s)/sec).") + # A hotkey that `keyboard` cannot poll used to be reported green while being inert. + hotkey_check = _validate_hotkey(config.get("stop_hotkey")) if config.get("stop_hotkey"): - add("Stop hotkey", "ok", f"Stop hotkey is {config['stop_hotkey']}.") + if hotkey_check["valid"]: + add("Stop hotkey", "ok", hotkey_check["reason"]) + else: + add("Stop hotkey", "review", hotkey_check["reason"]) elif repeat_limit is None and runtime_limit == 0 and max_actions == 0: add("Stop hotkey", "review", "Continuous run has no stop hotkey.") + action_name = config.get("click_mode") + if action_name: + try: + _resolve_action(action_name, config) + add("Action", "ok", f"{action_name} is configured correctly.") + except Exception as exc: + add("Action", "review", str(exc)) + + schedule = config.get("schedule") or {} + if schedule.get("error"): + add("Scheduled start", "review", schedule["error"]) + elif schedule.get("scheduled"): + add("Scheduled start", "ok", schedule.get("detail", "Scheduled.")) + + targets = config.get("targets") or [] + if len(targets) > 1: + add("Targets", "ok", f"Round-robin cycles {len(targets)} recorded point(s).") + review_count = sum(1 for item in items if item["state"] == "review") status = "Ready" if review_count == 0 else f"Review {review_count} item(s)" return { @@ -370,34 +779,55 @@ def _format_profile_state_text(profile_state): winsound = None -def _collect_headless_health_data(): +DEPENDENCY_CATALOGUE = { + "pyautogui": {"module": "pyautogui", "required": True, "unlocks": "click, scroll, key and drag output"}, + "keyboard": {"module": "keyboard", "required": True, "unlocks": "stop hotkey, global hotkeys, point recording"}, + "pystray": {"module": "pystray", "required": False, "unlocks": "close-to-tray and the tray menu"}, + "Pillow": {"module": "PIL", "required": False, "unlocks": "Photo Clicker previews and screen sampling"}, + "numpy": {"module": "numpy", "required": False, "unlocks": "fast Colour Clicker region scanning"}, + "opencv-python": {"module": "cv2", "required": False, "unlocks": "Photo Clicker confidence matching"}, + "win10toast": {"module": "win10toast", "required": False, "unlocks": "Windows toast notifications"}, +} + + +def _collect_dependency_health(catalogue=None): + """Resolve every catalogued dependency to an availability record. Pure and headless.""" import importlib.util - dependency_names = { - "pyautogui": "pyautogui", - "keyboard": "keyboard", - "pystray": "pystray", - "Pillow": "PIL", - "numpy": "numpy", - "pywin32": "win32api", - } + catalogue = catalogue if catalogue is not None else DEPENDENCY_CATALOGUE dependencies = {} - for dependency_name, module_name in dependency_names.items(): + for dependency_name, spec in catalogue.items(): + module_name = spec["module"] if dependency_name in IMPORT_ERRORS: - dependencies[dependency_name] = { - "available": False, - "detail": IMPORT_ERRORS[dependency_name], - } - elif importlib.util.find_spec(module_name) is None: - dependencies[dependency_name] = { - "available": False, - "detail": "module not found", - } + available, detail = False, IMPORT_ERRORS[dependency_name] else: - dependencies[dependency_name] = { - "available": True, - "detail": "available", - } + try: + found = importlib.util.find_spec(module_name) is not None + except Exception as exc: + found, detail = False, f"probe failed: {exc}" + else: + detail = "available" if found else "module not found" + available = found + dependencies[dependency_name] = { + "available": available, + "detail": detail, + "required": bool(spec.get("required")), + "unlocks": spec.get("unlocks", ""), + } + return dependencies + + +def _missing_required_dependencies(dependencies): + """Names of required dependencies that are unavailable, sorted for stable output.""" + return sorted( + name + for name, data in (dependencies or {}).items() + if data.get("required") and not data.get("available") + ) + + +def _collect_headless_health_data(): + dependencies = _collect_dependency_health() profile_file = _state_file_location(PROFILE_FILE_NAME) workspace_file = _state_file_location(WORKSPACE_FILE_NAME) @@ -407,6 +837,7 @@ def _collect_headless_health_data(): "python": sys.version.split()[0], "resource_root": os.path.dirname(_resource_path("favicon.ico")), "dependencies": dependencies, + "missing_required": _missing_required_dependencies(dependencies), "state_files": { "profiles": { "path": profile_file, @@ -424,10 +855,17 @@ def _build_headless_health_report(): health_data = _collect_headless_health_data() dependency_lines = [] for dependency_name, dependency_data in health_data["dependencies"].items(): + tier = "required" if dependency_data.get("required") else "optional" if dependency_data["available"]: - dependency_lines.append(f"- {dependency_name}: available") + dependency_lines.append(f"- {dependency_name} ({tier}): available") else: - dependency_lines.append(f"- {dependency_name}: missing ({dependency_data['detail']})") + unlocks = dependency_data.get("unlocks") or "extra features" + dependency_lines.append( + f"- {dependency_name} ({tier}): missing ({dependency_data['detail']}) - unlocks {unlocks}" + ) + missing_required = health_data.get("missing_required") or [] + if missing_required: + dependency_lines.append(f"- ACTION: pip install {' '.join(missing_required)}") profile_file = health_data["state_files"]["profiles"] workspace_file = health_data["state_files"]["workspace"] @@ -506,7 +944,9 @@ def _normalize_recording_points(raw_points, limit=200, strict=True): def _normalize_sequence_steps(raw_steps, click_types=None): - click_types = click_types or DEFAULT_CLICK_TYPES + # Defaults to the full action registry so sequences can use keys, scrolls and drags, + # not just the six original click types. + click_types = click_types or ACTION_REGISTRY if not isinstance(raw_steps, list): raise ValueError("Sequence files must contain a list of steps.") @@ -741,7 +1181,7 @@ def _validate_sequence_file(file_path, click_types=None): def _validate_profile_data(profile_name, profile_data, click_types=None): - click_types = click_types or DEFAULT_CLICK_TYPES + click_types = click_types or ACTION_REGISTRY enum_fields = dict(PROFILE_ENUM_FIELDS) enum_fields["click_mode"] = set(click_types) @@ -790,10 +1230,16 @@ def _validate_profile_data(profile_name, profile_data, click_types=None): "micro_pause_duration", } and value < 0: result["errors"].append(f"{field_name} must be zero or greater") - if field_name == "window_opacity" and not 0.2 <= value <= 1.0: - result["warnings"].append("window_opacity is outside the normal 0.2-1.0 range") - if field_name == "ui_scale" and not 0.5 <= value <= 2.0: - result["warnings"].append("ui_scale is outside the normal 0.5-2.0 range") + if field_name == "window_opacity" and not WINDOW_OPACITY_RANGE[0] <= value <= WINDOW_OPACITY_RANGE[1]: + result["warnings"].append( + f"window_opacity is outside the supported {WINDOW_OPACITY_RANGE[0]:.2f}-" + f"{WINDOW_OPACITY_RANGE[1]:.2f} range and will be clamped on load" + ) + if field_name == "ui_scale" and not UI_SCALE_RANGE[0] <= value <= UI_SCALE_RANGE[1]: + result["warnings"].append( + f"ui_scale is outside the supported {UI_SCALE_RANGE[0]:.2f}-" + f"{UI_SCALE_RANGE[1]:.2f} range and will be clamped on load" + ) for field_name in PROFILE_BOOL_FIELDS & set(profile_data): if not isinstance(profile_data[field_name], bool): @@ -932,17 +1378,59 @@ def fit_shell_width(event=None): window.after_idle(fit_shell_width) return shell, canvas +_WIN10TOAST_CACHE = [] + + +def _load_win10toast(): + """Import win10toast on first use; it drags in pkg_resources and costs ~1s.""" + if _WIN10TOAST_CACHE: + return _WIN10TOAST_CACHE[0] + module = None + try: + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", message="pkg_resources is deprecated as an API.*", category=UserWarning) + import win10toast as _win10toast # This module only works on Windows. + module = _win10toast + except Exception as exc: + IMPORT_ERRORS.setdefault("win10toast", str(exc)) + _WIN10TOAST_CACHE.append(module) + return module + + +################################################################################ +# /\ | | | |__ __/ __ \ / ____| | |_ _/ ____| |/ / ____| __ \ # +# / \ | | | | | | | | | | | | | | | || | | ' /| |__ | |__) |# +# / /\ \| | | | | | | | | | | | | | | || | | < | __| | _ / # +# / ____ \ |__| | | | | |__| | | |____| |____ _| || |____| . \| |____| | \ \ # +# /_/ \_\____/ |_| \____/ \_____|______|_____\_____|_|\_\______|_| \_\# +################################################################################ + def Photo_Clicker(): - """Public entry point — opens the Photo Clicker window.""" + """Public entry point for the upgraded Photo Clicker.""" if not _ensure_dependencies("Photo Clicker", ["pyautogui", "keyboard"]): return + # Pillow drives the thumbnail preview; without it the window opens with a dead panel. + if "Pillow" in IMPORT_ERRORS: + _show_dependency_error("Photo Clicker preview", ["Pillow"]) + + _BG = "#edf0f7" + _CARD_BG = "#f8fafc" + _BORDER = "#bfd0e5" + _HERO_BG = "#1e293b" + _HERO_FG = "#f8fafc" + _HERO_SUB = "#94a3b8" + _LABEL_FG = "#0f172a" + _ACCENT = "#0f766e" + _DANGER = "#b91c1c" + _STATUS_BG = "#0f172a" + _STATUS_FG = "#cbd5e1" owner = tk._default_root win = tk.Toplevel(owner) if owner else tk.Tk() win.title("Photo Clicker") - win.geometry("680x620+280+100") - win.minsize(680, 620) + win.geometry("760x700+280+100") + win.minsize(700, 620) win.attributes("-topmost", True) win.resizable(True, True) win.configure(bg=_BG) @@ -951,7 +1439,6 @@ def Photo_Clicker(): except Exception: pass - # ── state ──────────────────────────────────────────────────────────── state = { "thread": None, "stop_event": threading.Event(), @@ -960,48 +1447,53 @@ def Photo_Clicker(): } ui_queue = queue.Queue() - # ── variables ──────────────────────────────────────────────────────── - filepath_var = tk.StringVar(value="No image selected") - confidence_var = tk.StringVar(value="0.8") - mode_var = tk.StringVar(value="Click Once") - burst_var = tk.StringVar(value="5") - interval_var = tk.StringVar(value="1.00") - button_var = tk.StringVar(value="Left Click") - hotkey_var = tk.StringVar(value="esc") - grayscale_var = tk.BooleanVar(value=False) - region_var = tk.StringVar(value="Full Screen") - rx1_var = tk.StringVar(value="0") - ry1_var = tk.StringVar(value="0") - rx2_var = tk.StringVar(value="1920") - ry2_var = tk.StringVar(value="1080") - status_var = tk.StringVar(value="Select an image to locate on screen.") - stats_var = tk.StringVar(value="No scans yet.") + filepath_var = tk.StringVar(value="No image selected") + confidence_var = tk.StringVar(value="0.80") + mode_var = tk.StringVar(value="Click Once") + burst_var = tk.StringVar(value="5") + interval_var = tk.StringVar(value="1.00") + button_var = tk.StringVar(value="Left Click") + hotkey_var = tk.StringVar(value="esc") + grayscale_var = tk.BooleanVar(value=False) + region_var = tk.StringVar(value="Full Screen") + preset_var = tk.StringVar(value="Balanced") + focus_size_var = tk.StringVar(value="420") + offset_x_var = tk.StringVar(value="0") + offset_y_var = tk.StringVar(value="0") + settle_delay_var = tk.StringVar(value="0.00") + rx1_var = tk.StringVar(value="0") + ry1_var = tk.StringVar(value="0") + rx2_var = tk.StringVar(value="1920") + ry2_var = tk.StringVar(value="1080") + beep_var = tk.BooleanVar(value=False) + status_var = tk.StringVar(value="Select an image to locate on screen.") + stats_var = tk.StringVar(value="No scans yet.") + region_summary_var = tk.StringVar(value="Full screen region ready.") try: - sw, sh = pyautogui.size() - rx2_var.set(str(sw)) - ry2_var.set(str(sh)) + screen_width, screen_height = pyautogui.size() + rx2_var.set(str(screen_width)) + ry2_var.set(str(screen_height)) except Exception: - pass + screen_width, screen_height = 1920, 1080 - # ── helpers ────────────────────────────────────────────────────────── - def _set_status(msg): + def set_status(message): if threading.current_thread() is threading.main_thread(): - status_var.set(msg) + status_var.set(message) else: - ui_queue.put(("status", msg)) + ui_queue.put(("status", message)) - def _set_stats(msg): + def set_stats(message): if threading.current_thread() is threading.main_thread(): - stats_var.set(msg) + stats_var.set(message) else: - ui_queue.put(("stats", msg)) + ui_queue.put(("stats", message)) - def _set_controls_running(running): + def set_controls_running(running): start_btn.configure(state=DISABLED if running else NORMAL) stop_btn.configure(state=NORMAL if running else DISABLED) - def _pump_queue(): + def pump_queue(): try: while True: action, payload = ui_queue.get_nowait() @@ -1011,184 +1503,344 @@ def _pump_queue(): stats_var.set(payload) elif action == "finish": state["thread"] = None - _set_controls_running(False) + set_controls_running(False) except queue.Empty: pass try: if win.winfo_exists(): - win.after(100, _pump_queue) + win.after(100, pump_queue) except tk.TclError: pass - def _stop_check(): + def stop_check(): if state["stop_event"].is_set(): return True - if keyboard and hotkey_var.get().strip(): + stop_hotkey = hotkey_var.get().strip() + if stop_hotkey and keyboard: try: - if keyboard.is_pressed(hotkey_var.get().strip()): + if keyboard.is_pressed(stop_hotkey): state["stop_event"].set() - _set_status(f"Stop hotkey '{hotkey_var.get().strip()}' pressed.") + set_status(f"Stop hotkey '{stop_hotkey}' pressed.") return True except Exception: pass return False - # ── commands ───────────────────────────────────────────────────────── - def pick_image(): - path = filedialog.askopenfilename( - title="Select reference image", - filetypes=[ - ("Image files", "*.png *.jpg *.jpeg *.bmp *.gif *.tiff"), - ("All files", "*.*"), - ], - parent=win, - ) - if not path: + def update_region_summary(*_args): + if region_var.get() != "Custom Region": + region_summary_var.set("Full screen region ready.") return - state["image_path"] = path - filepath_var.set(os.path.basename(path)) - _set_status(f"Loaded: {os.path.basename(path)}") - - # thumbnail preview - if Image and ImageTk: - try: - img = Image.open(path) - img.thumbnail((160, 120), Image.LANCZOS) - photo = ImageTk.PhotoImage(img) - state["thumbnail"] = photo # prevent GC - thumb_label.configure(image=photo, text="") - except Exception: - thumb_label.configure(image="", text="(preview unavailable)") - else: - thumb_label.configure(image="", text="(Pillow not installed)") - - def _worker(image_path, conf, mode, burst_count, interval_sec, - btn, clicks, use_grayscale, region): try: - total_found = 0 - total_clicked = 0 - started = time.perf_counter() - - iterations = 0 - while not _stop_check(): - iterations += 1 - - try: - loc = pyautogui.locateOnScreen( - image_path, - confidence=conf, - grayscale=use_grayscale, - region=region, - ) - except pyautogui.ImageNotFoundException: - loc = None - except Exception as exc: - _set_status(f"Scan error: {exc}") - loc = None - - elapsed = time.perf_counter() - started + x1 = int(rx1_var.get()) + y1 = int(ry1_var.get()) + x2 = int(rx2_var.get()) + y2 = int(ry2_var.get()) + width = abs(x2 - x1) + height = abs(y2 - y1) + if width == 0 or height == 0: + raise ValueError + region_summary_var.set( + f"Custom region {width} x {height} at {min(x1, x2)}, {min(y1, y2)}." + ) + except Exception: + region_summary_var.set("Custom region pending valid coordinates.") - if loc is not None: - total_found += 1 - cx, cy = pyautogui.center(loc) - pyautogui.click(x=cx, y=cy, button=btn, clicks=clicks, - interval=0.02 if clicks > 1 else 0.0) - total_clicked += 1 - _set_status(f"Clicked image centre at ({cx}, {cy}).") - _set_stats( - f"Scan #{iterations} | found {total_found}x " - f"| clicked {total_clicked}x | {elapsed:.2f}s elapsed" - ) - else: - _set_status(f"Image not found on scan #{iterations}.") - _set_stats( - f"Scan #{iterations} | found {total_found}x " - f"| clicked {total_clicked}x | {elapsed:.2f}s elapsed" - ) + def toggle_region(*_args): + is_custom = region_var.get() == "Custom Region" + for widget in (rx1_entry, ry1_entry, rx2_entry, ry2_entry): + widget.configure(state=NORMAL if is_custom else DISABLED) + update_region_summary() - # exit conditions - if mode == "Click Once": - break - elif mode == "Burst Count": - if total_clicked >= burst_count: - _set_status( - f"Burst complete: {total_clicked} click(s) in " - f"{elapsed:.2f}s." - ) - break - # continuous watch — loop again after interval - if not _stop_check() and mode != "Click Once": - state["stop_event"].wait(interval_sec) + def toggle_burst(*_args): + burst_entry.configure(state=NORMAL if mode_var.get() == "Burst Count" else DISABLED) - except Exception as exc: - _set_status(f"Worker error: {exc}") - finally: - ui_queue.put(("finish", None)) + def set_full_screen_region(set_mode=False): + rx1_var.set("0") + ry1_var.set("0") + rx2_var.set(str(screen_width)) + ry2_var.set(str(screen_height)) + if set_mode: + region_var.set("Full Screen") + toggle_region() + set_status("Region reset to the full screen.") - def start_scan(): - if state["thread"] and state["thread"].is_alive(): - _set_status("A scan is already running.") - return - if not state["image_path"]: - messagebox.showerror("No image", "Select a reference image first.", - parent=win) + def focus_region_near_cursor(): + try: + focus_size = max(40, int(focus_size_var.get())) + except ValueError: + messagebox.showerror("Invalid focus size", "Focus size must be a whole number.", parent=win) return try: - conf = float(confidence_var.get()) - if not 0 < conf <= 1: - raise ValueError - except ValueError: - messagebox.showerror("Invalid confidence", - "Confidence must be between 0 and 1.", - parent=win) + cursor_x, cursor_y = pyautogui.position() + except Exception as exc: + messagebox.showerror("Capture failed", f"Unable to read the cursor position.\n{exc}", parent=win) return - click_map = { - "Left Click": ("left", 1), - "Right Click": ("right", 1), - "Middle Click": ("middle", 1), - "Double Left Click": ("left", 2), - "Double Right Click": ("right", 2), + half_size = focus_size // 2 + x1 = max(0, cursor_x - half_size) + y1 = max(0, cursor_y - half_size) + x2 = min(screen_width, cursor_x + half_size) + y2 = min(screen_height, cursor_y + half_size) + rx1_var.set(str(x1)) + ry1_var.set(str(y1)) + rx2_var.set(str(x2)) + ry2_var.set(str(y2)) + region_var.set("Custom Region") + toggle_region() + set_status(f"Focused the scan region around cursor {cursor_x}, {cursor_y}.") + + def open_image_folder(): + image_path = state["image_path"] + if not image_path: + messagebox.showinfo("Photo Clicker", "Choose an image first.", parent=win) + return + try: + os.startfile(os.path.dirname(image_path)) + except Exception as exc: + messagebox.showerror("Open folder failed", f"Unable to open the image folder.\n{exc}", parent=win) + + def apply_scan_preset(*_args): + preset_name = preset_var.get() + preset_map = { + "Balanced": ("0.80", "1.00", False, "Click Once", "5"), + "Precision Hunt": ("0.90", "1.20", False, "Click Once", "3"), + "Fast Watch": ("0.72", "0.25", True, "Continuous Watch", "5"), + "Burst Sweep": ("0.78", "0.40", True, "Burst Count", "8"), } - btn_info = click_map.get(button_var.get(), ("left", 1)) + confidence, interval, grayscale, mode_name, burst_total = preset_map.get( + preset_name, + preset_map["Balanced"], + ) + confidence_var.set(confidence) + interval_var.set(interval) + grayscale_var.set(grayscale) + mode_var.set(mode_name) + burst_var.set(burst_total) + toggle_burst() + set_status(f"Preset '{preset_name}' applied.") + + def pick_image(): + path = filedialog.askopenfilename( + title="Select reference image", + filetypes=[ + ("Image files", "*.png *.jpg *.jpeg *.bmp *.gif *.tiff"), + ("All files", "*.*"), + ], + parent=win, + ) + if not path: + return + + state["image_path"] = path + filepath_var.set(os.path.basename(path)) + set_status(f"Loaded reference image {os.path.basename(path)}.") + + if Image and ImageTk: + try: + image = Image.open(path) + image.thumbnail((180, 130), Image.LANCZOS) + preview = ImageTk.PhotoImage(image) + state["thumbnail"] = preview + thumb_label.configure(image=preview, text="") + except Exception: + thumb_label.configure(image="", text="(preview unavailable)") + else: + thumb_label.configure(image="", text="(Pillow not installed)") + + def parse_region(): + if region_var.get() != "Custom Region": + return None + try: + x1 = int(rx1_var.get()) + y1 = int(ry1_var.get()) + x2 = int(rx2_var.get()) + y2 = int(ry2_var.get()) + except ValueError: + raise ValueError("Region coordinates must be whole numbers.") + + left = min(x1, x2) + top = min(y1, y2) + width = abs(x2 - x1) + height = abs(y2 - y1) + if width == 0 or height == 0: + raise ValueError("Custom region must have non-zero width and height.") + return (left, top, width, height) + + def worker( + image_path, + confidence, + mode_name, + burst_count, + interval_seconds, + button_name, + click_count, + use_grayscale, + region, + offset_x, + offset_y, + settle_delay, + should_beep, + ): + try: + total_found = 0 + total_clicked = 0 + started_at = time.perf_counter() + iteration = 0 + + while not stop_check(): + iteration += 1 + try: + location = pyautogui.locateOnScreen( + image_path, + confidence=confidence, + grayscale=use_grayscale, + region=region, + ) + except Exception as exc: + if exc.__class__.__name__ == "ImageNotFoundException": + location = None + elif "confidence" in str(exc).lower() and "opencv" in str(exc).lower(): + # PyAutoGUI needs OpenCV for confidence matching. Say so plainly + # instead of leaking its internal message, and stop the scan + # rather than repeating an error that cannot resolve itself. + set_status( + "Confidence matching needs OpenCV. Install it with: " + "pip install opencv-python" + ) + break + else: + set_status(f"Scan error: {exc}") + location = None + + elapsed = time.perf_counter() - started_at + if location is None: + set_status(f"Image not found on scan #{iteration}.") + set_stats( + f"Scan #{iteration} | found {total_found}x | clicked {total_clicked}x | {elapsed:.2f}s elapsed" + ) + else: + total_found += 1 + centre_x, centre_y = pyautogui.center(location) + click_x = int(centre_x + offset_x) + click_y = int(centre_y + offset_y) + if settle_delay > 0 and state["stop_event"].wait(settle_delay): + break + pyautogui.click( + x=click_x, + y=click_y, + button=button_name, + clicks=click_count, + interval=0.02 if click_count > 1 else 0.0, + ) + total_clicked += 1 + if should_beep and winsound: + try: + winsound.Beep(980, 60) + except Exception: + pass + set_status( + f"Match found at {centre_x}, {centre_y}. Clicked {click_x}, {click_y}." + ) + set_stats( + f"Scan #{iteration} | found {total_found}x | clicked {total_clicked}x | {elapsed:.2f}s elapsed" + ) + + if mode_name == "Click Once": + break + if mode_name == "Burst Count" and total_clicked >= burst_count: + set_status(f"Burst complete after {total_clicked} click(s).") + break + if mode_name != "Click Once" and not stop_check(): + state["stop_event"].wait(interval_seconds) + except Exception as exc: + set_status(f"Worker error: {exc}") + finally: + ui_queue.put(("finish", None)) + + def start_scan(): + if state["thread"] and state["thread"].is_alive(): + set_status("A scan is already running.") + return + if not state["image_path"]: + messagebox.showerror("No image", "Select a reference image first.", parent=win) + return + + try: + confidence = float(confidence_var.get()) + if not 0 < confidence <= 1: + raise ValueError + except ValueError: + messagebox.showerror("Invalid confidence", "Confidence must be between 0 and 1.", parent=win) + return + + try: + interval_seconds = float(interval_var.get()) + if interval_seconds < 0: + raise ValueError + except ValueError: + messagebox.showerror("Invalid interval", "Scan interval must be zero or greater.", parent=win) + return - mode = mode_var.get() try: - burst_count = int(burst_var.get()) if mode == "Burst Count" else 1 + settle_delay = float(settle_delay_var.get()) + if settle_delay < 0: + raise ValueError except ValueError: - burst_count = 1 + messagebox.showerror("Invalid settle delay", "Settle delay must be zero or greater.", parent=win) + return try: - interval_sec = float(interval_var.get()) + offset_x = int(offset_x_var.get()) + offset_y = int(offset_y_var.get()) except ValueError: - interval_sec = 1.0 + messagebox.showerror("Invalid click offset", "Offset values must be whole numbers.", parent=win) + return - use_grayscale = grayscale_var.get() + mode_name = mode_var.get() + try: + burst_count = int(burst_var.get()) if mode_name == "Burst Count" else 1 + if burst_count < 1: + raise ValueError + except ValueError: + messagebox.showerror("Invalid burst count", "Burst count must be at least 1.", parent=win) + return - region = None - if region_var.get() == "Custom Region": - try: - rx1 = int(rx1_var.get()) - ry1 = int(ry1_var.get()) - rx2 = int(rx2_var.get()) - ry2 = int(ry2_var.get()) - region = (min(rx1, rx2), min(ry1, ry2), - abs(rx2 - rx1), abs(ry2 - ry1)) - if region[2] == 0 or region[3] == 0: - raise ValueError("Zero-size region") - except Exception as exc: - messagebox.showerror("Invalid region", str(exc), parent=win) - return + click_map = { + "Left Click": ("left", 1), + "Right Click": ("right", 1), + "Middle Click": ("middle", 1), + "Double Left Click": ("left", 2), + "Double Right Click": ("right", 2), + } + button_name, click_count = click_map.get(button_var.get(), ("left", 1)) - state["stop_event"].clear() - _set_controls_running(True) - _set_status("Scanning for image…") + try: + region = parse_region() + except Exception as exc: + messagebox.showerror("Invalid region", str(exc), parent=win) + return + state["stop_event"].clear() + set_controls_running(True) + set_status("Scanning for the reference image...") state["thread"] = threading.Thread( - target=_worker, - args=(state["image_path"], conf, mode, burst_count, interval_sec, - btn_info[0], btn_info[1], use_grayscale, region), + target=worker, + args=( + state["image_path"], + confidence, + mode_name, + burst_count, + interval_seconds, + button_name, + click_count, + grayscale_var.get(), + region, + offset_x, + offset_y, + settle_delay, + beep_var.get(), + ), daemon=True, ) state["thread"].start() @@ -1196,267 +1848,243 @@ def start_scan(): def stop_scan(): if state["thread"] and state["thread"].is_alive(): state["stop_event"].set() - _set_status("Stop requested.") + set_status("Stop requested.") def close_window(): state["stop_event"].set() win.destroy() - def _toggle_region(*_): - is_custom = region_var.get() == "Custom Region" - for widget in (rx1_entry, ry1_entry, rx2_entry, ry2_entry): - widget.configure(state=NORMAL if is_custom else DISABLED) - - def _toggle_burst(*_): - burst_entry.configure( - state=NORMAL if mode_var.get() == "Burst Count" else DISABLED - ) - - # ── styles ─────────────────────────────────────────────────────────── style = ttk.Style(win) try: style.theme_use("clam") except Exception: pass - style.configure("PC.Accent.TButton", background=_ACCENT, - foreground="white", padding=(14, 8), - font=("Segoe UI", 10, "bold")) - style.map("PC.Accent.TButton", - background=[("active", "#115e59"), ("disabled", "#94a3b8")]) - style.configure("PC.Danger.TButton", background=_DANGER, - foreground="white", padding=(12, 8), - font=("Segoe UI", 10, "bold")) - style.map("PC.Danger.TButton", - background=[("active", "#991b1b"), ("disabled", "#94a3b8")]) - style.configure("PC.Secondary.TButton", background="#e2e8f0", - foreground="#0f172a", padding=(12, 7), - font=("Segoe UI", 10)) + style.configure("PC.Accent.TButton", background=_ACCENT, foreground="white", padding=(14, 8), font=("Segoe UI", 10, "bold")) + style.map("PC.Accent.TButton", background=[("active", "#115e59"), ("disabled", "#94a3b8")]) + style.configure("PC.Danger.TButton", background=_DANGER, foreground="white", padding=(12, 8), font=("Segoe UI", 10, "bold")) + style.map("PC.Danger.TButton", background=[("active", "#991b1b"), ("disabled", "#94a3b8")]) + style.configure("PC.Secondary.TButton", background="#e2e8f0", foreground="#0f172a", padding=(12, 7), font=("Segoe UI", 10)) style.map("PC.Secondary.TButton", background=[("active", "#cbd5e1")]) - # ── layout ─────────────────────────────────────────────────────────── - shell = tk.Frame(win, bg=_BG, padx=18, pady=18) - shell.pack(fill="both", expand=True) + shell, _photo_canvas = _create_scrollable_shell(win, _BG, min_width=760, padx=18, pady=18) shell.columnconfigure(0, weight=1) - # hero header - hero = tk.Frame(shell, bg=_HERO_BG, padx=20, pady=14) + hero = tk.Frame(shell, bg=_HERO_BG, padx=20, pady=16) hero.grid(row=0, column=0, sticky="ew") - tk.Label(hero, text="Photo Clicker", bg=_HERO_BG, fg=_HERO_FG, - font=("Segoe UI", 16, "bold")).pack(anchor="w") - tk.Label(hero, text="Locate a reference image on screen and click on it " - "automatically.", bg=_HERO_BG, fg=_HERO_SUB, - font=("Segoe UI", 9)).pack(anchor="w", pady=(2, 0)) - - # main card - card = tk.Frame(shell, bg=_CARD_BG, padx=16, pady=16, - highlightbackground=_BORDER, highlightthickness=1) - card.grid(row=1, column=0, sticky="nsew", pady=(14, 0)) + tk.Label(hero, text="Photo Clicker", bg=_HERO_BG, fg=_HERO_FG, font=("Segoe UI", 16, "bold")).pack(anchor="w") + tk.Label( + hero, + text="Find a reference image, click with optional offsets, and narrow the scan area when you need faster automation.", + bg=_HERO_BG, + fg=_HERO_SUB, + font=("Segoe UI", 9), + wraplength=680, + justify=LEFT, + ).pack(anchor="w", pady=(2, 0)) + + tool_card = tk.Frame(shell, bg=_CARD_BG, padx=16, pady=16, highlightbackground=_BORDER, highlightthickness=1) + tool_card.grid(row=1, column=0, sticky="ew", pady=(14, 0)) + tool_card.columnconfigure(1, weight=1) + tool_card.columnconfigure(3, weight=1) + tk.Label(tool_card, text="Scan preset", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=0, column=0, sticky="w") + preset_combo = ttk.Combobox( + tool_card, + textvariable=preset_var, + values=("Balanced", "Precision Hunt", "Fast Watch", "Burst Sweep"), + state="readonly", + width=18, + ) + preset_combo.grid(row=0, column=1, sticky="w", pady=(0, 10)) + ttk.Button(tool_card, text="Apply Preset", style="PC.Secondary.TButton", command=apply_scan_preset).grid(row=0, column=2, sticky="w", padx=(10, 8), pady=(0, 10)) + ttk.Button(tool_card, text="Open Image Folder", style="PC.Secondary.TButton", command=open_image_folder).grid(row=0, column=3, sticky="e", pady=(0, 10)) + tk.Label(tool_card, text="Focus zone size", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=1, column=0, sticky="w") + ttk.Entry(tool_card, textvariable=focus_size_var, width=12).grid(row=1, column=1, sticky="w") + ttk.Button(tool_card, text="Reset To Screen", style="PC.Secondary.TButton", command=lambda: set_full_screen_region(set_mode=True)).grid(row=1, column=2, sticky="w", padx=(10, 8)) + ttk.Button(tool_card, text="Focus Near Cursor", style="PC.Secondary.TButton", command=focus_region_near_cursor).grid(row=1, column=3, sticky="e") + + card = tk.Frame(shell, bg=_CARD_BG, padx=16, pady=16, highlightbackground=_BORDER, highlightthickness=1) + card.grid(row=2, column=0, sticky="nsew", pady=(14, 0)) card.columnconfigure(1, weight=1) card.columnconfigure(3, weight=1) - shell.rowconfigure(1, weight=1) - - # row 0 — image picker - tk.Label(card, text="Reference image", bg=_CARD_BG, fg=_LABEL_FG, - font=("Segoe UI", 10, "bold")).grid(row=0, column=0, sticky=W, - pady=(0, 6)) - tk.Label(card, textvariable=filepath_var, bg=_CARD_BG, fg="#475569", - font=("Segoe UI", 10)).grid(row=0, column=1, sticky=W, - pady=(0, 6)) - ttk.Button(card, text="Browse…", style="PC.Secondary.TButton", - command=pick_image).grid(row=0, column=2, columnspan=2, - sticky="e", pady=(0, 6)) - - # row 1 — thumbnail preview - tk.Label(card, text="Preview", bg=_CARD_BG, fg=_LABEL_FG, - font=("Segoe UI", 10, "bold")).grid(row=1, column=0, sticky="nw", - pady=(0, 10)) - thumb_label = tk.Label(card, text="(no image)", bg="#e2e8f0", fg="#64748b", - width=22, height=7, relief="solid", bd=1, - font=("Segoe UI", 9)) - thumb_label.grid(row=1, column=1, sticky=W, pady=(0, 10)) - - # row 2 — confidence + click type - tk.Label(card, text="Confidence (0-1)", bg=_CARD_BG, fg=_LABEL_FG, - font=("Segoe UI", 10, "bold")).grid(row=2, column=0, sticky=W, - pady=(0, 6)) - ttk.Entry(card, textvariable=confidence_var, width=12).grid( - row=2, column=1, sticky=W, pady=(0, 6)) - tk.Label(card, text="Click type", bg=_CARD_BG, fg=_LABEL_FG, - font=("Segoe UI", 10, "bold")).grid(row=2, column=2, sticky=W, - padx=(14, 0), pady=(0, 6)) - ttk.Combobox(card, textvariable=button_var, state="readonly", width=18, - values=("Left Click", "Right Click", "Middle Click", - "Double Left Click", "Double Right Click")).grid( - row=2, column=3, sticky=W, pady=(0, 6)) - - # row 3 — mode + burst count - tk.Label(card, text="Mode", bg=_CARD_BG, fg=_LABEL_FG, - font=("Segoe UI", 10, "bold")).grid(row=3, column=0, sticky=W, - pady=(0, 6)) - mode_combo = ttk.Combobox(card, textvariable=mode_var, state="readonly", - width=18, - values=("Click Once", "Burst Count", - "Continuous Watch")) - mode_combo.grid(row=3, column=1, sticky=W, pady=(0, 6)) - tk.Label(card, text="Burst count", bg=_CARD_BG, fg=_LABEL_FG, - font=("Segoe UI", 10, "bold")).grid(row=3, column=2, sticky=W, - padx=(14, 0), pady=(0, 6)) - burst_entry = ttk.Entry(card, textvariable=burst_var, width=12, - state=DISABLED) - burst_entry.grid(row=3, column=3, sticky=W, pady=(0, 6)) - - # row 4 — scan interval + stop hotkey - tk.Label(card, text="Scan interval (sec)", bg=_CARD_BG, fg=_LABEL_FG, - font=("Segoe UI", 10, "bold")).grid(row=4, column=0, sticky=W, - pady=(0, 6)) - ttk.Entry(card, textvariable=interval_var, width=12).grid( - row=4, column=1, sticky=W, pady=(0, 6)) - tk.Label(card, text="Stop hotkey", bg=_CARD_BG, fg=_LABEL_FG, - font=("Segoe UI", 10, "bold")).grid(row=4, column=2, sticky=W, - padx=(14, 0), pady=(0, 6)) - ttk.Entry(card, textvariable=hotkey_var, width=12).grid( - row=4, column=3, sticky=W, pady=(0, 6)) - - # row 5 — grayscale + region selector - ttk.Checkbutton(card, text="Grayscale matching (faster, less precise)", - variable=grayscale_var).grid( - row=5, column=0, columnspan=2, sticky=W, pady=(0, 6)) - tk.Label(card, text="Region", bg=_CARD_BG, fg=_LABEL_FG, - font=("Segoe UI", 10, "bold")).grid(row=5, column=2, sticky=W, - padx=(14, 0), pady=(0, 6)) - region_combo = ttk.Combobox(card, textvariable=region_var, state="readonly", - width=18, - values=("Full Screen", "Custom Region")) - region_combo.grid(row=5, column=3, sticky=W, pady=(0, 6)) - - # row 6 — custom region fields - region_frame = ttk.LabelFrame(card, text="Custom region", padding=8) - region_frame.grid(row=6, column=0, columnspan=4, sticky="ew", pady=(4, 8)) - ttk.Label(region_frame, text="X1").grid(row=0, column=0, sticky=W) - rx1_entry = ttk.Entry(region_frame, textvariable=rx1_var, width=8, - state=DISABLED) + + tk.Label(card, text="Reference image", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=0, column=0, sticky="w", pady=(0, 8)) + tk.Label(card, textvariable=filepath_var, bg=_CARD_BG, fg="#475569", font=("Segoe UI", 10), wraplength=360, justify=LEFT).grid(row=0, column=1, columnspan=2, sticky="w", pady=(0, 8)) + ttk.Button(card, text="Browse...", style="PC.Secondary.TButton", command=pick_image).grid(row=0, column=3, sticky="e", pady=(0, 8)) + + tk.Label(card, text="Preview", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=1, column=0, sticky="nw", pady=(0, 12)) + thumb_label = tk.Label( + card, + text="(no image)", + bg="#e2e8f0", + fg="#64748b", + width=24, + height=8, + relief="solid", + bd=1, + font=("Segoe UI", 9), + ) + thumb_label.grid(row=1, column=1, sticky="w", pady=(0, 12)) + insight_frame = tk.Frame(card, bg=_CARD_BG) + insight_frame.grid(row=1, column=2, columnspan=2, sticky="nsew", padx=(14, 0), pady=(0, 12)) + insight_frame.columnconfigure(0, weight=1) + tk.Label(insight_frame, text="Region summary", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=0, column=0, sticky="w") + tk.Label(insight_frame, textvariable=region_summary_var, bg=_CARD_BG, fg="#475569", font=("Segoe UI", 9), wraplength=270, justify=LEFT).grid(row=1, column=0, sticky="w", pady=(4, 10)) + tk.Label( + insight_frame, + text="Use click offsets when the image itself is not the exact point you want to press.", + bg=_CARD_BG, + fg="#475569", + font=("Segoe UI", 9), + wraplength=270, + justify=LEFT, + ).grid(row=2, column=0, sticky="w") + + tk.Label(card, text="Confidence (0-1)", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=2, column=0, sticky="w", pady=(0, 8)) + ttk.Entry(card, textvariable=confidence_var, width=12).grid(row=2, column=1, sticky="w", pady=(0, 8)) + tk.Label(card, text="Click type", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=2, column=2, sticky="w", padx=(14, 0), pady=(0, 8)) + ttk.Combobox(card, textvariable=button_var, state="readonly", width=18, values=("Left Click", "Right Click", "Middle Click", "Double Left Click", "Double Right Click")).grid(row=2, column=3, sticky="w", pady=(0, 8)) + + tk.Label(card, text="Mode", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=3, column=0, sticky="w", pady=(0, 8)) + mode_combo = ttk.Combobox(card, textvariable=mode_var, state="readonly", width=18, values=("Click Once", "Burst Count", "Continuous Watch")) + mode_combo.grid(row=3, column=1, sticky="w", pady=(0, 8)) + tk.Label(card, text="Burst count", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=3, column=2, sticky="w", padx=(14, 0), pady=(0, 8)) + burst_entry = ttk.Entry(card, textvariable=burst_var, width=12, state=DISABLED) + burst_entry.grid(row=3, column=3, sticky="w", pady=(0, 8)) + + tk.Label(card, text="Scan interval (sec)", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=4, column=0, sticky="w", pady=(0, 8)) + ttk.Entry(card, textvariable=interval_var, width=12).grid(row=4, column=1, sticky="w", pady=(0, 8)) + tk.Label(card, text="Stop hotkey", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=4, column=2, sticky="w", padx=(14, 0), pady=(0, 8)) + ttk.Entry(card, textvariable=hotkey_var, width=12).grid(row=4, column=3, sticky="w", pady=(0, 8)) + + tk.Label(card, text="Click offset X / Y", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=5, column=0, sticky="w", pady=(0, 8)) + offset_row = tk.Frame(card, bg=_CARD_BG) + offset_row.grid(row=5, column=1, sticky="w", pady=(0, 8)) + ttk.Entry(offset_row, textvariable=offset_x_var, width=8).grid(row=0, column=0, padx=(0, 6)) + ttk.Entry(offset_row, textvariable=offset_y_var, width=8).grid(row=0, column=1) + tk.Label(card, text="Settle delay (sec)", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=5, column=2, sticky="w", padx=(14, 0), pady=(0, 8)) + ttk.Entry(card, textvariable=settle_delay_var, width=12).grid(row=5, column=3, sticky="w", pady=(0, 8)) + + ttk.Checkbutton(card, text="Grayscale matching", variable=grayscale_var).grid(row=6, column=0, columnspan=2, sticky="w", pady=(0, 8)) + ttk.Checkbutton(card, text="Beep when a match is clicked", variable=beep_var).grid(row=6, column=2, columnspan=2, sticky="w", pady=(0, 8)) + + tk.Label(card, text="Region", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=7, column=0, sticky="w", pady=(0, 8)) + region_combo = ttk.Combobox(card, textvariable=region_var, state="readonly", width=18, values=("Full Screen", "Custom Region")) + region_combo.grid(row=7, column=1, sticky="w", pady=(0, 8)) + tk.Label( + card, + text="Preset focus creates a square region around your cursor for faster hunts.", + bg=_CARD_BG, + fg="#475569", + font=("Segoe UI", 9), + wraplength=300, + justify=LEFT, + ).grid(row=7, column=2, columnspan=2, sticky="w", padx=(14, 0), pady=(0, 8)) + + region_frame = ttk.LabelFrame(card, text="Custom region", padding=10) + region_frame.grid(row=8, column=0, columnspan=4, sticky="ew", pady=(4, 10)) + ttk.Label(region_frame, text="X1").grid(row=0, column=0, sticky="w") + rx1_entry = ttk.Entry(region_frame, textvariable=rx1_var, width=8, state=DISABLED) rx1_entry.grid(row=0, column=1, padx=(0, 10)) - ttk.Label(region_frame, text="Y1").grid(row=0, column=2, sticky=W) - ry1_entry = ttk.Entry(region_frame, textvariable=ry1_var, width=8, - state=DISABLED) + ttk.Label(region_frame, text="Y1").grid(row=0, column=2, sticky="w") + ry1_entry = ttk.Entry(region_frame, textvariable=ry1_var, width=8, state=DISABLED) ry1_entry.grid(row=0, column=3, padx=(0, 10)) - ttk.Label(region_frame, text="X2").grid(row=0, column=4, sticky=W) - rx2_entry = ttk.Entry(region_frame, textvariable=rx2_var, width=8, - state=DISABLED) + ttk.Label(region_frame, text="X2").grid(row=0, column=4, sticky="w") + rx2_entry = ttk.Entry(region_frame, textvariable=rx2_var, width=8, state=DISABLED) rx2_entry.grid(row=0, column=5, padx=(0, 10)) - ttk.Label(region_frame, text="Y2").grid(row=0, column=6, sticky=W) - ry2_entry = ttk.Entry(region_frame, textvariable=ry2_var, width=8, - state=DISABLED) - ry2_entry.grid(row=0, column=7) - - # row 7 — action buttons - btn_row = tk.Frame(card, bg=_CARD_BG) - btn_row.grid(row=7, column=0, columnspan=4, sticky="ew", pady=(6, 4)) - start_btn = ttk.Button(btn_row, text="Start Scan", style="PC.Accent.TButton", - command=start_scan) + ttk.Label(region_frame, text="Y2").grid(row=0, column=6, sticky="w") + ry2_entry = ttk.Entry(region_frame, textvariable=ry2_var, width=8, state=DISABLED) + ry2_entry.grid(row=0, column=7, padx=(0, 10)) + ttk.Button(region_frame, text="Use Full Screen", style="PC.Secondary.TButton", command=lambda: set_full_screen_region(set_mode=True)).grid(row=1, column=0, columnspan=4, sticky="w", pady=(10, 0)) + ttk.Button(region_frame, text="Focus Near Cursor", style="PC.Secondary.TButton", command=focus_region_near_cursor).grid(row=1, column=4, columnspan=4, sticky="e", pady=(10, 0)) + + button_row = tk.Frame(card, bg=_CARD_BG) + button_row.grid(row=9, column=0, columnspan=4, sticky="ew", pady=(6, 0)) + start_btn = ttk.Button(button_row, text="Start Scan", style="PC.Accent.TButton", command=start_scan) start_btn.grid(row=0, column=0, padx=(0, 8)) - stop_btn = ttk.Button(btn_row, text="Stop", style="PC.Danger.TButton", - command=stop_scan, state=DISABLED) + stop_btn = ttk.Button(button_row, text="Stop", style="PC.Danger.TButton", command=stop_scan, state=DISABLED) stop_btn.grid(row=0, column=1) - # status strip - status_frame = tk.Frame(shell, bg=_STATUS_BG, padx=16, pady=10) - status_frame.grid(row=2, column=0, sticky="ew", pady=(14, 0)) - tk.Label(status_frame, text="Status", bg=_STATUS_BG, fg=_HERO_FG, - font=("Segoe UI", 10, "bold")).grid(row=0, column=0, sticky=W) - tk.Label(status_frame, textvariable=status_var, bg=_STATUS_BG, - fg=_STATUS_FG, font=("Segoe UI", 10), wraplength=600, - justify=LEFT).grid(row=1, column=0, sticky=W, pady=(4, 0)) - tk.Label(status_frame, textvariable=stats_var, bg=_STATUS_BG, - fg=_STATUS_FG, font=("Segoe UI", 9), wraplength=600, - justify=LEFT).grid(row=2, column=0, sticky=W, pady=(4, 0)) - - # bindings - mode_combo.bind("<>", _toggle_burst) - region_combo.bind("<>", _toggle_region) - - _pump_queue() + status_frame = tk.Frame(shell, bg=_STATUS_BG, padx=16, pady=12) + status_frame.grid(row=3, column=0, sticky="ew", pady=(14, 0)) + tk.Label(status_frame, text="Status", bg=_STATUS_BG, fg=_HERO_FG, font=("Segoe UI", 10, "bold")).grid(row=0, column=0, sticky="w") + tk.Label(status_frame, textvariable=status_var, bg=_STATUS_BG, fg=_STATUS_FG, font=("Segoe UI", 10), wraplength=680, justify=LEFT).grid(row=1, column=0, sticky="w", pady=(4, 0)) + tk.Label(status_frame, textvariable=stats_var, bg=_STATUS_BG, fg=_STATUS_FG, font=("Segoe UI", 9), wraplength=680, justify=LEFT).grid(row=2, column=0, sticky="w", pady=(4, 0)) + + for tracked_var in (region_var, rx1_var, ry1_var, rx2_var, ry2_var): + tracked_var.trace_add("write", update_region_summary) + preset_combo.bind("<>", apply_scan_preset) + mode_combo.bind("<>", toggle_burst) + region_combo.bind("<>", toggle_region) + + toggle_burst() + toggle_region() + pump_queue() win.protocol("WM_DELETE_WINDOW", close_window) if owner is None: win.mainloop() -# allow standalone execution - - -try: - import gspread - from oauth2client.service_account import ServiceAccountCredentials - from pprint import pprint -except: - pass - -try: - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", message="pkg_resources is deprecated as an API.*", category=UserWarning) - import win10toast #This module only works on windows -except: - pass - -try: - from PIL import ImageGrab - from colormap import rgb2hex - from win32api import GetSystemMetrics -except: - pass - -################################################################################ -# /\ | | | |__ __/ __ \ / ____| | |_ _/ ____| |/ / ____| __ \ # -# / \ | | | | | | | | | | | | | | | || | | ' /| |__ | |__) |# -# / /\ \| | | | | | | | | | | | | | | || | | < | __| | _ / # -# / ____ \ |__| | | | | |__| | | |____| |____ _| || |____| . \| |____| | \ \ # -# /_/ \_\____/ |_| \____/ \_____|______|_____\_____|_|\_\______|_| \_\# -################################################################################ - def Colour_Clicker(): - """Advanced Colour Clicker with NumPy performance and Magnified Pipette.""" + """Advanced Colour Clicker with scrollable tools, recent swatches, and scan presets.""" + if not _ensure_dependencies("Advanced Colour Clicker", ["pyautogui", "keyboard"], parent=tk._default_root): return if ImageGrab is None: _show_dependency_error("Advanced Colour Clicker", ["Pillow"], parent=tk._default_root) return - _BG = "#edf0f7" - _CARD_BG = "#f8fafc" - _BORDER = "#bfd0e5" - _HERO_BG = "#1e293b" - _HERO_FG = "#f8fafc" - _HERO_SUB = "#94a3b8" - _LABEL_FG = "#0f172a" - _ACCENT = "#0f766e" - _DANGER = "#b91c1c" + _BG = "#edf0f7" + _CARD_BG = "#f8fafc" + _BORDER = "#bfd0e5" + _HERO_BG = "#1e293b" + _HERO_FG = "#f8fafc" + _HERO_SUB = "#94a3b8" + _LABEL_FG = "#0f172a" + _ACCENT = "#0f766e" + _DANGER = "#b91c1c" owner = tk._default_root master = tk.Toplevel(owner) if owner else tk.Tk() master.title("Advanced Colour Clicker") - master.geometry("720x780+300+100") - master.minsize(700, 700) + master.geometry("780x820+300+100") + master.minsize(720, 700) master.attributes("-topmost", True) master.configure(bg=_BG) - try: master.iconbitmap(_resource_path("favicon.ico")) - except: pass + try: + master.iconbitmap(_resource_path("favicon.ico")) + except Exception: + pass try: import numpy as np - HAS_NUMPY = True + + has_numpy = True except ImportError: - HAS_NUMPY = False + has_numpy = False try: - from PIL import Image, ImageTk - HAS_PIL_UI = True + from PIL import Image as PILImage, ImageTk as PILImageTk + + has_pil_ui = True except ImportError: - HAS_PIL_UI = False + PILImage = None + PILImageTk = None + has_pil_ui = False - try: screen_width, screen_height = pyautogui.size() - except: screen_width, screen_height = 1920, 1080 + try: + screen_width, screen_height = pyautogui.size() + except Exception: + screen_width, screen_height = 1920, 1080 - state = {"thread": None, "stop_event": threading.Event(), "is_sampling": False, "loupe_win": None, "loupe_canvas": None} + state = { + "thread": None, + "stop_event": threading.Event(), + "is_sampling": False, + "loupe_win": None, + "loupe_canvas": None, + "sample_hotkey": None, + "history": [], + } ui_queue = queue.Queue() colour_var = tk.StringVar(value="#FFFFFF") @@ -1470,2096 +2098,672 @@ def Colour_Clicker(): button_var = tk.StringVar(value="Left Click") mode_var = tk.StringVar(value="First Match") hotkey_var = tk.StringVar(value="esc") + preset_var = tk.StringVar(value="Balanced") + max_clicks_var = tk.StringVar(value="25") + focus_size_var = tk.StringVar(value="320") status_var = tk.StringVar(value="Ready. Sample a colour or select an area.") stats_var = tk.StringVar(value="No scans yet.") + region_summary_var = tk.StringVar(value="Full screen scan ready.") + rgb_summary_var = tk.StringVar(value="RGB 255, 255, 255") + + def set_status(message): + ui_queue.put(("status", message)) - def set_status(msg): ui_queue.put(("status", msg)) - def set_stats(msg): ui_queue.put(("stats", msg)) + def set_stats(message): + ui_queue.put(("stats", message)) def pump_ui_queue(): try: while True: - t, d = ui_queue.get_nowait() - if t == "status": status_var.set(d) - elif t == "stats": stats_var.set(d) - elif t == "finish": + action, payload = ui_queue.get_nowait() + if action == "status": + status_var.set(payload) + elif action == "stats": + stats_var.set(payload) + elif action == "finish": state["thread"] = None scan_btn.configure(state=NORMAL) watch_btn.configure(state=NORMAL) stop_btn.configure(state=DISABLED) - except queue.Empty: pass - master.after(100, pump_ui_queue) - - def select_area(): - overlay = tk.Toplevel(master) - overlay.attributes("-alpha", 0.3) - overlay.attributes("-fullscreen", True) - overlay.attributes("-topmost", True) - overlay.configure(bg="black") - overlay.focus_force() - canvas = tk.Canvas(overlay, cursor="cross", bg="black", highlightthickness=0) - canvas.pack(fill="both", expand=True) - sel = {"x1": 0, "y1": 0, "rect": None} - def on_down(e): - sel["x1"], sel["y1"] = e.x, e.y - sel["rect" ] = canvas.create_rectangle(e.x, e.y, e.x, e.y, outline="white", width=2) - def on_move(e): - if sel["rect"]: canvas.coords(sel["rect"], sel["x1"], sel["y1"], e.x, e.y) - def on_up(e): - x1, y1, x2, y2 = sel["x1"], sel["y1"], e.x, e.y - x_start_var.set(str(min(x1, x2))); y_start_var.set(str(min(y1, y2))) - x_end_var.set(str(max(x1, x2))); y_end_var.set(str(max(y1, y2))) - overlay.destroy() - set_status(f"Region set to ({x_start_var.get()}, {y_start_var.get()}) to ({x_end_var.get()}, {y_end_var.get()})") - overlay.bind("", on_down) - overlay.bind("", on_move) - overlay.bind("", on_up) - overlay.bind("", lambda _: overlay.destroy()) - - def update_loupe(): - if not state["is_sampling"]: return + except queue.Empty: + pass + except Exception: + pass try: - x, y = pyautogui.position() - state["loupe_win"].geometry(f"+{(x + 20)}+{(y + 20)}") - if HAS_PIL_UI: - img = ImageGrab.grab(bbox=(x-10, y-10, x+11, y+11)).convert("RGB") - img_zoom = img.resize((147, 147), Image.NEAREST) - photo = ImageTk.PhotoImage(img_zoom) - state["loupe_canvas"].delete("all") - state["loupe_canvas"].create_image(0, 0, anchor="nw", image=photo) - state["loupe_canvas"].image = photo - state["loupe_canvas"].create_line(73, 0, 73, 147, fill="red") - state["loupe_canvas"].create_line(0, 73, 147, 73, fill="red") - r, g, b = img.getpixel((10, 10)) - state["loupe_win"].title(f"#{r:02X}{g:02X}{b:02X}") - except: pass - master.after(30, update_loupe) + if master.winfo_exists(): + master.after(100, pump_ui_queue) + except Exception: + pass - def start_sampling(): - if state["is_sampling"]: return - state["is_sampling"] = True - set_status("Sample colour: Click target pixel. ESC to cancel.") - master.config(cursor="cross") - lwin = tk.Toplevel(master) - lwin.overrideredirect(True) - lwin.attributes("-topmost", True) - lwin.geometry("150x150") - lwin.configure(bg="black", highlightbackground="white", highlightthickness=2) - lcanv = tk.Canvas(lwin, width=147, height=147, bg="black", highlightthickness=0) - lcanv.pack() - state["loupe_win"] = lwin - state["loupe_canvas"] = lcanv - update_loupe() - def perform_sample(e=None): - x, y = pyautogui.position() - img = ImageGrab.grab(bbox=(x, y, x+1, y+1)).convert('RGB') - r, g, b = img.getpixel((0, 0)) - colour_var.set("#{:02X}{:02X}{:02X}".format(r, g, b)) - update_preview(); cancel_sampling() - def cancel_sampling(e=None): - state["is_sampling"] = False; master.config(cursor="") - if state["loupe_win"]: state["loupe_win"].destroy() - master.unbind(""); - try: keyboard.unhook_all_hotkeys() - except: pass - master.bind("", perform_sample) - keyboard.add_hotkey("esc", cancel_sampling) - - def update_preview(*_): - try: - col = colour_var.get(); preview_box.configure(bg=col) - except: pass - - def worker(target_hex, tolerance, bbox, step, mode, is_watcher, interval, btn, clicks, stop_hk): - try: - target_rgb = tuple(int(target_hex.lstrip('#')[i:i+2], 16) for i in (0, 2, 4)) - iterations = 0; found_total = 0 - while not state["stop_event"].is_set(): - iterations += 1 - if stop_hk and keyboard.is_pressed(stop_hk): break - img = ImageGrab.grab(bbox=bbox).convert("RGB") - found_points = [] - if HAS_NUMPY: - arr = np.array(img) - diff = np.abs(arr - target_rgb) - match_mask = np.all(diff <= tolerance, axis=-1) - match_mask = match_mask[::step, ::step] - y_coords, x_coords = np.where(match_mask) - for y, x in zip(y_coords, x_coords): found_points.append((bbox[0] + x*step, bbox[1] + y*step)) - else: - for y in range(0, img.height, step): - for x in range(0, img.width, step): - r, g, b = img.getpixel((x, y)) - if all(abs(c-t) <= tolerance for c, t in zip((r, g, b), target_rgb)): - found_points.append((bbox[0]+x, bbox[1]+y)) - if mode == "First Match": break - if mode == "First Match" and found_points: break - if found_points: - if mode == "First Match": pyautogui.click(found_points[0][0], found_points[0][1], button=btn, clicks=clicks); found_total += 1 - else: - for px, py in found_points: - if state["stop_event"].is_set(): break - pyautogui.click(px, py, button=btn, clicks=clicks); found_total += 1 - set_status(f"Found {len(found_points)} match(es).") - else: set_status(f"No match in scan #{iterations}.") - set_stats(f"Scans: {iterations} | Clicks: {found_total}") - if not is_watcher: break - state["stop_event"].wait(interval) - except Exception as e: set_status(f"Error: {e}") - finally: ui_queue.put(("finish", None)) - - def start_worker(is_watcher): - try: - col, tol, step = colour_var.get(), int(tolerance_var.get()), int(scan_step_var.get()) - bbox = (int(x_start_var.get()), int(y_start_var.get()), int(x_end_var.get()), int(y_end_var.get())) - interval = float(interval_var.get()); btn = "left" if "Left" in button_var.get() else "right" - clicks = 2 if "Double" in button_var.get() else 1; stop_hk = hotkey_var.get() - state["stop_event"].clear(); scan_btn.configure(state=DISABLED); watch_btn.configure(state=DISABLED); stop_btn.configure(state=NORMAL) - state["thread"] = threading.Thread(target=worker, args=(col, tol, bbox, step, mode_var.get(), is_watcher, interval, btn, clicks, stop_hk), daemon=True) - state["thread"].start() - except Exception as e: messagebox.showerror("Error", str(e)) - - shell = tk.Frame(master, bg=_BG, padx=20, pady=20); shell.pack(fill="both", expand=True) - hero = tk.Frame(shell, bg=_HERO_BG, padx=20, pady=15); hero.pack(fill="x") - tk.Label(hero, text="Advanced Colour Clicker", bg=_HERO_BG, fg=_HERO_FG, font=("Segoe UI", 16, "bold")).pack(anchor="w") - tk.Label(hero, text="Scan screen regions for colours with NumPy speed.", bg=_HERO_BG, fg=_HERO_SUB, font=("Segoe UI", 9)).pack(anchor="w") - card1 = tk.Frame(shell, bg=_CARD_BG, padx=15, pady=15, highlightbackground=_BORDER, highlightthickness=1); card1.pack(fill="x", pady=(15, 0)) - tk.Label(card1, text="Target Colour & Tolerance", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).pack(anchor="w", pady=(0, 10)) - trow = tk.Frame(card1, bg=_CARD_BG); trow.pack(fill="x") - preview_box = tk.Frame(trow, width=40, height=40, relief="solid", bd=1); preview_box.pack(side="left", padx=(0, 10)) - ttk.Entry(trow, textvariable=colour_var, width=12).pack(side="left", padx=(0, 10)) - ttk.Button(trow, text="Pipette", command=start_sampling).pack(side="left", padx=(0, 10)) - tk.Label(trow, text="Tolerance:", bg=_CARD_BG).pack(side="left"); ttk.Entry(trow, textvariable=tolerance_var, width=8).pack(side="left", padx=(5, 0)) - card2 = tk.Frame(shell, bg=_CARD_BG, padx=15, pady=15, highlightbackground=_BORDER, highlightthickness=1); card2.pack(fill="x", pady=(15, 0)) - tk.Label(card2, text="Scan Region", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).pack(anchor="w", pady=(0, 10)) - rg = tk.Frame(card2, bg=_CARD_BG); rg.pack(fill="x") - tk.Label(rg, text="X1", bg=_CARD_BG).grid(row=0, column=0, padx=2); ttk.Entry(rg, textvariable=x_start_var, width=7).grid(row=0, column=1) - tk.Label(rg, text="Y1", bg=_CARD_BG).grid(row=0, column=2, padx=2); ttk.Entry(rg, textvariable=y_start_var, width=7).grid(row=0, column=3) - tk.Label(rg, text="X2", bg=_CARD_BG).grid(row=0, column=4, padx=2); ttk.Entry(rg, textvariable=x_end_var, width=7).grid(row=0, column=5) - tk.Label(rg, text="Y2", bg=_CARD_BG).grid(row=0, column=6, padx=2); ttk.Entry(rg, textvariable=y_end_var, width=7).grid(row=0, column=7) - ttk.Button(rg, text="Select Area", command=select_area).grid(row=0, column=8, padx=10) - card3 = tk.Frame(shell, bg=_CARD_BG, padx=15, pady=15, highlightbackground=_BORDER, highlightthickness=1); card3.pack(fill="x", pady=(15, 0)) - er = tk.Frame(card3, bg=_CARD_BG); er.pack(fill="x") - tk.Label(er, text="Mode:", bg=_CARD_BG).pack(side="left"); ttk.Combobox(er, textvariable=mode_var, values=("First Match", "All Matches"), state="readonly", width=14).pack(side="left", padx=4) - tk.Label(er, text="Step:", bg=_CARD_BG).pack(side="left", padx=(6, 0)); ttk.Entry(er, textvariable=scan_step_var, width=5).pack(side="left", padx=4) - tk.Label(er, text="Btn:", bg=_CARD_BG).pack(side="left", padx=(6, 0)); ttk.Combobox(er, textvariable=button_var, values=("Left Click", "Right Click", "Double Left"), state="readonly", width=12).pack(side="left", padx=4) - br = tk.Frame(shell, bg=_BG); br.pack(fill="x", pady=(20, 0)) - scan_btn = ttk.Button(br, text="Single Scan", command=lambda: start_worker(False)); scan_btn.pack(side="left", padx=(0, 10)) - watch_btn = ttk.Button(br, text="Watch Region", command=lambda: start_worker(True)); watch_btn.pack(side="left", padx=(0, 10)) - stop_btn = ttk.Button(br, text="Stop", state=DISABLED, command=lambda: state["stop_event"].set()); stop_btn.pack(side="left") - ss = tk.Frame(shell, bg="#0f172a", padx=15, pady=10); ss.pack(fill="x", side="bottom") - tk.Label(ss, textvariable=status_var, bg="#0f172a", fg="#f8fafc", font=("Segoe UI", 9)).pack(anchor="w") - tk.Label(ss, textvariable=stats_var, bg="#0f172a", fg="#94a3b8", font=("Segoe UI", 8)).pack(anchor="w") - pump_ui_queue(); update_preview() - master.protocol("WM_DELETE_WINDOW", lambda: (state["stop_event"].set(), master.destroy())) - - -def Photo_Clicker(): - """Public entry point for the upgraded Photo Clicker.""" - - if not _ensure_dependencies("Photo Clicker", ["pyautogui", "keyboard"]): - return - - _BG = "#edf0f7" - _CARD_BG = "#f8fafc" - _BORDER = "#bfd0e5" - _HERO_BG = "#1e293b" - _HERO_FG = "#f8fafc" - _HERO_SUB = "#94a3b8" - _LABEL_FG = "#0f172a" - _ACCENT = "#0f766e" - _DANGER = "#b91c1c" - _STATUS_BG = "#0f172a" - _STATUS_FG = "#cbd5e1" - - owner = tk._default_root - win = tk.Toplevel(owner) if owner else tk.Tk() - win.title("Photo Clicker") - win.geometry("760x700+280+100") - win.minsize(700, 620) - win.attributes("-topmost", True) - win.resizable(True, True) - win.configure(bg=_BG) - try: - win.iconbitmap(_resource_path("favicon.ico")) - except Exception: - pass - - state = { - "thread": None, - "stop_event": threading.Event(), - "image_path": None, - "thumbnail": None, - } - ui_queue = queue.Queue() - - filepath_var = tk.StringVar(value="No image selected") - confidence_var = tk.StringVar(value="0.80") - mode_var = tk.StringVar(value="Click Once") - burst_var = tk.StringVar(value="5") - interval_var = tk.StringVar(value="1.00") - button_var = tk.StringVar(value="Left Click") - hotkey_var = tk.StringVar(value="esc") - grayscale_var = tk.BooleanVar(value=False) - region_var = tk.StringVar(value="Full Screen") - preset_var = tk.StringVar(value="Balanced") - focus_size_var = tk.StringVar(value="420") - offset_x_var = tk.StringVar(value="0") - offset_y_var = tk.StringVar(value="0") - settle_delay_var = tk.StringVar(value="0.00") - rx1_var = tk.StringVar(value="0") - ry1_var = tk.StringVar(value="0") - rx2_var = tk.StringVar(value="1920") - ry2_var = tk.StringVar(value="1080") - beep_var = tk.BooleanVar(value=False) - status_var = tk.StringVar(value="Select an image to locate on screen.") - stats_var = tk.StringVar(value="No scans yet.") - region_summary_var = tk.StringVar(value="Full screen region ready.") - - try: - screen_width, screen_height = pyautogui.size() - rx2_var.set(str(screen_width)) - ry2_var.set(str(screen_height)) - except Exception: - screen_width, screen_height = 1920, 1080 - - def set_status(message): - if threading.current_thread() is threading.main_thread(): - status_var.set(message) - else: - ui_queue.put(("status", message)) - - def set_stats(message): - if threading.current_thread() is threading.main_thread(): - stats_var.set(message) - else: - ui_queue.put(("stats", message)) - - def set_controls_running(running): - start_btn.configure(state=DISABLED if running else NORMAL) - stop_btn.configure(state=NORMAL if running else DISABLED) + def is_valid_hex_colour(value): + value = value.strip() + if len(value) != 7 or not value.startswith("#"): + return False + return all(ch in "0123456789abcdefABCDEF" for ch in value[1:]) - def pump_queue(): - try: - while True: - action, payload = ui_queue.get_nowait() - if action == "status": - status_var.set(payload) - elif action == "stats": - stats_var.set(payload) - elif action == "finish": - state["thread"] = None - set_controls_running(False) - except queue.Empty: - pass - try: - if win.winfo_exists(): - win.after(100, pump_queue) - except tk.TclError: - pass + def remember_colour(value): + value = value.strip().upper() + if not is_valid_hex_colour(value): + return + if value in state["history"]: + state["history"].remove(value) + state["history"].insert(0, value) + state["history"] = state["history"][:6] + refresh_history_buttons() - def stop_check(): - if state["stop_event"].is_set(): - return True - stop_hotkey = hotkey_var.get().strip() - if stop_hotkey and keyboard: - try: - if keyboard.is_pressed(stop_hotkey): - state["stop_event"].set() - set_status(f"Stop hotkey '{stop_hotkey}' pressed.") - return True - except Exception: - pass - return False + def refresh_history_buttons(): + for child in history_row.winfo_children(): + child.destroy() + if not state["history"]: + tk.Label( + history_row, + text="Recent swatches appear here after pipette samples.", + bg=_CARD_BG, + fg="#64748b", + font=("Segoe UI", 9), + ).grid(row=0, column=0, sticky="w") + return + for index, swatch in enumerate(state["history"]): + tk.Button( + history_row, + text=swatch, + bg=swatch, + fg="white" if swatch not in ("#FFFFFF", "#F8FAFC", "#FEF3C7") else "#0f172a", + activebackground=swatch, + activeforeground="white", + relief="flat", + font=("Segoe UI", 8, "bold"), + command=lambda value=swatch: (colour_var.set(value), update_preview(), set_status(f"Loaded swatch {value}.")), + ).grid(row=0, column=index, padx=(0, 6), sticky="w") def update_region_summary(*_args): - if region_var.get() != "Custom Region": - region_summary_var.set("Full screen region ready.") - return try: - x1 = int(rx1_var.get()) - y1 = int(ry1_var.get()) - x2 = int(rx2_var.get()) - y2 = int(ry2_var.get()) + x1 = int(x_start_var.get()) + y1 = int(y_start_var.get()) + x2 = int(x_end_var.get()) + y2 = int(y_end_var.get()) width = abs(x2 - x1) height = abs(y2 - y1) if width == 0 or height == 0: raise ValueError - region_summary_var.set( - f"Custom region {width} x {height} at {min(x1, x2)}, {min(y1, y2)}." - ) + region_summary_var.set(f"Region {width} x {height} from {min(x1, x2)}, {min(y1, y2)}.") except Exception: - region_summary_var.set("Custom region pending valid coordinates.") + region_summary_var.set("Enter a valid scan region.") - def toggle_region(*_args): - is_custom = region_var.get() == "Custom Region" - for widget in (rx1_entry, ry1_entry, rx2_entry, ry2_entry): - widget.configure(state=NORMAL if is_custom else DISABLED) - update_region_summary() + def select_area(): + overlay = tk.Toplevel(master) + overlay.attributes("-alpha", 0.3) + overlay.attributes("-fullscreen", True) + overlay.attributes("-topmost", True) + overlay.configure(bg="black") + overlay.focus_force() + canvas = tk.Canvas(overlay, cursor="cross", bg="black", highlightthickness=0) + canvas.pack(fill="both", expand=True) + selection = {"x1": 0, "y1": 0, "rect": None} - def toggle_burst(*_args): - burst_entry.configure(state=NORMAL if mode_var.get() == "Burst Count" else DISABLED) + def on_down(event): + selection["x1"], selection["y1"] = event.x, event.y + selection["rect"] = canvas.create_rectangle(event.x, event.y, event.x, event.y, outline="white", width=2) - def set_full_screen_region(set_mode=False): - rx1_var.set("0") - ry1_var.set("0") - rx2_var.set(str(screen_width)) - ry2_var.set(str(screen_height)) - if set_mode: - region_var.set("Full Screen") - toggle_region() - set_status("Region reset to the full screen.") + def on_move(event): + if selection["rect"]: + canvas.coords(selection["rect"], selection["x1"], selection["y1"], event.x, event.y) + + def on_up(event): + x1, y1, x2, y2 = selection["x1"], selection["y1"], event.x, event.y + x_start_var.set(str(min(x1, x2))) + y_start_var.set(str(min(y1, y2))) + x_end_var.set(str(max(x1, x2))) + y_end_var.set(str(max(y1, y2))) + overlay.destroy() + set_status(f"Region set to ({x_start_var.get()}, {y_start_var.get()}) to ({x_end_var.get()}, {y_end_var.get()}).") + + overlay.bind("", on_down) + overlay.bind("", on_move) + overlay.bind("", on_up) + overlay.bind("", lambda _event: overlay.destroy()) + + def set_full_region(): + x_start_var.set("0") + y_start_var.set("0") + x_end_var.set(str(screen_width)) + y_end_var.set(str(screen_height)) + set_status("Scan region reset to the full screen.") def focus_region_near_cursor(): try: focus_size = max(40, int(focus_size_var.get())) except ValueError: - messagebox.showerror("Invalid focus size", "Focus size must be a whole number.", parent=win) + messagebox.showerror("Invalid focus size", "Focus size must be a whole number.", parent=master) return try: cursor_x, cursor_y = pyautogui.position() except Exception as exc: - messagebox.showerror("Capture failed", f"Unable to read the cursor position.\n{exc}", parent=win) + messagebox.showerror("Capture failed", f"Unable to read the cursor position.\n{exc}", parent=master) return half_size = focus_size // 2 - x1 = max(0, cursor_x - half_size) - y1 = max(0, cursor_y - half_size) - x2 = min(screen_width, cursor_x + half_size) - y2 = min(screen_height, cursor_y + half_size) - rx1_var.set(str(x1)) - ry1_var.set(str(y1)) - rx2_var.set(str(x2)) - ry2_var.set(str(y2)) - region_var.set("Custom Region") - toggle_region() - set_status(f"Focused the scan region around cursor {cursor_x}, {cursor_y}.") - - def open_image_folder(): - image_path = state["image_path"] - if not image_path: - messagebox.showinfo("Photo Clicker", "Choose an image first.", parent=win) - return - try: - os.startfile(os.path.dirname(image_path)) - except Exception as exc: - messagebox.showerror("Open folder failed", f"Unable to open the image folder.\n{exc}", parent=win) + x_start_var.set(str(max(0, cursor_x - half_size))) + y_start_var.set(str(max(0, cursor_y - half_size))) + x_end_var.set(str(min(screen_width, cursor_x + half_size))) + y_end_var.set(str(min(screen_height, cursor_y + half_size))) + set_status(f"Focused the region around cursor {cursor_x}, {cursor_y}.") - def apply_scan_preset(*_args): + def apply_colour_preset(*_args): preset_name = preset_var.get() preset_map = { - "Balanced": ("0.80", "1.00", False, "Click Once", "5"), - "Precision Hunt": ("0.90", "1.20", False, "Click Once", "3"), - "Fast Watch": ("0.72", "0.25", True, "Continuous Watch", "5"), - "Burst Sweep": ("0.78", "0.40", True, "Burst Count", "8"), + "Balanced": ("8", "2", "0.50", "First Match", "25"), + "Pinpoint": ("4", "1", "0.75", "First Match", "10"), + "Sweep": ("12", "3", "0.25", "All Matches", "40"), + "Wide Hunt": ("18", "4", "0.60", "All Matches", "0"), } - confidence, interval, grayscale, mode_name, burst_total = preset_map.get( + tolerance_value, step_value, interval_value, mode_name, max_clicks = preset_map.get( preset_name, preset_map["Balanced"], ) - confidence_var.set(confidence) - interval_var.set(interval) - grayscale_var.set(grayscale) + tolerance_var.set(tolerance_value) + scan_step_var.set(step_value) + interval_var.set(interval_value) mode_var.set(mode_name) - burst_var.set(burst_total) - toggle_burst() + max_clicks_var.set(max_clicks) set_status(f"Preset '{preset_name}' applied.") - def pick_image(): - path = filedialog.askopenfilename( - title="Select reference image", - filetypes=[ - ("Image files", "*.png *.jpg *.jpeg *.bmp *.gif *.tiff"), - ("All files", "*.*"), - ], - parent=win, - ) - if not path: + def update_loupe(): + if not state["is_sampling"]: return + try: + cursor_x, cursor_y = pyautogui.position() + state["loupe_win"].geometry(f"+{cursor_x + 20}+{cursor_y + 20}") + if has_pil_ui: + image = ImageGrab.grab(bbox=(cursor_x - 10, cursor_y - 10, cursor_x + 11, cursor_y + 11)).convert("RGB") + zoomed = image.resize((147, 147), PILImage.NEAREST) + photo = PILImageTk.PhotoImage(zoomed) + state["loupe_canvas"].delete("all") + state["loupe_canvas"].create_image(0, 0, anchor="nw", image=photo) + state["loupe_canvas"].image = photo + state["loupe_canvas"].create_line(73, 0, 73, 147, fill="red") + state["loupe_canvas"].create_line(0, 73, 147, 73, fill="red") + red, green, blue = image.getpixel((10, 10)) + state["loupe_win"].title(f"#{red:02X}{green:02X}{blue:02X}") + except Exception: + pass + master.after(30, update_loupe) - state["image_path"] = path - filepath_var.set(os.path.basename(path)) - set_status(f"Loaded reference image {os.path.basename(path)}.") + def start_sampling(): + if state["is_sampling"]: + return + state["is_sampling"] = True + set_status("Sample colour: click a pixel. Press ESC to cancel.") + master.config(cursor="cross") + loupe_window = tk.Toplevel(master) + loupe_window.overrideredirect(True) + loupe_window.attributes("-topmost", True) + loupe_window.geometry("150x150") + loupe_window.configure(bg="black", highlightbackground="white", highlightthickness=2) + loupe_canvas = tk.Canvas(loupe_window, width=147, height=147, bg="black", highlightthickness=0) + loupe_canvas.pack() + state["loupe_win"] = loupe_window + state["loupe_canvas"] = loupe_canvas + update_loupe() - if Image and ImageTk: + def cancel_sampling(status_message="Sampling cancelled."): + state["is_sampling"] = False + master.config(cursor="") try: - image = Image.open(path) - image.thumbnail((180, 130), Image.LANCZOS) - preview = ImageTk.PhotoImage(image) - state["thumbnail"] = preview - thumb_label.configure(image=preview, text="") + if state["loupe_win"] and state["loupe_win"].winfo_exists(): + state["loupe_win"].destroy() except Exception: - thumb_label.configure(image="", text="(preview unavailable)") - else: - thumb_label.configure(image="", text="(Pillow not installed)") + pass + state["loupe_win"] = None + state["loupe_canvas"] = None + master.unbind("") + try: + if state["sample_hotkey"] is not None and keyboard: + keyboard.remove_hotkey(state["sample_hotkey"]) + except Exception: + pass + state["sample_hotkey"] = None + set_status(status_message) - def parse_region(): - if region_var.get() != "Custom Region": - return None + def perform_sample(_event=None): + x_pos, y_pos = pyautogui.position() + image = ImageGrab.grab(bbox=(x_pos, y_pos, x_pos + 1, y_pos + 1)).convert("RGB") + red, green, blue = image.getpixel((0, 0)) + sampled_hex = "#{:02X}{:02X}{:02X}".format(red, green, blue) + colour_var.set(sampled_hex) + remember_colour(sampled_hex) + update_preview() + cancel_sampling(status_message=f"Sampled colour {sampled_hex}.") + + master.bind("", perform_sample) try: - x1 = int(rx1_var.get()) - y1 = int(ry1_var.get()) - x2 = int(rx2_var.get()) - y2 = int(ry2_var.get()) - except ValueError: - raise ValueError("Region coordinates must be whole numbers.") + state["sample_hotkey"] = keyboard.add_hotkey("esc", lambda: cancel_sampling()) + except Exception: + state["sample_hotkey"] = None - left = min(x1, x2) - top = min(y1, y2) - width = abs(x2 - x1) - height = abs(y2 - y1) - if width == 0 or height == 0: - raise ValueError("Custom region must have non-zero width and height.") - return (left, top, width, height) + def update_preview(*_args): + try: + colour_hex = colour_var.get().strip().upper() + if is_valid_hex_colour(colour_hex): + preview_box.configure(bg=colour_hex) + red = int(colour_hex[1:3], 16) + green = int(colour_hex[3:5], 16) + blue = int(colour_hex[5:7], 16) + rgb_summary_var.set(f"RGB {red}, {green}, {blue}") + else: + preview_box.configure(bg="#e2e8f0") + rgb_summary_var.set("Enter a valid hex colour.") + except Exception: + pass - def worker( - image_path, - confidence, - mode_name, - burst_count, - interval_seconds, - button_name, - click_count, - use_grayscale, - region, - offset_x, - offset_y, - settle_delay, - should_beep, - ): + def worker(target_hex, tolerance, bbox, step, mode_name, is_watcher, interval_seconds, button_name, click_count, stop_hotkey, max_clicks_per_scan): try: - total_found = 0 - total_clicked = 0 - started_at = time.perf_counter() - iteration = 0 + target_rgb = tuple(int(target_hex.lstrip("#")[i:i + 2], 16) for i in (0, 2, 4)) + iterations = 0 + total_clicks = 0 + while not state["stop_event"].is_set(): + iterations += 1 + if stop_hotkey: + try: + if keyboard.is_pressed(stop_hotkey): + break + except Exception: + pass + image = ImageGrab.grab(bbox=bbox).convert("RGB") + found_points = [] + if has_numpy: + arr = np.array(image) + diff = np.abs(arr - target_rgb) + match_mask = np.all(diff <= tolerance, axis=-1) + match_mask = match_mask[::step, ::step] + y_coords, x_coords = np.where(match_mask) + for y_pos, x_pos in zip(y_coords, x_coords): + found_points.append((bbox[0] + x_pos * step, bbox[1] + y_pos * step)) + else: + for y_pos in range(0, image.height, step): + for x_pos in range(0, image.width, step): + red, green, blue = image.getpixel((x_pos, y_pos)) + if all(abs(channel - target) <= tolerance for channel, target in zip((red, green, blue), target_rgb)): + found_points.append((bbox[0] + x_pos, bbox[1] + y_pos)) + if mode_name == "First Match": + break + if mode_name == "First Match" and found_points: + break - while not stop_check(): - iteration += 1 - try: - location = pyautogui.locateOnScreen( - image_path, - confidence=confidence, - grayscale=use_grayscale, - region=region, - ) - except Exception as exc: - if exc.__class__.__name__ == "ImageNotFoundException": - location = None + if found_points: + if mode_name == "First Match": + click_points = found_points[:1] else: - set_status(f"Scan error: {exc}") - location = None - - elapsed = time.perf_counter() - started_at - if location is None: - set_status(f"Image not found on scan #{iteration}.") - set_stats( - f"Scan #{iteration} | found {total_found}x | clicked {total_clicked}x | {elapsed:.2f}s elapsed" - ) + click_points = found_points[:max_clicks_per_scan] if max_clicks_per_scan > 0 else found_points + for point_x, point_y in click_points: + if state["stop_event"].is_set(): + break + pyautogui.click(point_x, point_y, button=button_name, clicks=click_count, interval=0.02 if click_count > 1 else 0.0) + total_clicks += 1 + if mode_name == "All Matches" and max_clicks_per_scan > 0 and len(found_points) > len(click_points): + set_status(f"Found {len(found_points)} match(es), clicked the first {len(click_points)}.") + else: + set_status(f"Found {len(found_points)} match(es).") else: - total_found += 1 - centre_x, centre_y = pyautogui.center(location) - click_x = int(centre_x + offset_x) - click_y = int(centre_y + offset_y) - if settle_delay > 0 and state["stop_event"].wait(settle_delay): - break - pyautogui.click( - x=click_x, - y=click_y, - button=button_name, - clicks=click_count, - interval=0.02 if click_count > 1 else 0.0, - ) - total_clicked += 1 - if should_beep and winsound: - try: - winsound.Beep(980, 60) - except Exception: - pass - set_status( - f"Match found at {centre_x}, {centre_y}. Clicked {click_x}, {click_y}." - ) - set_stats( - f"Scan #{iteration} | found {total_found}x | clicked {total_clicked}x | {elapsed:.2f}s elapsed" - ) + set_status(f"No match in scan #{iterations}.") - if mode_name == "Click Once": - break - if mode_name == "Burst Count" and total_clicked >= burst_count: - set_status(f"Burst complete after {total_clicked} click(s).") + set_stats(f"Scans: {iterations} | Clicks: {total_clicks}") + if not is_watcher: break - if mode_name != "Click Once" and not stop_check(): - state["stop_event"].wait(interval_seconds) + state["stop_event"].wait(interval_seconds) except Exception as exc: - set_status(f"Worker error: {exc}") + set_status(f"Error: {exc}") finally: ui_queue.put(("finish", None)) - def start_scan(): - if state["thread"] and state["thread"].is_alive(): - set_status("A scan is already running.") - return - if not state["image_path"]: - messagebox.showerror("No image", "Select a reference image first.", parent=win) - return - - try: - confidence = float(confidence_var.get()) - if not 0 < confidence <= 1: - raise ValueError - except ValueError: - messagebox.showerror("Invalid confidence", "Confidence must be between 0 and 1.", parent=win) - return - + def start_worker(is_watcher): try: + colour_hex = colour_var.get().strip().upper() + if not is_valid_hex_colour(colour_hex): + raise ValueError("Colour must use the format #RRGGBB.") + tolerance = int(tolerance_var.get()) + step = int(scan_step_var.get()) interval_seconds = float(interval_var.get()) + max_clicks_per_scan = int(max_clicks_var.get()) + if tolerance < 0: + raise ValueError("Tolerance must be zero or greater.") + if step < 1: + raise ValueError("Scan step must be at least 1.") if interval_seconds < 0: - raise ValueError - except ValueError: - messagebox.showerror("Invalid interval", "Scan interval must be zero or greater.", parent=win) - return - - try: - settle_delay = float(settle_delay_var.get()) - if settle_delay < 0: - raise ValueError - except ValueError: - messagebox.showerror("Invalid settle delay", "Settle delay must be zero or greater.", parent=win) - return - - try: - offset_x = int(offset_x_var.get()) - offset_y = int(offset_y_var.get()) - except ValueError: - messagebox.showerror("Invalid click offset", "Offset values must be whole numbers.", parent=win) - return + raise ValueError("Watch interval must be zero or greater.") + if max_clicks_per_scan < 0: + raise ValueError("Max clicks per scan must be zero or greater.") - mode_name = mode_var.get() - try: - burst_count = int(burst_var.get()) if mode_name == "Burst Count" else 1 - if burst_count < 1: - raise ValueError - except ValueError: - messagebox.showerror("Invalid burst count", "Burst count must be at least 1.", parent=win) - return + x1 = int(x_start_var.get()) + y1 = int(y_start_var.get()) + x2 = int(x_end_var.get()) + y2 = int(y_end_var.get()) + left = min(x1, x2) + top = min(y1, y2) + right = max(x1, x2) + bottom = max(y1, y2) + if right <= left or bottom <= top: + raise ValueError("Scan region must have width and height greater than zero.") + bbox = (left, top, right, bottom) - click_map = { - "Left Click": ("left", 1), - "Right Click": ("right", 1), - "Middle Click": ("middle", 1), - "Double Left Click": ("left", 2), - "Double Right Click": ("right", 2), - } - button_name, click_count = click_map.get(button_var.get(), ("left", 1)) + button_name = "left" + if "Right" in button_var.get(): + button_name = "right" + elif "Middle" in button_var.get(): + button_name = "middle" + click_count = 2 if "Double" in button_var.get() else 1 + stop_hotkey = hotkey_var.get().strip() - try: - region = parse_region() + state["stop_event"].clear() + scan_btn.configure(state=DISABLED) + watch_btn.configure(state=DISABLED) + stop_btn.configure(state=NORMAL) + remember_colour(colour_hex) + state["thread"] = threading.Thread( + target=worker, + args=( + colour_hex, + tolerance, + bbox, + step, + mode_var.get(), + is_watcher, + interval_seconds, + button_name, + click_count, + stop_hotkey, + max_clicks_per_scan, + ), + daemon=True, + ) + state["thread"].start() except Exception as exc: - messagebox.showerror("Invalid region", str(exc), parent=win) - return - - state["stop_event"].clear() - set_controls_running(True) - set_status("Scanning for the reference image...") - state["thread"] = threading.Thread( - target=worker, - args=( - state["image_path"], - confidence, - mode_name, - burst_count, - interval_seconds, - button_name, - click_count, - grayscale_var.get(), - region, - offset_x, - offset_y, - settle_delay, - beep_var.get(), - ), - daemon=True, - ) - state["thread"].start() - - def stop_scan(): - if state["thread"] and state["thread"].is_alive(): - state["stop_event"].set() - set_status("Stop requested.") + messagebox.showerror("Error", str(exc), parent=master) def close_window(): state["stop_event"].set() - win.destroy() + if state["is_sampling"]: + master.unbind("") + try: + if state["sample_hotkey"] is not None and keyboard: + keyboard.remove_hotkey(state["sample_hotkey"]) + except Exception: + pass + try: + if state["loupe_win"] and state["loupe_win"].winfo_exists(): + state["loupe_win"].destroy() + except Exception: + pass + master.destroy() - style = ttk.Style(win) + style = ttk.Style(master) try: style.theme_use("clam") except Exception: pass - style.configure("PC.Accent.TButton", background=_ACCENT, foreground="white", padding=(14, 8), font=("Segoe UI", 10, "bold")) - style.map("PC.Accent.TButton", background=[("active", "#115e59"), ("disabled", "#94a3b8")]) - style.configure("PC.Danger.TButton", background=_DANGER, foreground="white", padding=(12, 8), font=("Segoe UI", 10, "bold")) - style.map("PC.Danger.TButton", background=[("active", "#991b1b"), ("disabled", "#94a3b8")]) - style.configure("PC.Secondary.TButton", background="#e2e8f0", foreground="#0f172a", padding=(12, 7), font=("Segoe UI", 10)) - style.map("PC.Secondary.TButton", background=[("active", "#cbd5e1")]) + style.configure("CC.Accent.TButton", background=_ACCENT, foreground="white", padding=(12, 7), font=("Segoe UI", 10, "bold")) + style.map("CC.Accent.TButton", background=[("active", "#115e59"), ("disabled", "#94a3b8")]) + style.configure("CC.Danger.TButton", background=_DANGER, foreground="white", padding=(12, 7), font=("Segoe UI", 10, "bold")) + style.map("CC.Danger.TButton", background=[("active", "#991b1b"), ("disabled", "#94a3b8")]) + style.configure("CC.Secondary.TButton", background="#e2e8f0", foreground="#0f172a", padding=(12, 7), font=("Segoe UI", 10)) + style.map("CC.Secondary.TButton", background=[("active", "#cbd5e1")]) - shell, _photo_canvas = _create_scrollable_shell(win, _BG, min_width=760, padx=18, pady=18) + shell, _colour_canvas = _create_scrollable_shell(master, _BG, min_width=780, padx=20, pady=20) shell.columnconfigure(0, weight=1) - hero = tk.Frame(shell, bg=_HERO_BG, padx=20, pady=16) + hero = tk.Frame(shell, bg=_HERO_BG, padx=20, pady=15) hero.grid(row=0, column=0, sticky="ew") - tk.Label(hero, text="Photo Clicker", bg=_HERO_BG, fg=_HERO_FG, font=("Segoe UI", 16, "bold")).pack(anchor="w") + tk.Label(hero, text="Advanced Colour Clicker", bg=_HERO_BG, fg=_HERO_FG, font=("Segoe UI", 16, "bold")).pack(anchor="w") tk.Label( hero, - text="Find a reference image, click with optional offsets, and narrow the scan area when you need faster automation.", + text="Scan wide regions for a sampled colour, keep recent swatches, and cap how many matches each pass is allowed to click.", bg=_HERO_BG, fg=_HERO_SUB, font=("Segoe UI", 9), - wraplength=680, + wraplength=700, justify=LEFT, - ).pack(anchor="w", pady=(2, 0)) + ).pack(anchor="w") - tool_card = tk.Frame(shell, bg=_CARD_BG, padx=16, pady=16, highlightbackground=_BORDER, highlightthickness=1) - tool_card.grid(row=1, column=0, sticky="ew", pady=(14, 0)) + tool_card = tk.Frame(shell, bg=_CARD_BG, padx=15, pady=15, highlightbackground=_BORDER, highlightthickness=1) + tool_card.grid(row=1, column=0, sticky="ew", pady=(15, 0)) tool_card.columnconfigure(1, weight=1) tool_card.columnconfigure(3, weight=1) - tk.Label(tool_card, text="Scan preset", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=0, column=0, sticky="w") - preset_combo = ttk.Combobox( - tool_card, - textvariable=preset_var, - values=("Balanced", "Precision Hunt", "Fast Watch", "Burst Sweep"), - state="readonly", - width=18, - ) + tk.Label(tool_card, text="Search preset", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=0, column=0, sticky="w") + preset_combo = ttk.Combobox(tool_card, textvariable=preset_var, values=("Balanced", "Pinpoint", "Sweep", "Wide Hunt"), state="readonly", width=18) preset_combo.grid(row=0, column=1, sticky="w", pady=(0, 10)) - ttk.Button(tool_card, text="Apply Preset", style="PC.Secondary.TButton", command=apply_scan_preset).grid(row=0, column=2, sticky="w", padx=(10, 8), pady=(0, 10)) - ttk.Button(tool_card, text="Open Image Folder", style="PC.Secondary.TButton", command=open_image_folder).grid(row=0, column=3, sticky="e", pady=(0, 10)) + ttk.Button(tool_card, text="Apply Preset", style="CC.Secondary.TButton", command=apply_colour_preset).grid(row=0, column=2, sticky="w", padx=(10, 8), pady=(0, 10)) tk.Label(tool_card, text="Focus zone size", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=1, column=0, sticky="w") ttk.Entry(tool_card, textvariable=focus_size_var, width=12).grid(row=1, column=1, sticky="w") - ttk.Button(tool_card, text="Reset To Screen", style="PC.Secondary.TButton", command=lambda: set_full_screen_region(set_mode=True)).grid(row=1, column=2, sticky="w", padx=(10, 8)) - ttk.Button(tool_card, text="Focus Near Cursor", style="PC.Secondary.TButton", command=focus_region_near_cursor).grid(row=1, column=3, sticky="e") - - card = tk.Frame(shell, bg=_CARD_BG, padx=16, pady=16, highlightbackground=_BORDER, highlightthickness=1) - card.grid(row=2, column=0, sticky="nsew", pady=(14, 0)) - card.columnconfigure(1, weight=1) - card.columnconfigure(3, weight=1) + ttk.Button(tool_card, text="Full Screen", style="CC.Secondary.TButton", command=set_full_region).grid(row=1, column=2, sticky="w", padx=(10, 8)) + ttk.Button(tool_card, text="Focus Near Cursor", style="CC.Secondary.TButton", command=focus_region_near_cursor).grid(row=1, column=3, sticky="e") - tk.Label(card, text="Reference image", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=0, column=0, sticky="w", pady=(0, 8)) - tk.Label(card, textvariable=filepath_var, bg=_CARD_BG, fg="#475569", font=("Segoe UI", 10), wraplength=360, justify=LEFT).grid(row=0, column=1, columnspan=2, sticky="w", pady=(0, 8)) - ttk.Button(card, text="Browse...", style="PC.Secondary.TButton", command=pick_image).grid(row=0, column=3, sticky="e", pady=(0, 8)) + card1 = tk.Frame(shell, bg=_CARD_BG, padx=15, pady=15, highlightbackground=_BORDER, highlightthickness=1) + card1.grid(row=2, column=0, sticky="ew", pady=(15, 0)) + card1.columnconfigure(1, weight=1) + tk.Label(card1, text="Target Colour", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=0, column=0, sticky="w", pady=(0, 10)) + colour_row = tk.Frame(card1, bg=_CARD_BG) + colour_row.grid(row=1, column=0, columnspan=2, sticky="ew") + preview_box = tk.Frame(colour_row, width=40, height=40, relief="solid", bd=1, bg="#FFFFFF") + preview_box.grid(row=0, column=0, rowspan=2, sticky="w", padx=(0, 10)) + preview_box.grid_propagate(False) + ttk.Entry(colour_row, textvariable=colour_var, width=12).grid(row=0, column=1, sticky="w", padx=(0, 10)) + ttk.Button(colour_row, text="Pipette", style="CC.Secondary.TButton", command=start_sampling).grid(row=0, column=2, sticky="w", padx=(0, 10)) + tk.Label(colour_row, text="Tolerance", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=0, column=3, sticky="w") + ttk.Entry(colour_row, textvariable=tolerance_var, width=8).grid(row=0, column=4, sticky="w") + tk.Label(colour_row, textvariable=rgb_summary_var, bg=_CARD_BG, fg="#475569", font=("Segoe UI", 9)).grid(row=1, column=1, columnspan=4, sticky="w", pady=(8, 0)) + tk.Label(card1, text="Recent swatches", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=2, column=0, sticky="w", pady=(14, 6)) + history_row = tk.Frame(card1, bg=_CARD_BG) + history_row.grid(row=3, column=0, columnspan=2, sticky="w") - tk.Label(card, text="Preview", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=1, column=0, sticky="nw", pady=(0, 12)) - thumb_label = tk.Label( - card, - text="(no image)", - bg="#e2e8f0", - fg="#64748b", - width=24, - height=8, - relief="solid", - bd=1, - font=("Segoe UI", 9), - ) - thumb_label.grid(row=1, column=1, sticky="w", pady=(0, 12)) - insight_frame = tk.Frame(card, bg=_CARD_BG) - insight_frame.grid(row=1, column=2, columnspan=2, sticky="nsew", padx=(14, 0), pady=(0, 12)) - insight_frame.columnconfigure(0, weight=1) - tk.Label(insight_frame, text="Region summary", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=0, column=0, sticky="w") - tk.Label(insight_frame, textvariable=region_summary_var, bg=_CARD_BG, fg="#475569", font=("Segoe UI", 9), wraplength=270, justify=LEFT).grid(row=1, column=0, sticky="w", pady=(4, 10)) - tk.Label( - insight_frame, - text="Use click offsets when the image itself is not the exact point you want to press.", - bg=_CARD_BG, - fg="#475569", - font=("Segoe UI", 9), - wraplength=270, - justify=LEFT, - ).grid(row=2, column=0, sticky="w") - - tk.Label(card, text="Confidence (0-1)", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=2, column=0, sticky="w", pady=(0, 8)) - ttk.Entry(card, textvariable=confidence_var, width=12).grid(row=2, column=1, sticky="w", pady=(0, 8)) - tk.Label(card, text="Click type", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=2, column=2, sticky="w", padx=(14, 0), pady=(0, 8)) - ttk.Combobox(card, textvariable=button_var, state="readonly", width=18, values=("Left Click", "Right Click", "Middle Click", "Double Left Click", "Double Right Click")).grid(row=2, column=3, sticky="w", pady=(0, 8)) - - tk.Label(card, text="Mode", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=3, column=0, sticky="w", pady=(0, 8)) - mode_combo = ttk.Combobox(card, textvariable=mode_var, state="readonly", width=18, values=("Click Once", "Burst Count", "Continuous Watch")) - mode_combo.grid(row=3, column=1, sticky="w", pady=(0, 8)) - tk.Label(card, text="Burst count", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=3, column=2, sticky="w", padx=(14, 0), pady=(0, 8)) - burst_entry = ttk.Entry(card, textvariable=burst_var, width=12, state=DISABLED) - burst_entry.grid(row=3, column=3, sticky="w", pady=(0, 8)) - - tk.Label(card, text="Scan interval (sec)", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=4, column=0, sticky="w", pady=(0, 8)) - ttk.Entry(card, textvariable=interval_var, width=12).grid(row=4, column=1, sticky="w", pady=(0, 8)) - tk.Label(card, text="Stop hotkey", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=4, column=2, sticky="w", padx=(14, 0), pady=(0, 8)) - ttk.Entry(card, textvariable=hotkey_var, width=12).grid(row=4, column=3, sticky="w", pady=(0, 8)) - - tk.Label(card, text="Click offset X / Y", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=5, column=0, sticky="w", pady=(0, 8)) - offset_row = tk.Frame(card, bg=_CARD_BG) - offset_row.grid(row=5, column=1, sticky="w", pady=(0, 8)) - ttk.Entry(offset_row, textvariable=offset_x_var, width=8).grid(row=0, column=0, padx=(0, 6)) - ttk.Entry(offset_row, textvariable=offset_y_var, width=8).grid(row=0, column=1) - tk.Label(card, text="Settle delay (sec)", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=5, column=2, sticky="w", padx=(14, 0), pady=(0, 8)) - ttk.Entry(card, textvariable=settle_delay_var, width=12).grid(row=5, column=3, sticky="w", pady=(0, 8)) - - ttk.Checkbutton(card, text="Grayscale matching", variable=grayscale_var).grid(row=6, column=0, columnspan=2, sticky="w", pady=(0, 8)) - ttk.Checkbutton(card, text="Beep when a match is clicked", variable=beep_var).grid(row=6, column=2, columnspan=2, sticky="w", pady=(0, 8)) - - tk.Label(card, text="Region", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=7, column=0, sticky="w", pady=(0, 8)) - region_combo = ttk.Combobox(card, textvariable=region_var, state="readonly", width=18, values=("Full Screen", "Custom Region")) - region_combo.grid(row=7, column=1, sticky="w", pady=(0, 8)) - tk.Label( - card, - text="Preset focus creates a square region around your cursor for faster hunts.", - bg=_CARD_BG, - fg="#475569", - font=("Segoe UI", 9), - wraplength=300, - justify=LEFT, - ).grid(row=7, column=2, columnspan=2, sticky="w", padx=(14, 0), pady=(0, 8)) + card2 = tk.Frame(shell, bg=_CARD_BG, padx=15, pady=15, highlightbackground=_BORDER, highlightthickness=1) + card2.grid(row=3, column=0, sticky="ew", pady=(15, 0)) + tk.Label(card2, text="Scan Region", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=0, column=0, sticky="w", pady=(0, 10)) + region_grid = tk.Frame(card2, bg=_CARD_BG) + region_grid.grid(row=1, column=0, sticky="ew") + tk.Label(region_grid, text="X1", bg=_CARD_BG).grid(row=0, column=0, padx=(0, 4)) + ttk.Entry(region_grid, textvariable=x_start_var, width=8).grid(row=0, column=1, padx=(0, 8)) + tk.Label(region_grid, text="Y1", bg=_CARD_BG).grid(row=0, column=2, padx=(0, 4)) + ttk.Entry(region_grid, textvariable=y_start_var, width=8).grid(row=0, column=3, padx=(0, 8)) + tk.Label(region_grid, text="X2", bg=_CARD_BG).grid(row=0, column=4, padx=(0, 4)) + ttk.Entry(region_grid, textvariable=x_end_var, width=8).grid(row=0, column=5, padx=(0, 8)) + tk.Label(region_grid, text="Y2", bg=_CARD_BG).grid(row=0, column=6, padx=(0, 4)) + ttk.Entry(region_grid, textvariable=y_end_var, width=8).grid(row=0, column=7, padx=(0, 8)) + ttk.Button(region_grid, text="Select Area", style="CC.Secondary.TButton", command=select_area).grid(row=0, column=8, padx=(6, 6)) + ttk.Button(region_grid, text="Full Screen", style="CC.Secondary.TButton", command=set_full_region).grid(row=0, column=9, padx=(0, 6)) + ttk.Button(region_grid, text="Focus Cursor", style="CC.Secondary.TButton", command=focus_region_near_cursor).grid(row=0, column=10) + tk.Label(card2, textvariable=region_summary_var, bg=_CARD_BG, fg="#475569", font=("Segoe UI", 9), wraplength=700, justify=LEFT).grid(row=2, column=0, sticky="w", pady=(10, 0)) - region_frame = ttk.LabelFrame(card, text="Custom region", padding=10) - region_frame.grid(row=8, column=0, columnspan=4, sticky="ew", pady=(4, 10)) - ttk.Label(region_frame, text="X1").grid(row=0, column=0, sticky="w") - rx1_entry = ttk.Entry(region_frame, textvariable=rx1_var, width=8, state=DISABLED) - rx1_entry.grid(row=0, column=1, padx=(0, 10)) - ttk.Label(region_frame, text="Y1").grid(row=0, column=2, sticky="w") - ry1_entry = ttk.Entry(region_frame, textvariable=ry1_var, width=8, state=DISABLED) - ry1_entry.grid(row=0, column=3, padx=(0, 10)) - ttk.Label(region_frame, text="X2").grid(row=0, column=4, sticky="w") - rx2_entry = ttk.Entry(region_frame, textvariable=rx2_var, width=8, state=DISABLED) - rx2_entry.grid(row=0, column=5, padx=(0, 10)) - ttk.Label(region_frame, text="Y2").grid(row=0, column=6, sticky="w") - ry2_entry = ttk.Entry(region_frame, textvariable=ry2_var, width=8, state=DISABLED) - ry2_entry.grid(row=0, column=7, padx=(0, 10)) - ttk.Button(region_frame, text="Use Full Screen", style="PC.Secondary.TButton", command=lambda: set_full_screen_region(set_mode=True)).grid(row=1, column=0, columnspan=4, sticky="w", pady=(10, 0)) - ttk.Button(region_frame, text="Focus Near Cursor", style="PC.Secondary.TButton", command=focus_region_near_cursor).grid(row=1, column=4, columnspan=4, sticky="e", pady=(10, 0)) + card3 = tk.Frame(shell, bg=_CARD_BG, padx=15, pady=15, highlightbackground=_BORDER, highlightthickness=1) + card3.grid(row=4, column=0, sticky="ew", pady=(15, 0)) + card3.columnconfigure(1, weight=1) + card3.columnconfigure(3, weight=1) + tk.Label(card3, text="Mode", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=0, column=0, sticky="w", pady=(0, 8)) + ttk.Combobox(card3, textvariable=mode_var, values=("First Match", "All Matches"), state="readonly", width=16).grid(row=0, column=1, sticky="w", pady=(0, 8)) + tk.Label(card3, text="Button", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=0, column=2, sticky="w", padx=(14, 0), pady=(0, 8)) + ttk.Combobox(card3, textvariable=button_var, values=("Left Click", "Right Click", "Double Left", "Middle Click"), state="readonly", width=16).grid(row=0, column=3, sticky="w", pady=(0, 8)) + tk.Label(card3, text="Scan step", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=1, column=0, sticky="w", pady=(0, 8)) + ttk.Entry(card3, textvariable=scan_step_var, width=12).grid(row=1, column=1, sticky="w", pady=(0, 8)) + tk.Label(card3, text="Watch interval", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=1, column=2, sticky="w", padx=(14, 0), pady=(0, 8)) + ttk.Entry(card3, textvariable=interval_var, width=12).grid(row=1, column=3, sticky="w", pady=(0, 8)) + tk.Label(card3, text="Stop hotkey", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=2, column=0, sticky="w", pady=(0, 8)) + ttk.Entry(card3, textvariable=hotkey_var, width=12).grid(row=2, column=1, sticky="w", pady=(0, 8)) + tk.Label(card3, text="Max clicks per scan", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=2, column=2, sticky="w", padx=(14, 0), pady=(0, 8)) + ttk.Entry(card3, textvariable=max_clicks_var, width=12).grid(row=2, column=3, sticky="w", pady=(0, 8)) + tk.Label(card3, text=f"Engine: {'NumPy accelerated' if has_numpy else 'pixel-by-pixel fallback'}", bg=_CARD_BG, fg="#475569", font=("Segoe UI", 9)).grid(row=3, column=0, columnspan=4, sticky="w", pady=(6, 0)) - button_row = tk.Frame(card, bg=_CARD_BG) - button_row.grid(row=9, column=0, columnspan=4, sticky="ew", pady=(6, 0)) - start_btn = ttk.Button(button_row, text="Start Scan", style="PC.Accent.TButton", command=start_scan) - start_btn.grid(row=0, column=0, padx=(0, 8)) - stop_btn = ttk.Button(button_row, text="Stop", style="PC.Danger.TButton", command=stop_scan, state=DISABLED) - stop_btn.grid(row=0, column=1) + button_row = tk.Frame(shell, bg=_BG) + button_row.grid(row=5, column=0, sticky="w", pady=(20, 0)) + scan_btn = ttk.Button(button_row, text="Single Scan", style="CC.Accent.TButton", command=lambda: start_worker(False)) + scan_btn.grid(row=0, column=0, padx=(0, 10)) + watch_btn = ttk.Button(button_row, text="Watch Region", style="CC.Secondary.TButton", command=lambda: start_worker(True)) + watch_btn.grid(row=0, column=1, padx=(0, 10)) + stop_btn = ttk.Button(button_row, text="Stop", style="CC.Danger.TButton", state=DISABLED, command=lambda: state["stop_event"].set()) + stop_btn.grid(row=0, column=2) - status_frame = tk.Frame(shell, bg=_STATUS_BG, padx=16, pady=12) - status_frame.grid(row=3, column=0, sticky="ew", pady=(14, 0)) - tk.Label(status_frame, text="Status", bg=_STATUS_BG, fg=_HERO_FG, font=("Segoe UI", 10, "bold")).grid(row=0, column=0, sticky="w") - tk.Label(status_frame, textvariable=status_var, bg=_STATUS_BG, fg=_STATUS_FG, font=("Segoe UI", 10), wraplength=680, justify=LEFT).grid(row=1, column=0, sticky="w", pady=(4, 0)) - tk.Label(status_frame, textvariable=stats_var, bg=_STATUS_BG, fg=_STATUS_FG, font=("Segoe UI", 9), wraplength=680, justify=LEFT).grid(row=2, column=0, sticky="w", pady=(4, 0)) + status_strip = tk.Frame(shell, bg="#0f172a", padx=15, pady=10) + status_strip.grid(row=6, column=0, sticky="ew", pady=(15, 0)) + tk.Label(status_strip, textvariable=status_var, bg="#0f172a", fg="#f8fafc", font=("Segoe UI", 9), wraplength=700, justify=LEFT).pack(anchor="w") + tk.Label(status_strip, textvariable=stats_var, bg="#0f172a", fg="#94a3b8", font=("Segoe UI", 8), wraplength=700, justify=LEFT).pack(anchor="w") - for tracked_var in (region_var, rx1_var, ry1_var, rx2_var, ry2_var): + colour_var.trace_add("write", update_preview) + for tracked_var in (x_start_var, y_start_var, x_end_var, y_end_var): tracked_var.trace_add("write", update_region_summary) - preset_combo.bind("<>", apply_scan_preset) - mode_combo.bind("<>", toggle_burst) - region_combo.bind("<>", toggle_region) + preset_combo.bind("<>", apply_colour_preset) - toggle_burst() - toggle_region() - pump_queue() - win.protocol("WM_DELETE_WINDOW", close_window) + remember_colour(colour_var.get()) + update_region_summary() + update_preview() + pump_ui_queue() + master.protocol("WM_DELETE_WINDOW", close_window) if owner is None: - win.mainloop() - + master.mainloop() -def Colour_Clicker(): - """Advanced Colour Clicker with scrollable tools, recent swatches, and scan presets.""" +def Locate_Click(): + """Alias for the Photo Clicker tool, kept for the existing menu entry. - if not _ensure_dependencies("Advanced Colour Clicker", ["pyautogui", "keyboard"], parent=tk._default_root): - return - if ImageGrab is None: - _show_dependency_error("Advanced Colour Clicker", ["Pillow"], parent=tk._default_root) - return + It used to run its own dependency check before calling Photo_Clicker, which runs the + same check, so a missing package produced two identical error dialogs. + """ + Photo_Clicker() - _BG = "#edf0f7" - _CARD_BG = "#f8fafc" - _BORDER = "#bfd0e5" - _HERO_BG = "#1e293b" - _HERO_FG = "#f8fafc" - _HERO_SUB = "#94a3b8" - _LABEL_FG = "#0f172a" - _ACCENT = "#0f766e" - _DANGER = "#b91c1c" +def Mega_Spam(): + if not _ensure_dependencies("Mega Spam", ["pyautogui", "keyboard"]): + return owner = tk._default_root master = tk.Toplevel(owner) if owner else tk.Tk() - master.title("Advanced Colour Clicker") - master.geometry("780x820+300+100") - master.minsize(720, 700) + master.title("Mega Spam Mode") + master.geometry("350x200+400+300") master.attributes("-topmost", True) - master.configure(bg=_BG) - try: - master.iconbitmap(_resource_path("favicon.ico")) - except Exception: - pass - - try: - import numpy as np - - has_numpy = True - except ImportError: - has_numpy = False + master.configure(bg="#1e293b") + + state = {"spamming": False, "thread": None} + + def spam_worker(): + while state["spamming"]: + if keyboard.is_pressed("esc"): + state["spamming"] = False + break + pyautogui.click() + btn.config(text="START SPAM", bg="#0f766e") - try: - from PIL import Image as PILImage, ImageTk as PILImageTk + def toggle_spam(): + if state["spamming"]: + state["spamming"] = False + else: + state["spamming"] = True + btn.config(text="STOP (ESC)", bg="#b91c1c") + state["thread"] = threading.Thread(target=spam_worker, daemon=True) + state["thread"].start() - has_pil_ui = True - except ImportError: - PILImage = None - PILImageTk = None - has_pil_ui = False + tk.Label(master, text="MEGA SPAM", font=("Segoe UI", 16, "bold"), bg="#1e293b", fg="#f8fafc").pack(pady=(20, 5)) + tk.Label(master, text="Bypasses normal delays for maximum CPS.", font=("Segoe UI", 9), bg="#1e293b", fg="#cbd5e1").pack() + tk.Label(master, text="Press ESC to force stop.", font=("Segoe UI", 9, "bold"), bg="#1e293b", fg="#ef4444").pack(pady=(0, 20)) + + btn = tk.Button(master, text="START SPAM", font=("Segoe UI", 12, "bold"), bg="#0f766e", fg="white", bd=0, command=toggle_spam) + btn.pack(ipadx=20, ipady=5) - try: - screen_width, screen_height = pyautogui.size() - except Exception: - screen_width, screen_height = 1920, 1080 +def Coordinates_Finder(): + if not _ensure_dependencies("Coordinates Finder", ["pyautogui"]): + return + owner = tk._default_root + master = tk.Toplevel(owner) if owner else tk.Tk() + master.title("Live Coordinates") + master.geometry("250x100+500+100") + master.attributes("-topmost", True) + master.configure(bg="#f8fafc") + + lbl = tk.Label(master, text="X: 0 | Y: 0", font=("Consolas", 18, "bold"), bg="#f8fafc", fg="#0f172a") + lbl.pack(expand=True) + + def update(): + try: + x, y = pyautogui.position() + lbl.config(text=f"X: {x} | Y: {y}") + except: pass + master.after(50, update) + + update() - state = { - "thread": None, - "stop_event": threading.Event(), - "is_sampling": False, - "loupe_win": None, - "loupe_canvas": None, - "sample_hotkey": None, - "history": [], - } - ui_queue = queue.Queue() - - colour_var = tk.StringVar(value="#FFFFFF") - tolerance_var = tk.StringVar(value="8") - x_start_var = tk.StringVar(value="0") - y_start_var = tk.StringVar(value="0") - x_end_var = tk.StringVar(value=str(screen_width)) - y_end_var = tk.StringVar(value=str(screen_height)) - scan_step_var = tk.StringVar(value="2") - interval_var = tk.StringVar(value="0.50") - button_var = tk.StringVar(value="Left Click") - mode_var = tk.StringVar(value="First Match") - hotkey_var = tk.StringVar(value="esc") - preset_var = tk.StringVar(value="Balanced") - max_clicks_var = tk.StringVar(value="25") - focus_size_var = tk.StringVar(value="320") - status_var = tk.StringVar(value="Ready. Sample a colour or select an area.") - stats_var = tk.StringVar(value="No scans yet.") - region_summary_var = tk.StringVar(value="Full screen scan ready.") - rgb_summary_var = tk.StringVar(value="RGB 255, 255, 255") - - def set_status(message): - ui_queue.put(("status", message)) - - def set_stats(message): - ui_queue.put(("stats", message)) - - def pump_ui_queue(): - try: - while True: - action, payload = ui_queue.get_nowait() - if action == "status": - status_var.set(payload) - elif action == "stats": - stats_var.set(payload) - elif action == "finish": - state["thread"] = None - scan_btn.configure(state=NORMAL) - watch_btn.configure(state=NORMAL) - stop_btn.configure(state=DISABLED) - except queue.Empty: - pass - master.after(100, pump_ui_queue) - - def is_valid_hex_colour(value): - value = value.strip() - if len(value) != 7 or not value.startswith("#"): - return False - return all(ch in "0123456789abcdefABCDEF" for ch in value[1:]) - - def remember_colour(value): - value = value.strip().upper() - if not is_valid_hex_colour(value): - return - if value in state["history"]: - state["history"].remove(value) - state["history"].insert(0, value) - state["history"] = state["history"][:6] - refresh_history_buttons() - - def refresh_history_buttons(): - for child in history_row.winfo_children(): - child.destroy() - if not state["history"]: - tk.Label( - history_row, - text="Recent swatches appear here after pipette samples.", - bg=_CARD_BG, - fg="#64748b", - font=("Segoe UI", 9), - ).grid(row=0, column=0, sticky="w") - return - for index, swatch in enumerate(state["history"]): - tk.Button( - history_row, - text=swatch, - bg=swatch, - fg="white" if swatch not in ("#FFFFFF", "#F8FAFC", "#FEF3C7") else "#0f172a", - activebackground=swatch, - activeforeground="white", - relief="flat", - font=("Segoe UI", 8, "bold"), - command=lambda value=swatch: (colour_var.set(value), update_preview(), set_status(f"Loaded swatch {value}.")), - ).grid(row=0, column=index, padx=(0, 6), sticky="w") - - def update_region_summary(*_args): - try: - x1 = int(x_start_var.get()) - y1 = int(y_start_var.get()) - x2 = int(x_end_var.get()) - y2 = int(y_end_var.get()) - width = abs(x2 - x1) - height = abs(y2 - y1) - if width == 0 or height == 0: - raise ValueError - region_summary_var.set(f"Region {width} x {height} from {min(x1, x2)}, {min(y1, y2)}.") - except Exception: - region_summary_var.set("Enter a valid scan region.") - - def select_area(): - overlay = tk.Toplevel(master) - overlay.attributes("-alpha", 0.3) - overlay.attributes("-fullscreen", True) - overlay.attributes("-topmost", True) - overlay.configure(bg="black") - overlay.focus_force() - canvas = tk.Canvas(overlay, cursor="cross", bg="black", highlightthickness=0) - canvas.pack(fill="both", expand=True) - selection = {"x1": 0, "y1": 0, "rect": None} - - def on_down(event): - selection["x1"], selection["y1"] = event.x, event.y - selection["rect"] = canvas.create_rectangle(event.x, event.y, event.x, event.y, outline="white", width=2) - - def on_move(event): - if selection["rect"]: - canvas.coords(selection["rect"], selection["x1"], selection["y1"], event.x, event.y) - - def on_up(event): - x1, y1, x2, y2 = selection["x1"], selection["y1"], event.x, event.y - x_start_var.set(str(min(x1, x2))) - y_start_var.set(str(min(y1, y2))) - x_end_var.set(str(max(x1, x2))) - y_end_var.set(str(max(y1, y2))) - overlay.destroy() - set_status(f"Region set to ({x_start_var.get()}, {y_start_var.get()}) to ({x_end_var.get()}, {y_end_var.get()}).") - - overlay.bind("", on_down) - overlay.bind("", on_move) - overlay.bind("", on_up) - overlay.bind("", lambda _event: overlay.destroy()) - - def set_full_region(): - x_start_var.set("0") - y_start_var.set("0") - x_end_var.set(str(screen_width)) - y_end_var.set(str(screen_height)) - set_status("Scan region reset to the full screen.") - - def focus_region_near_cursor(): - try: - focus_size = max(40, int(focus_size_var.get())) - except ValueError: - messagebox.showerror("Invalid focus size", "Focus size must be a whole number.", parent=master) - return - - try: - cursor_x, cursor_y = pyautogui.position() - except Exception as exc: - messagebox.showerror("Capture failed", f"Unable to read the cursor position.\n{exc}", parent=master) - return - - half_size = focus_size // 2 - x_start_var.set(str(max(0, cursor_x - half_size))) - y_start_var.set(str(max(0, cursor_y - half_size))) - x_end_var.set(str(min(screen_width, cursor_x + half_size))) - y_end_var.set(str(min(screen_height, cursor_y + half_size))) - set_status(f"Focused the region around cursor {cursor_x}, {cursor_y}.") - - def apply_colour_preset(*_args): - preset_name = preset_var.get() - preset_map = { - "Balanced": ("8", "2", "0.50", "First Match", "25"), - "Pinpoint": ("4", "1", "0.75", "First Match", "10"), - "Sweep": ("12", "3", "0.25", "All Matches", "40"), - "Wide Hunt": ("18", "4", "0.60", "All Matches", "0"), - } - tolerance_value, step_value, interval_value, mode_name, max_clicks = preset_map.get( - preset_name, - preset_map["Balanced"], - ) - tolerance_var.set(tolerance_value) - scan_step_var.set(step_value) - interval_var.set(interval_value) - mode_var.set(mode_name) - max_clicks_var.set(max_clicks) - set_status(f"Preset '{preset_name}' applied.") - - def update_loupe(): - if not state["is_sampling"]: - return - try: - cursor_x, cursor_y = pyautogui.position() - state["loupe_win"].geometry(f"+{cursor_x + 20}+{cursor_y + 20}") - if has_pil_ui: - image = ImageGrab.grab(bbox=(cursor_x - 10, cursor_y - 10, cursor_x + 11, cursor_y + 11)).convert("RGB") - zoomed = image.resize((147, 147), PILImage.NEAREST) - photo = PILImageTk.PhotoImage(zoomed) - state["loupe_canvas"].delete("all") - state["loupe_canvas"].create_image(0, 0, anchor="nw", image=photo) - state["loupe_canvas"].image = photo - state["loupe_canvas"].create_line(73, 0, 73, 147, fill="red") - state["loupe_canvas"].create_line(0, 73, 147, 73, fill="red") - red, green, blue = image.getpixel((10, 10)) - state["loupe_win"].title(f"#{red:02X}{green:02X}{blue:02X}") - except Exception: - pass - master.after(30, update_loupe) - - def start_sampling(): - if state["is_sampling"]: - return - state["is_sampling"] = True - set_status("Sample colour: click a pixel. Press ESC to cancel.") - master.config(cursor="cross") - loupe_window = tk.Toplevel(master) - loupe_window.overrideredirect(True) - loupe_window.attributes("-topmost", True) - loupe_window.geometry("150x150") - loupe_window.configure(bg="black", highlightbackground="white", highlightthickness=2) - loupe_canvas = tk.Canvas(loupe_window, width=147, height=147, bg="black", highlightthickness=0) - loupe_canvas.pack() - state["loupe_win"] = loupe_window - state["loupe_canvas"] = loupe_canvas - update_loupe() - - def cancel_sampling(status_message="Sampling cancelled."): - state["is_sampling"] = False - master.config(cursor="") - try: - if state["loupe_win"] and state["loupe_win"].winfo_exists(): - state["loupe_win"].destroy() - except Exception: - pass - state["loupe_win"] = None - state["loupe_canvas"] = None - master.unbind("") - try: - if state["sample_hotkey"] is not None and keyboard: - keyboard.remove_hotkey(state["sample_hotkey"]) - except Exception: - pass - state["sample_hotkey"] = None - set_status(status_message) - - def perform_sample(_event=None): - x_pos, y_pos = pyautogui.position() - image = ImageGrab.grab(bbox=(x_pos, y_pos, x_pos + 1, y_pos + 1)).convert("RGB") - red, green, blue = image.getpixel((0, 0)) - sampled_hex = "#{:02X}{:02X}{:02X}".format(red, green, blue) - colour_var.set(sampled_hex) - remember_colour(sampled_hex) - update_preview() - cancel_sampling(status_message=f"Sampled colour {sampled_hex}.") - - master.bind("", perform_sample) - try: - state["sample_hotkey"] = keyboard.add_hotkey("esc", lambda: cancel_sampling()) - except Exception: - state["sample_hotkey"] = None - - def update_preview(*_args): - try: - colour_hex = colour_var.get().strip().upper() - if is_valid_hex_colour(colour_hex): - preview_box.configure(bg=colour_hex) - red = int(colour_hex[1:3], 16) - green = int(colour_hex[3:5], 16) - blue = int(colour_hex[5:7], 16) - rgb_summary_var.set(f"RGB {red}, {green}, {blue}") - else: - preview_box.configure(bg="#e2e8f0") - rgb_summary_var.set("Enter a valid hex colour.") - except Exception: - pass - - def worker(target_hex, tolerance, bbox, step, mode_name, is_watcher, interval_seconds, button_name, click_count, stop_hotkey, max_clicks_per_scan): - try: - target_rgb = tuple(int(target_hex.lstrip("#")[i:i + 2], 16) for i in (0, 2, 4)) - iterations = 0 - total_clicks = 0 - while not state["stop_event"].is_set(): - iterations += 1 - if stop_hotkey: - try: - if keyboard.is_pressed(stop_hotkey): - break - except Exception: - pass - image = ImageGrab.grab(bbox=bbox).convert("RGB") - found_points = [] - if has_numpy: - arr = np.array(image) - diff = np.abs(arr - target_rgb) - match_mask = np.all(diff <= tolerance, axis=-1) - match_mask = match_mask[::step, ::step] - y_coords, x_coords = np.where(match_mask) - for y_pos, x_pos in zip(y_coords, x_coords): - found_points.append((bbox[0] + x_pos * step, bbox[1] + y_pos * step)) - else: - for y_pos in range(0, image.height, step): - for x_pos in range(0, image.width, step): - red, green, blue = image.getpixel((x_pos, y_pos)) - if all(abs(channel - target) <= tolerance for channel, target in zip((red, green, blue), target_rgb)): - found_points.append((bbox[0] + x_pos, bbox[1] + y_pos)) - if mode_name == "First Match": - break - if mode_name == "First Match" and found_points: - break - - if found_points: - if mode_name == "First Match": - click_points = found_points[:1] - else: - click_points = found_points[:max_clicks_per_scan] if max_clicks_per_scan > 0 else found_points - for point_x, point_y in click_points: - if state["stop_event"].is_set(): - break - pyautogui.click(point_x, point_y, button=button_name, clicks=click_count, interval=0.02 if click_count > 1 else 0.0) - total_clicks += 1 - if mode_name == "All Matches" and max_clicks_per_scan > 0 and len(found_points) > len(click_points): - set_status(f"Found {len(found_points)} match(es), clicked the first {len(click_points)}.") - else: - set_status(f"Found {len(found_points)} match(es).") - else: - set_status(f"No match in scan #{iterations}.") - - set_stats(f"Scans: {iterations} | Clicks: {total_clicks}") - if not is_watcher: - break - state["stop_event"].wait(interval_seconds) - except Exception as exc: - set_status(f"Error: {exc}") - finally: - ui_queue.put(("finish", None)) - - def start_worker(is_watcher): - try: - colour_hex = colour_var.get().strip().upper() - if not is_valid_hex_colour(colour_hex): - raise ValueError("Colour must use the format #RRGGBB.") - tolerance = int(tolerance_var.get()) - step = int(scan_step_var.get()) - interval_seconds = float(interval_var.get()) - max_clicks_per_scan = int(max_clicks_var.get()) - if tolerance < 0: - raise ValueError("Tolerance must be zero or greater.") - if step < 1: - raise ValueError("Scan step must be at least 1.") - if interval_seconds < 0: - raise ValueError("Watch interval must be zero or greater.") - if max_clicks_per_scan < 0: - raise ValueError("Max clicks per scan must be zero or greater.") - - x1 = int(x_start_var.get()) - y1 = int(y_start_var.get()) - x2 = int(x_end_var.get()) - y2 = int(y_end_var.get()) - left = min(x1, x2) - top = min(y1, y2) - right = max(x1, x2) - bottom = max(y1, y2) - if right <= left or bottom <= top: - raise ValueError("Scan region must have width and height greater than zero.") - bbox = (left, top, right, bottom) - - button_name = "left" - if "Right" in button_var.get(): - button_name = "right" - elif "Middle" in button_var.get(): - button_name = "middle" - click_count = 2 if "Double" in button_var.get() else 1 - stop_hotkey = hotkey_var.get().strip() - - state["stop_event"].clear() - scan_btn.configure(state=DISABLED) - watch_btn.configure(state=DISABLED) - stop_btn.configure(state=NORMAL) - remember_colour(colour_hex) - state["thread"] = threading.Thread( - target=worker, - args=( - colour_hex, - tolerance, - bbox, - step, - mode_var.get(), - is_watcher, - interval_seconds, - button_name, - click_count, - stop_hotkey, - max_clicks_per_scan, - ), - daemon=True, - ) - state["thread"].start() - except Exception as exc: - messagebox.showerror("Error", str(exc), parent=master) - - def close_window(): - state["stop_event"].set() - if state["is_sampling"]: - master.unbind("") - try: - if state["sample_hotkey"] is not None and keyboard: - keyboard.remove_hotkey(state["sample_hotkey"]) - except Exception: - pass - try: - if state["loupe_win"] and state["loupe_win"].winfo_exists(): - state["loupe_win"].destroy() - except Exception: - pass - master.destroy() - - style = ttk.Style(master) - try: - style.theme_use("clam") - except Exception: - pass - style.configure("CC.Accent.TButton", background=_ACCENT, foreground="white", padding=(12, 7), font=("Segoe UI", 10, "bold")) - style.map("CC.Accent.TButton", background=[("active", "#115e59"), ("disabled", "#94a3b8")]) - style.configure("CC.Danger.TButton", background=_DANGER, foreground="white", padding=(12, 7), font=("Segoe UI", 10, "bold")) - style.map("CC.Danger.TButton", background=[("active", "#991b1b"), ("disabled", "#94a3b8")]) - style.configure("CC.Secondary.TButton", background="#e2e8f0", foreground="#0f172a", padding=(12, 7), font=("Segoe UI", 10)) - style.map("CC.Secondary.TButton", background=[("active", "#cbd5e1")]) - - shell, _colour_canvas = _create_scrollable_shell(master, _BG, min_width=780, padx=20, pady=20) - shell.columnconfigure(0, weight=1) - - hero = tk.Frame(shell, bg=_HERO_BG, padx=20, pady=15) - hero.grid(row=0, column=0, sticky="ew") - tk.Label(hero, text="Advanced Colour Clicker", bg=_HERO_BG, fg=_HERO_FG, font=("Segoe UI", 16, "bold")).pack(anchor="w") - tk.Label( - hero, - text="Scan wide regions for a sampled colour, keep recent swatches, and cap how many matches each pass is allowed to click.", - bg=_HERO_BG, - fg=_HERO_SUB, - font=("Segoe UI", 9), - wraplength=700, - justify=LEFT, - ).pack(anchor="w") - - tool_card = tk.Frame(shell, bg=_CARD_BG, padx=15, pady=15, highlightbackground=_BORDER, highlightthickness=1) - tool_card.grid(row=1, column=0, sticky="ew", pady=(15, 0)) - tool_card.columnconfigure(1, weight=1) - tool_card.columnconfigure(3, weight=1) - tk.Label(tool_card, text="Search preset", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=0, column=0, sticky="w") - preset_combo = ttk.Combobox(tool_card, textvariable=preset_var, values=("Balanced", "Pinpoint", "Sweep", "Wide Hunt"), state="readonly", width=18) - preset_combo.grid(row=0, column=1, sticky="w", pady=(0, 10)) - ttk.Button(tool_card, text="Apply Preset", style="CC.Secondary.TButton", command=apply_colour_preset).grid(row=0, column=2, sticky="w", padx=(10, 8), pady=(0, 10)) - tk.Label(tool_card, text="Focus zone size", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=1, column=0, sticky="w") - ttk.Entry(tool_card, textvariable=focus_size_var, width=12).grid(row=1, column=1, sticky="w") - ttk.Button(tool_card, text="Full Screen", style="CC.Secondary.TButton", command=set_full_region).grid(row=1, column=2, sticky="w", padx=(10, 8)) - ttk.Button(tool_card, text="Focus Near Cursor", style="CC.Secondary.TButton", command=focus_region_near_cursor).grid(row=1, column=3, sticky="e") - - card1 = tk.Frame(shell, bg=_CARD_BG, padx=15, pady=15, highlightbackground=_BORDER, highlightthickness=1) - card1.grid(row=2, column=0, sticky="ew", pady=(15, 0)) - card1.columnconfigure(1, weight=1) - tk.Label(card1, text="Target Colour", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=0, column=0, sticky="w", pady=(0, 10)) - colour_row = tk.Frame(card1, bg=_CARD_BG) - colour_row.grid(row=1, column=0, columnspan=2, sticky="ew") - preview_box = tk.Frame(colour_row, width=40, height=40, relief="solid", bd=1, bg="#FFFFFF") - preview_box.grid(row=0, column=0, rowspan=2, sticky="w", padx=(0, 10)) - preview_box.grid_propagate(False) - ttk.Entry(colour_row, textvariable=colour_var, width=12).grid(row=0, column=1, sticky="w", padx=(0, 10)) - ttk.Button(colour_row, text="Pipette", style="CC.Secondary.TButton", command=start_sampling).grid(row=0, column=2, sticky="w", padx=(0, 10)) - tk.Label(colour_row, text="Tolerance", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=0, column=3, sticky="w") - ttk.Entry(colour_row, textvariable=tolerance_var, width=8).grid(row=0, column=4, sticky="w") - tk.Label(colour_row, textvariable=rgb_summary_var, bg=_CARD_BG, fg="#475569", font=("Segoe UI", 9)).grid(row=1, column=1, columnspan=4, sticky="w", pady=(8, 0)) - tk.Label(card1, text="Recent swatches", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=2, column=0, sticky="w", pady=(14, 6)) - history_row = tk.Frame(card1, bg=_CARD_BG) - history_row.grid(row=3, column=0, columnspan=2, sticky="w") - - card2 = tk.Frame(shell, bg=_CARD_BG, padx=15, pady=15, highlightbackground=_BORDER, highlightthickness=1) - card2.grid(row=3, column=0, sticky="ew", pady=(15, 0)) - tk.Label(card2, text="Scan Region", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=0, column=0, sticky="w", pady=(0, 10)) - region_grid = tk.Frame(card2, bg=_CARD_BG) - region_grid.grid(row=1, column=0, sticky="ew") - tk.Label(region_grid, text="X1", bg=_CARD_BG).grid(row=0, column=0, padx=(0, 4)) - ttk.Entry(region_grid, textvariable=x_start_var, width=8).grid(row=0, column=1, padx=(0, 8)) - tk.Label(region_grid, text="Y1", bg=_CARD_BG).grid(row=0, column=2, padx=(0, 4)) - ttk.Entry(region_grid, textvariable=y_start_var, width=8).grid(row=0, column=3, padx=(0, 8)) - tk.Label(region_grid, text="X2", bg=_CARD_BG).grid(row=0, column=4, padx=(0, 4)) - ttk.Entry(region_grid, textvariable=x_end_var, width=8).grid(row=0, column=5, padx=(0, 8)) - tk.Label(region_grid, text="Y2", bg=_CARD_BG).grid(row=0, column=6, padx=(0, 4)) - ttk.Entry(region_grid, textvariable=y_end_var, width=8).grid(row=0, column=7, padx=(0, 8)) - ttk.Button(region_grid, text="Select Area", style="CC.Secondary.TButton", command=select_area).grid(row=0, column=8, padx=(6, 6)) - ttk.Button(region_grid, text="Full Screen", style="CC.Secondary.TButton", command=set_full_region).grid(row=0, column=9, padx=(0, 6)) - ttk.Button(region_grid, text="Focus Cursor", style="CC.Secondary.TButton", command=focus_region_near_cursor).grid(row=0, column=10) - tk.Label(card2, textvariable=region_summary_var, bg=_CARD_BG, fg="#475569", font=("Segoe UI", 9), wraplength=700, justify=LEFT).grid(row=2, column=0, sticky="w", pady=(10, 0)) - - card3 = tk.Frame(shell, bg=_CARD_BG, padx=15, pady=15, highlightbackground=_BORDER, highlightthickness=1) - card3.grid(row=4, column=0, sticky="ew", pady=(15, 0)) - card3.columnconfigure(1, weight=1) - card3.columnconfigure(3, weight=1) - tk.Label(card3, text="Mode", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=0, column=0, sticky="w", pady=(0, 8)) - ttk.Combobox(card3, textvariable=mode_var, values=("First Match", "All Matches"), state="readonly", width=16).grid(row=0, column=1, sticky="w", pady=(0, 8)) - tk.Label(card3, text="Button", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=0, column=2, sticky="w", padx=(14, 0), pady=(0, 8)) - ttk.Combobox(card3, textvariable=button_var, values=("Left Click", "Right Click", "Double Left", "Middle Click"), state="readonly", width=16).grid(row=0, column=3, sticky="w", pady=(0, 8)) - tk.Label(card3, text="Scan step", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=1, column=0, sticky="w", pady=(0, 8)) - ttk.Entry(card3, textvariable=scan_step_var, width=12).grid(row=1, column=1, sticky="w", pady=(0, 8)) - tk.Label(card3, text="Watch interval", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=1, column=2, sticky="w", padx=(14, 0), pady=(0, 8)) - ttk.Entry(card3, textvariable=interval_var, width=12).grid(row=1, column=3, sticky="w", pady=(0, 8)) - tk.Label(card3, text="Stop hotkey", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=2, column=0, sticky="w", pady=(0, 8)) - ttk.Entry(card3, textvariable=hotkey_var, width=12).grid(row=2, column=1, sticky="w", pady=(0, 8)) - tk.Label(card3, text="Max clicks per scan", bg=_CARD_BG, fg=_LABEL_FG, font=("Segoe UI", 10, "bold")).grid(row=2, column=2, sticky="w", padx=(14, 0), pady=(0, 8)) - ttk.Entry(card3, textvariable=max_clicks_var, width=12).grid(row=2, column=3, sticky="w", pady=(0, 8)) - tk.Label(card3, text=f"Engine: {'NumPy accelerated' if has_numpy else 'pixel-by-pixel fallback'}", bg=_CARD_BG, fg="#475569", font=("Segoe UI", 9)).grid(row=3, column=0, columnspan=4, sticky="w", pady=(6, 0)) - - button_row = tk.Frame(shell, bg=_BG) - button_row.grid(row=5, column=0, sticky="w", pady=(20, 0)) - scan_btn = ttk.Button(button_row, text="Single Scan", style="CC.Accent.TButton", command=lambda: start_worker(False)) - scan_btn.grid(row=0, column=0, padx=(0, 10)) - watch_btn = ttk.Button(button_row, text="Watch Region", style="CC.Secondary.TButton", command=lambda: start_worker(True)) - watch_btn.grid(row=0, column=1, padx=(0, 10)) - stop_btn = ttk.Button(button_row, text="Stop", style="CC.Danger.TButton", state=DISABLED, command=lambda: state["stop_event"].set()) - stop_btn.grid(row=0, column=2) - - status_strip = tk.Frame(shell, bg="#0f172a", padx=15, pady=10) - status_strip.grid(row=6, column=0, sticky="ew", pady=(15, 0)) - tk.Label(status_strip, textvariable=status_var, bg="#0f172a", fg="#f8fafc", font=("Segoe UI", 9), wraplength=700, justify=LEFT).pack(anchor="w") - tk.Label(status_strip, textvariable=stats_var, bg="#0f172a", fg="#94a3b8", font=("Segoe UI", 8), wraplength=700, justify=LEFT).pack(anchor="w") - - colour_var.trace_add("write", update_preview) - for tracked_var in (x_start_var, y_start_var, x_end_var, y_end_var): - tracked_var.trace_add("write", update_region_summary) - preset_combo.bind("<>", apply_colour_preset) - - remember_colour(colour_var.get()) - update_region_summary() - update_preview() - pump_ui_queue() - master.protocol("WM_DELETE_WINDOW", close_window) - if owner is None: - master.mainloop() - -def Locate_Click(): - """Wrapper for the advanced Photo Clicker tool.""" - if not _ensure_dependencies("Locate and Click", ["pyautogui", "keyboard"]): - return - Photo_Clicker() - - -def Mega_Spam(): - if not _ensure_dependencies("Mega Spam", ["pyautogui", "keyboard"]): - return - owner = tk._default_root - master = tk.Toplevel(owner) if owner else tk.Tk() - master.title("Mega Spam Mode") - master.geometry("350x200+400+300") - master.attributes("-topmost", True) - master.configure(bg="#1e293b") - - state = {"spamming": False, "thread": None} - - def spam_worker(): - while state["spamming"]: - if keyboard.is_pressed("esc"): - state["spamming"] = False - break - pyautogui.click() - btn.config(text="START SPAM", bg="#0f766e") - - def toggle_spam(): - if state["spamming"]: - state["spamming"] = False - else: - state["spamming"] = True - btn.config(text="STOP (ESC)", bg="#b91c1c") - state["thread"] = threading.Thread(target=spam_worker, daemon=True) - state["thread"].start() - - tk.Label(master, text="MEGA SPAM", font=("Segoe UI", 16, "bold"), bg="#1e293b", fg="#f8fafc").pack(pady=(20, 5)) - tk.Label(master, text="Bypasses normal delays for maximum CPS.", font=("Segoe UI", 9), bg="#1e293b", fg="#cbd5e1").pack() - tk.Label(master, text="Press ESC to force stop.", font=("Segoe UI", 9, "bold"), bg="#1e293b", fg="#ef4444").pack(pady=(0, 20)) - - btn = tk.Button(master, text="START SPAM", font=("Segoe UI", 12, "bold"), bg="#0f766e", fg="white", bd=0, command=toggle_spam) - btn.pack(ipadx=20, ipady=5) - -def Coordinates_Finder(): - if not _ensure_dependencies("Coordinates Finder", ["pyautogui"]): - return - owner = tk._default_root - master = tk.Toplevel(owner) if owner else tk.Tk() - master.title("Live Coordinates") - master.geometry("250x100+500+100") - master.attributes("-topmost", True) - master.configure(bg="#f8fafc") - - lbl = tk.Label(master, text="X: 0 | Y: 0", font=("Consolas", 18, "bold"), bg="#f8fafc", fg="#0f172a") - lbl.pack(expand=True) - - def update(): - try: - x, y = pyautogui.position() - lbl.config(text=f"X: {x} | Y: {y}") - except: pass - master.after(50, update) - - update() - -def Contact_Page(): - owner = tk._default_root - master = tk.Toplevel(owner) if owner else tk.Tk() - master.title("Contact & Feedback") - master.geometry("400x250+400+300") - master.configure(bg="#eef2ff") - - tk.Label(master, text="We'd love to hear from you!", font=("Segoe UI", 14, "bold"), bg="#eef2ff", fg="#1e3a8a").pack(pady=20) - - def open_github(): webbrowser.open("https://github.com/kai9987kai") - def open_email(): webbrowser.open("mailto:feedback@example.com") - - tk.Button(master, text="Visit GitHub Profile", font=("Segoe UI", 10), bg="#1e293b", fg="white", command=open_github).pack(pady=10, ipadx=10, ipady=5) - tk.Button(master, text="Send Email Feedback", font=("Segoe UI", 10), bg="#0f766e", fg="white", command=open_email).pack(pady=10, ipadx=10, ipady=5) - -def Help_Page(): - owner = tk._default_root - master = tk.Toplevel(owner) if owner else tk.Tk() - master.title("AutoClicker Help") - master.geometry("500x400+350+200") - master.configure(bg="#f8fafc") - - txt = tk.Text(master, font=("Segoe UI", 10), bg="#f8fafc", fg="#334155", wrap="word", padx=20, pady=20, bd=0) - txt.pack(fill="both", expand=True) - - help_text = """AutoClicker Innovation Suite Help - -1. Basic Clicking: -Set the Target X and Y (or leave empty to click at the current cursor). Set the delay between clicks, and choose the click button. Press Start or use your hotkey. - -2. Photo Clicker: -Open from the Tools menu. This allows you to select an image from your disk and the app will scan your screen for it and click it. - -3. Colour Clicker: -Advanced mode to click specific pixels. Use the 'Pipette' to sample a colour from your screen. - -4. Mega Spam: -Use this mode when you need the absolute maximum clicks per second, ignoring all safety delays. - -5. Recording: -Use the 'Record' button in the Quick Tools to capture a sequence of points (press 'R' to capture), then replay them.""" - txt.insert("1.0", help_text) - txt.config(state="disabled") - -def feedback(): - Contact_Page() - - -def NOTIFICATION(): - try: - toaster = win10toast.ToastNotifier() - toaster.show_toast("AutoClicker", APP_VERSION, duration=5, threaded=True, icon_path=_resource_path("favicon.ico")) - messagebox.showinfo('AutoClicker', APP_VERSION) - except: - messagebox.showinfo('AutoClicker', APP_VERSION) - pass - - -def tutorial(): - Help_Page() - -def OldStyleGUI(): - if not _ensure_dependencies("Old Style GUI", ["pyautogui", "keyboard"]): - return - class YourGUI(tk.Tk): - def __init__(self): - # inherit tkinter's window methods - tk.Tk.__init__(self) - - tk.Label(self, text="ENTER Y:", background="#e7dff2").grid(row=0, column=2) - self.inputX = tk.Entry(self) - self.inputX.grid(row=0, column=1) - - tk.Label(self, text="ENTER X:", background="#e7dff2").grid(row=0, column=0) - self.inputY = tk.Entry(self) - self.inputY.grid(row=0, column=3) - - self.cmb = ttk.Combobox(self, width="10", values=("Left Click","Right Click","Middle Click","Double Right Click","Double Left Click","Double Middle Click")) - ttk.Label(self, text="Delay Between clicks", background="#e7dff2", anchor=E).grid(row=7, column=0) - self.inputdelayentry = tk.StringVar() - - - - self.inputdelayentry.set("0") - - - self.inputdelay = tk.Entry(self, textvariable= self.inputdelayentry).grid(row=7, column=1) - - - class TableDropDown(ttk.Combobox): - def __init__(self, parent): - self.current_table = tk.StringVar() # create variable for table - ttk.Combobox.__init__(self, parent)# init widget - self.config(textvariable = self.current_table, state = "readonly", values = ["Customers", "Pets", "Invoices", "Prices"]) - self.current(0) # index of values for current table - self.place(x = 50, y = 50, anchor = "w") # place drop down box - - ttk.Label(self, text="""Choose the left or -right mouse button""", background="#e7dff2", anchor=E).grid(row=1, column=0) - self.cmb.grid(row=1, column=1, sticky="ew") - self.cmb.current(0) - - - - # Start Button ⬇ - tk.Button(self, text="start", fg='green', command=self.startclick).grid(row=7, column=0) - # close button ⬇ - tk.Button(self, text="exit!", fg='red', command=self.EXITME).grid(row=7, column=0) - - self.inputhotkey = tk.Entry(self) - self.inputhotkey.grid(row=1, column=3, columnspan=1) - - - def callback(): - webbrowser.open_new(r"https://kai9987kai.github.io/AutoClicker.html") - - def callback2(): - webbrowser.open_new(r"https://github.com/kai9987kai/AutoClicker") - - tk.Button(self, text="ABOUT", command=callback).grid(row=5, column=3, sticky="ew") - - def clicked3(): - your_gui.destroy() - pyautogui.PAUSE = 0.50 - pyautogui.FAILSAFE = True - - things = [] - root = Tk() - root.geometry("550x425") - - list_box = Listbox(root, font=(12)) - list_box.config(width=30, height=18) - list_box.place(x=0, y=0) - - run_btn = Button(root, text="Run List", command=lambda: run_list(), font=(12)) - run_btn.place(x=350, y=15) - run_btn.config(width=15) - - del_btn = Button(root, text="Delete", command=lambda: delete(list_box), font=(12)) - del_btn.place(x=350, y=105) - del_btn.config(width=15) - - add_btn = Button(root, text="Add", command=lambda: add(), font=(12)) - add_btn.place(x=350, y=60) - add_btn.config(width=15) - - x_txt = StringVar() - y_txt = StringVar() - - x_label = Label(root, text="x", font=(12)) - x_label.place(x=350, y=150) - - x = Entry(root, textvariable=x_txt) - x.place(x=375, y=150) - - x_txt.set('') - y_label = Label(root, text="y", font=(12)) - y_label.place(x=350, y=180) - - y = Entry(root, textvariable=y_txt) - y.place(x=375, y=180) - y_txt.set('') - - cmb = ttk.Combobox(root, width="15", values=("Left Click","Right Click","Middle Click","Double Right Click","Double Left Click","Double Middle Click")) - ttk.Label(root, text="""Select whether to right or -left click the list""", anchor=E).place(x=350, y=220) - cmb.place(x=350, y=265) - cmb.current(0) - - def add(): - content_x = x_txt.get() - content_y = y_txt.get() - closed_str = [content_x, content_y] - things.append(closed_str) - list_box.delete(0, 'end') - for i in range(len(things)): - list_box.insert(END, things[i]) - - def run_list(): - x_cords = [item[0] for item in things] - y_cords = [item[1] for item in things] - - for i in range(len(things)): - if cmb.get() == "Left Click": - screenWidth, screenHeight = pyautogui.size() - currentMouseX, currentMouseY = pyautogui.position() - pyautogui.moveTo(int(x_cords[i]), int(y_cords[i])) - # print("Gonna Click",x_cords[i],y_cords[i]) - pyautogui.click() - - - elif cmb.get() == "Right Click": - screenWidth, screenHeight = pyautogui.size() - currentMouseX, currentMouseY = pyautogui.position() - pyautogui.moveTo(int(x_cords[i]), int(y_cords[i])) - # print("Gonna Click",x_cords[i],y_cords[i]) - pyautogui.click(button='right') - pyautogui.click() - - elif cmb.get() == "Middle Click": - - screenWidth, screenHeight = pyautogui.size() - currentMouseX, currentMouseY = pyautogui.position() - pyautogui.moveTo(int(x_cords[i]), int(y_cords[i])) - # print("Gonna Click",x_cords[i],y_cords[i]) - pyautogui.click(button='middle') - pyautogui.click() - elif cmb.get() == "Double Right Click": - - screenWidth, screenHeight = pyautogui.size() - currentMouseX, currentMouseY = pyautogui.position() - pyautogui.moveTo(int(x_cords[i]), int(y_cords[i])) - # print("Gonna Click",x_cords[i],y_cords[i]) - pyautogui.click(clicks=2) - pyautogui.click(button='right') - pyautogui.click() - elif cmb.get() == "Double Left Click": - - screenWidth, screenHeight = pyautogui.size() - currentMouseX, currentMouseY = pyautogui.position() - pyautogui.moveTo(int(x_cords[i]), int(y_cords[i])) - # print("Gonna Click",x_cords[i],y_cords[i]) - pyautogui.click(clicks=2) - pyautogui.click(button='left') - pyautogui.click() - elif cmb.get() == "Double Middle Click": - - screenWidth, screenHeight = pyautogui.size() - currentMouseX, currentMouseY = pyautogui.position() - pyautogui.moveTo(int(x_cords[i]), int(y_cords[i])) - # print("Gonna Click",x_cords[i],y_cords[i]) - pyautogui.click(clicks=2) - pyautogui.click(button='middle') - pyautogui.click() - - - - def delete(listbox): - global things - # Delete from Listbox - selection = list_box.curselection() - list_box.delete(selection[0]) - # Delete from list that provided it - value = eval(list_box.get(selection[0])) - ind = things.index(value) - del (things[ind]) - - popup = Menu(root, tearoff=0) - popup.add_command(label='Run list', command=run_list) - popup.add_command(label='Exit', command=self.EXITME) - - def do_popup(event): - # display the popup menu - try: - popup.tk_popup(event.x_root, event.y_root, 0) - finally: - # make sure to release the grab (Tk 8.0a1 only) - popup.grab_release() - - root.bind("", do_popup) - - root.title("AutoClicker - list of coordinates") - try: - root.iconbitmap(_resource_path("favicon.ico")) - - except: - pass - root.resizable(False, False) - root.attributes("-topmost", True) - root.mainloop() - def Finder(): - Coordinates_Finder() - - tk.Button(self, text="List Coordinates", command=clicked3).grid(row=7, column=3,sticky="ew") - tk.Button(self, text="Find Coordinates", command=Finder).grid(row=6, column=3, sticky="ew") - tk.Button(self, text="Advanced Colour Clicker", command=Colour_Clicker).grid(row=7, column=1, columnspan=2, sticky="ew", padx=4) - - def clicked(): - Contact_Page() - - def settings(): - window = Tk() - window.title("Settings") - window.geometry('335x130') - try: - window.iconbitmap(_resource_path("favicon.ico")) - except: - pass - window.resizable(False, False) - window.geometry("+0+0") - window.attributes("-topmost", True) - - - def callBackFunc(): - your_gui.overrideredirect(True) - window.destroy() - - def ExitWindow(): - window.destroy() - - def Full_screen(): - your_gui.attributes('-fullscreen', True) - your_gui.bind('', lambda e: root.destroy()) - window.destroy() - def Exit_Full_Screen(): - python = sys.executable - os.execl(python, python, * sys.argv) - def Show_Title_bar(): - python = sys.executable - os.execl(python, python, * sys.argv) - - - ttk.Label(window, text="Settings").grid(column=0, row=1, sticky="ew") - Button(window, text=" Exit Settings ", command=ExitWindow).grid(column=0, row= 6) - Button(window, text="‍‍FullScreen", command=Full_screen).grid(column=0, row=3, sticky="ew") - Button(window, text="Exit FullScreen", command=Exit_Full_Screen).grid(column=1, row=3, sticky="ew") - Button(window, text="Hide Title Bar ", command=callBackFunc).grid(column=0, row=5, sticky="ew") - Button(window, text="Show Title Bar", command=Show_Title_bar).grid(column=1, row=5, sticky="ew") - Button(window, text="Restart program", command=Show_Title_bar).grid(column=1, row=6, sticky="ew") - - popup = Menu(your_gui, tearoff=0) - popup.add_command(label="FullScreen", command=Full_screen) - popup.add_command(label="Exit FullScreen", command=Exit_Full_Screen) - popup.add_command(label="Hide Title Bar", command=callBackFunc) - popup.add_command(label="Show Title Bar", command=Show_Title_bar) - popup.add_command(label="Restart program", command=Show_Title_bar) - popup.add_command(label="Exit Settings", command=ExitWindow) - - def do_popup(event): - - try: - - popup.tk_popup(event.x_root, event.y_root, 0) - finally: - popup.grab_release() - - window.bind("", do_popup) - - window.mainloop() - - - def OpenModernWindow(): - your_gui.destroy() - MAINWINDOW_REDESIGNED() - - def clicked2(): - Mega_Spam() - - # Menu Bar!! ⬇ - menu = Menu(self) - new_item = Menu(menu) - new_item.add_command(label='About', command=callback) - new_item.add_command(label='Github Page', command=callback2) - new_item.add_command(label='List Coordinates', command=clicked3) - new_item.add_command(label='Version Number', command=NOTIFICATION) - new_item.add_command(label='Modern Style', command=OpenModernWindow) - new_item.add_command(label='Auto Clicker Mega Spam', command=clicked2) - new_item.add_command(label='Coordinates Finder', command=Finder) - new_item.add_command(label='Send Feedback', command=feedback) - new_item.add_command(label='Locate and Click', command=Locate_Click) - new_item.add_command(label='Photo Clicker', command=Photo_Clicker) - new_item.add_command(label='Advanced Colour Clicker', command=Colour_Clicker) - new_item.add_command(label='Settings', command=settings) - new_item.add_separator() - new_item.add_command(label='Start', command=self.do_conversion) - new_item.add_command(label='Exit', command=self.EXITME) - menu.add_cascade(label='Menu', menu=new_item) - new_item2 = Menu(menu) - new_item2.add_command(label='Tutorial', command=tutorial) - new_item2.add_command(label='Contact', command=clicked) - menu.add_cascade(label='Help', menu=new_item2) - popup = Menu(self, tearoff=0) - popup.add_command(label="About", command=callback) - popup.add_command(label="Send Feedback", command=feedback) - popup.add_command(label='GitHub Page', command=callback2) - popup.add_command(label='Auto Clicker Mega Spam', command=clicked2) - popup.add_command(label='Version Number', command=NOTIFICATION) - popup.add_command(label='Modern Style', command=OpenModernWindow) - popup.add_command(label='Settings', command=settings) - popup.add_command(label='List of coordinates', command=clicked3) - popup.add_command(label='Locate and Click', command=Locate_Click) - popup.add_command(label='Photo Clicker', command=Photo_Clicker) - popup.add_command(label='Advanced Colour Clicker', command=Colour_Clicker) - popup.add_command(label='Find Coordinates', command=Finder) - popup.add_separator() - popup.add_command(label='Start', command=self.do_conversion) - popup.add_command(label='Exit', command=self.EXITME) - - def do_popup(event): - # display the popup menu - try: - popup.tk_popup(event.x_root, event.y_root, 0) - finally: - # make sure to release the grab (Tk 8.0a1 only) - popup.grab_release() - - self.bind("", do_popup) - self.config(menu=menu) - tk.Label(self, text="Keyboard key to stop clicking:", background="#e7dff2").grid(row=1, column=2) - - - def _setup_tray(self): - try: - from PIL import Image - image = Image.open(_resource_path("favicon.ico")) - menu = ( - item('Restore', self._restore_from_tray), - item('Start Clicker', self.startclick), - item('Stop Clicker', self.stopclick), - item('Exit', self.EXITME) - ) - self.tray_icon = pystray.Icon("autoclicker", image, "AutoClicker", menu) - threading.Thread(target=self.tray_icon.run, daemon=True).start() - except: - pass - - def _restore_from_tray(self, icon=None, item=None): - self.deiconify() - self.lift() - self.attributes("-topmost", True) - - def _minimize_to_tray(self): - self.withdraw() - if not hasattr(self, 'tray_icon'): - self._setup_tray() - - def EXITME(self): - - YourGUI.destroy(self) +def Contact_Page(): + owner = tk._default_root + master = tk.Toplevel(owner) if owner else tk.Tk() + master.title("Contact & Feedback") + master.geometry("400x250+400+300") + master.configure(bg="#eef2ff") + + tk.Label(master, text="We'd love to hear from you!", font=("Segoe UI", 14, "bold"), bg="#eef2ff", fg="#1e3a8a").pack(pady=20) + + def open_github(): webbrowser.open("https://github.com/kai9987kai") + def open_email(): webbrowser.open("mailto:feedback@example.com") + + tk.Button(master, text="Visit GitHub Profile", font=("Segoe UI", 10), bg="#1e293b", fg="white", command=open_github).pack(pady=10, ipadx=10, ipady=5) + tk.Button(master, text="Send Email Feedback", font=("Segoe UI", 10), bg="#0f766e", fg="white", command=open_email).pack(pady=10, ipadx=10, ipady=5) - def startclick(self): - x1 = threading.Thread(target=self.do_conversion, daemon=True) - x1.start() - def do_conversion(self): - if self.cmb.get() == "Left Click": - y = self.inputY.get() - x = self.inputX.get() - - running = True - try: - - x = int(x) - y = int(y) - except: - - messagebox.showerror('Invalid point', 'Invalid point') - YourGUI.destroy(self) - # strtoint("crashmE!") - while running: - pyautogui.FAILSAFE = False # disables the fail-safe - pyautogui.click(x, y) - - num= int(self.inputdelayentry.get()) - start_time = datetime.datetime.now() - while (datetime.datetime.now() - start_time).total_seconds() < num: - if keyboard.is_pressed(self.inputhotkey.get()): - exit(0) - else: - pass - - if keyboard.is_pressed(self.inputhotkey.get()): - break - elif self.cmb.get() == "Right Click": - - y = self.inputY.get() - x = self.inputX.get() - running = True - try: - - x = int(x) - y = int(y) - except: +def Help_Page(): + owner = tk._default_root + master = tk.Toplevel(owner) if owner else tk.Tk() + master.title("AutoClicker Help") + master.geometry("500x400+350+200") + master.configure(bg="#f8fafc") + + txt = tk.Text(master, font=("Segoe UI", 10), bg="#f8fafc", fg="#334155", wrap="word", padx=20, pady=20, bd=0) + txt.pack(fill="both", expand=True) + + help_text = """AutoClicker Innovation Suite Help - messagebox.showerror('Invalid point', 'Invalid point') - YourGUI.destroy(self) - # strtoint("crashmE!") - - while running: - pyautogui.FAILSAFE = False # disables the fail-safe - pyautogui.click(button='right') - pyautogui.click(x, y) - - - if keyboard.is_pressed(self.inputhotkey.get()): - break - - num= int(self.inputdelayentry.get()) - start_time = datetime.datetime.now() - while (datetime.datetime.now() - start_time).total_seconds() < num: - - if keyboard.is_pressed(self.inputhotkey.get()): - exit(0) - else: - pass - elif self.cmb.get() == "Middle Click": - y = self.inputY.get() - x = self.inputX.get() - running = True - try: - x = int(x) - y = int(y) - except: - messagebox.showerror('Invalid point', 'Invalid point') - YourGUI.destroy(self) - - while running: - pyautogui.FAILSAFE = False - pyautogui.click(button='middle') - pyautogui.click(x, y) - time.sleep(int(self.inputdelayentry.get())) - +1. Basic Clicking: +Set the Target X and Y (or leave empty to click at the current cursor). Set the delay between clicks, and choose the click button. Press Start or use your hotkey. - - num= int(self.inputdelayentry.get()) - start_time = datetime.datetime.now() - while (datetime.datetime.now() - start_time).total_seconds() < num: - if keyboard.is_pressed(self.inputhotkey.get()): - exit(0) - else: - pass - - - if keyboard.is_pressed(self.inputhotkey.get()): - break - elif self.cmb.get() == "Double Right Click": - y = self.inputY.get() - x = self.inputX.get() - running = True - try: - x = int(x) - y = int(y) - except: - messagebox.showerror('Invalid point', 'Invalid point') - YourGUI.destroy(self) - - while running: - pyautogui.FAILSAFE = False - pyautogui.click(clicks=2) - pyautogui.click(button='right') - pyautogui.click(x, y) - time.sleep(int(self.inputdelayentry.get())) - +2. Photo Clicker: +Open from the Tools menu. This allows you to select an image from your disk and the app will scan your screen for it and click it. - - num= int(self.inputdelayentry.get()) - start_time = datetime.datetime.now() - while (datetime.datetime.now() - start_time).total_seconds() < num: - if keyboard.is_pressed(self.inputhotkey.get()): - exit(0) - else: - pass - - - if keyboard.is_pressed(self.inputhotkey.get()): - break - elif self.cmb.get() == "Double Left Click": - y = self.inputY.get() - x = self.inputX.get() - running = True - try: - x = int(x) - y = int(y) - except: - messagebox.showerror('Invalid point', 'Invalid point') - YourGUI.destroy(self) - - while running: - pyautogui.FAILSAFE = False - pyautogui.click(clicks=2) - pyautogui.click(button='left') - pyautogui.click(x, y) - time.sleep(int(self.inputdelayentry.get())) - +3. Colour Clicker: +Advanced mode to click specific pixels. Use the 'Pipette' to sample a colour from your screen. - - num= int(self.inputdelayentry.get()) - start_time = datetime.datetime.now() - while (datetime.datetime.now() - start_time).total_seconds() < num: - if keyboard.is_pressed(self.inputhotkey.get()): - exit(0) - else: - pass - - - if keyboard.is_pressed(self.inputhotkey.get()): - break - elif self.cmb.get() == "Double Middle Click": - y = self.inputY.get() - x = self.inputX.get() - running = True - try: - x = int(x) - y = int(y) - except: - messagebox.showerror('Invalid point', 'Invalid point') - YourGUI.destroy(self) - - while running: - pyautogui.FAILSAFE = False - pyautogui.click(clicks=2) - pyautogui.click(button='middle') - pyautogui.click(x, y) - time.sleep(int(self.inputdelayentry.get())) - +4. Mega Spam: +Use this mode when you need the absolute maximum clicks per second, ignoring all safety delays. - - num= int(self.inputdelayentry.get()) - start_time = datetime.datetime.now() - while (datetime.datetime.now() - start_time).total_seconds() < num: - if keyboard.is_pressed(self.inputhotkey.get()): - exit(0) - else: - pass - - - if keyboard.is_pressed(self.inputhotkey.get()): - break +5. Recording: +Use the 'Record' button in the Quick Tools to capture a sequence of points (press 'R' to capture), then replay them.""" + txt.insert("1.0", help_text) + txt.config(state="disabled") - def do_hotkey(self): - hotkey = self.inputhotkey.get() +def feedback(): + Contact_Page() - if __name__ == '__main__': - your_gui = YourGUI() - your_gui.geometry("+300+300") - your_gui.attributes("-topmost", True) - your_gui.title('AutoClicker') # Set title - try: - your_gui.iconbitmap(_resource_path("favicon.ico")) - except: - pass - your_gui.resizable(False, False) - your_gui.configure(background="#e7dff2") - your_gui.mainloop() -class Coordinates(): - replayBtn = (100, 350) +def NOTIFICATION(): + try: + toaster = _load_win10toast().ToastNotifier() + toaster.show_toast("AutoClicker", APP_VERSION, duration=5, threaded=True, icon_path=_resource_path("favicon.ico")) + messagebox.showinfo('AutoClicker', APP_VERSION) + except: + messagebox.showinfo('AutoClicker', APP_VERSION) + pass + +def tutorial(): + Help_Page() -def MAINWINDOW_NEWSTYLE(): - if not _ensure_dependencies("AutoClicker", ["pyautogui", "keyboard"]): +def OldStyleGUI(): + if not _ensure_dependencies("Old Style GUI", ["pyautogui", "keyboard"]): return class YourGUI(tk.Tk): def __init__(self): + # inherit tkinter's window methods tk.Tk.__init__(self) - ttk.Label(self, text="ENTER Y:", background="#e7dff2", anchor=E).grid(row=0, column=2) + + tk.Label(self, text="ENTER Y:", background="#e7dff2").grid(row=0, column=2) + self.inputX = tk.Entry(self) + self.inputX.grid(row=0, column=1) + + tk.Label(self, text="ENTER X:", background="#e7dff2").grid(row=0, column=0) + self.inputY = tk.Entry(self) + self.inputY.grid(row=0, column=3) + + self.cmb = ttk.Combobox(self, width="10", values=("Left Click","Right Click","Middle Click","Double Right Click","Double Left Click","Double Middle Click")) ttk.Label(self, text="Delay Between clicks", background="#e7dff2", anchor=E).grid(row=7, column=0) self.inputdelayentry = tk.StringVar() @@ -3568,19 +2772,11 @@ def __init__(self): self.inputdelayentry.set("0") - self.inputdelay = ttk.Entry(self, textvariable= self.inputdelayentry).grid(row=7, column=1) - - - - - self.inputX = ttk.Entry(self) - self.inputX.grid(row=0, column=1) - - self.cmb = ttk.Combobox(self, width="15", values=("Left Click","Right Click","Middle Click","Double Right Click","Double Left Click","Double Middle Click")) - + self.inputdelay = tk.Entry(self, textvariable= self.inputdelayentry).grid(row=7, column=1) + + class TableDropDown(ttk.Combobox): def __init__(self, parent): - self.current_table = tk.StringVar() # create variable for table ttk.Combobox.__init__(self, parent)# init widget self.config(textvariable = self.current_table, state = "readonly", values = ["Customers", "Pets", "Invoices", "Prices"]) @@ -3593,18 +2789,13 @@ def __init__(self, parent): self.cmb.current(0) - - - - ttk.Label(self, text="ENTER X:", background="#e7dff2").grid(row=0, column=0) - self.inputY = ttk.Entry(self) - self.inputY.grid(row=0, column=3) # Start Button ⬇ - ttk.Button(self, text="start", command=self.startclick).grid(row=7, column=0) + tk.Button(self, text="start", fg='green', command=self.startclick).grid(row=7, column=0) # close button ⬇ - ttk.Button(self, text="exit!", command=self.EXITME).grid(row=7, column=0) - self.inputhotkey = ttk.Entry(self) + tk.Button(self, text="exit!", fg='red', command=self.EXITME).grid(row=7, column=0) + + self.inputhotkey = tk.Entry(self) self.inputhotkey.grid(row=1, column=3, columnspan=1) @@ -3614,7 +2805,7 @@ def callback(): def callback2(): webbrowser.open_new(r"https://github.com/kai9987kai/AutoClicker") - ttk.Button(self, text="ABOUT", command=callback).grid(row=5, column=3, sticky="ew") + tk.Button(self, text="ABOUT", command=callback).grid(row=5, column=3, sticky="ew") def clicked3(): your_gui.destroy() @@ -3623,22 +2814,22 @@ def clicked3(): things = [] root = Tk() - root.geometry("600x400") + root.geometry("550x425") list_box = Listbox(root, font=(12)) list_box.config(width=30, height=18) list_box.place(x=0, y=0) - run_btn = ttk.Button(root, text="Run List", command=lambda: run_list()) - run_btn.place(x=350, y=20) + run_btn = Button(root, text="Run List", command=lambda: run_list(), font=(12)) + run_btn.place(x=350, y=15) run_btn.config(width=15) - del_btn = ttk.Button(root, text="Delete", command=lambda: delete(list_box)) - del_btn.place(x=350, y=80) + del_btn = Button(root, text="Delete", command=lambda: delete(list_box), font=(12)) + del_btn.place(x=350, y=105) del_btn.config(width=15) - add_btn = ttk.Button(root, text="Add", command=lambda: add()) - add_btn.place(x=350, y=50) + add_btn = Button(root, text="Add", command=lambda: add(), font=(12)) + add_btn.place(x=350, y=60) add_btn.config(width=15) x_txt = StringVar() @@ -3647,31 +2838,22 @@ def clicked3(): x_label = Label(root, text="x", font=(12)) x_label.place(x=350, y=150) - x = ttk.Entry(root, textvariable=x_txt) + x = Entry(root, textvariable=x_txt) x.place(x=375, y=150) x_txt.set('') y_label = Label(root, text="y", font=(12)) - y_label.place(x=350, y=170) + y_label.place(x=350, y=180) - y = ttk.Entry(root, textvariable=y_txt) + y = Entry(root, textvariable=y_txt) y.place(x=375, y=180) y_txt.set('') - - cmb = ttk.Combobox(root, width="15", values=("Left Click","Right Click","Middle Click","Double Right Click","Double Left Click","Double Middle Click")) ttk.Label(root, text="""Select whether to right or -left click the list""", anchor=E).place(x=350, y=250) - cmb.place(x=350, y=300) +left click the list""", anchor=E).place(x=350, y=220) + cmb.place(x=350, y=265) cmb.current(0) - root.title("AutoClicker - list of coordinates") - try: - root.iconbitmap(_resource_path("favicon.ico")) - except: - pass - root.resizable(False, False) - root.attributes("-topmost", True) def add(): content_x = x_txt.get() @@ -3683,12 +2865,10 @@ def add(): list_box.insert(END, things[i]) def run_list(): - x_cords = [item[0] for item in things] y_cords = [item[1] for item in things] for i in range(len(things)): - if cmb.get() == "Left Click": screenWidth, screenHeight = pyautogui.size() currentMouseX, currentMouseY = pyautogui.position() @@ -3698,7 +2878,6 @@ def run_list(): elif cmb.get() == "Right Click": - screenWidth, screenHeight = pyautogui.size() currentMouseX, currentMouseY = pyautogui.position() pyautogui.moveTo(int(x_cords[i]), int(y_cords[i])) @@ -3742,15 +2921,26 @@ def run_list(): pyautogui.click(button='middle') pyautogui.click() - def delete(listbox): - global things - # Delete from Listbox + + + def delete(listbox=None): + """Remove the selected coordinate from both the listbox and the model. + + This used to declare `global things` even though `things` is a closure + local, so it always raised NameError: the row vanished from the listbox + while Run List kept clicking the deleted coordinate. It also read the + row *after* deleting it (so it saw the wrong one) and passed that text + through eval(), which executes whatever the X/Y fields contain. + """ selection = list_box.curselection() - list_box.delete(selection[0]) - # Delete from list that provided it - value = eval(list_box.get(selection[0])) - ind = things.index(value) - del (things[ind]) + if not selection: + return + index = selection[0] + if 0 <= index < len(things): + del things[index] + list_box.delete(0, END) + for entry in things: + list_box.insert(END, entry) popup = Menu(root, tearoff=0) popup.add_command(label='Run list', command=run_list) @@ -3766,25 +2956,29 @@ def do_popup(event): root.bind("", do_popup) + root.title("AutoClicker - list of coordinates") + try: + root.iconbitmap(_resource_path("favicon.ico")) + + except: + pass + root.resizable(False, False) + root.attributes("-topmost", True) root.mainloop() - - def clicked(): - Contact_Page() - - def clicked2(): - Mega_Spam() def Finder(): Coordinates_Finder() - - ttk.Button(self, text="List Coordinates", command=clicked3).grid(row=7, column=3, sticky="ew") - ttk.Button(self, text="Find Coordinates", command=Finder).grid(row=6, column=3, sticky="ew") - ttk.Button(self, text="Advanced Colour Clicker", command=Colour_Clicker).grid(row=7, column=1, columnspan=2, sticky="ew", padx=4) + tk.Button(self, text="List Coordinates", command=clicked3).grid(row=7, column=3,sticky="ew") + tk.Button(self, text="Find Coordinates", command=Finder).grid(row=6, column=3, sticky="ew") + tk.Button(self, text="Advanced Colour Clicker", command=Colour_Clicker).grid(row=7, column=1, columnspan=2, sticky="ew", padx=4) + + def clicked(): + Contact_Page() def settings(): window = Tk() window.title("Settings") - window.geometry('330x115') + window.geometry('335x130') try: window.iconbitmap(_resource_path("favicon.ico")) except: @@ -3814,12 +3008,12 @@ def Show_Title_bar(): ttk.Label(window, text="Settings").grid(column=0, row=1, sticky="ew") - ttk.Button(window, text=" Exit Settings ", command=ExitWindow).grid(column=0, row= 6) - ttk.Button(window, text="‍‍FullScreen", command=Full_screen).grid(column=0, row=3, sticky="ew") - ttk.Button(window, text="Exit FullScreen", command=Exit_Full_Screen).grid(column=1, row=3, sticky="ew") - ttk.Button(window, text="Hide Title Bar ", command=callBackFunc).grid(column=0, row=5, sticky="ew") - ttk.Button(window, text="Show Title Bar", command=Show_Title_bar).grid(column=1, row=5, sticky="ew") - ttk.Button(window, text="Restart program", command=Show_Title_bar).grid(column=1, row=6, sticky="ew") + Button(window, text=" Exit Settings ", command=ExitWindow).grid(column=0, row= 6) + Button(window, text="‍‍FullScreen", command=Full_screen).grid(column=0, row=3, sticky="ew") + Button(window, text="Exit FullScreen", command=Exit_Full_Screen).grid(column=1, row=3, sticky="ew") + Button(window, text="Hide Title Bar ", command=callBackFunc).grid(column=0, row=5, sticky="ew") + Button(window, text="Show Title Bar", command=Show_Title_bar).grid(column=1, row=5, sticky="ew") + Button(window, text="Restart program", command=Show_Title_bar).grid(column=1, row=6, sticky="ew") popup = Menu(your_gui, tearoff=0) popup.add_command(label="FullScreen", command=Full_screen) @@ -3841,47 +3035,48 @@ def do_popup(event): window.mainloop() - def OpenOldWindow(): + + def OpenModernWindow(): your_gui.destroy() - OldStyleGUI() + MAINWINDOW_REDESIGNED() + + def clicked2(): + Mega_Spam() # Menu Bar!! ⬇ menu = Menu(self) new_item = Menu(menu) new_item.add_command(label='About', command=callback) new_item.add_command(label='Github Page', command=callback2) - new_item.add_command(label='Auto Clicker Mega Spam', command=clicked2) + new_item.add_command(label='List Coordinates', command=clicked3) new_item.add_command(label='Version Number', command=NOTIFICATION) - new_item.add_command(label='Old Style GUI', command=OpenOldWindow) - new_item.add_command(label='Settings', command=settings) - new_item.add_command(label='List of Coordinates', command=clicked3) + new_item.add_command(label='Modern Style', command=OpenModernWindow) + new_item.add_command(label='Auto Clicker Mega Spam', command=clicked2) new_item.add_command(label='Coordinates Finder', command=Finder) new_item.add_command(label='Send Feedback', command=feedback) - new_item.add_command(label='Advanced Colour Clicker', command=Colour_Clicker) new_item.add_command(label='Locate and Click', command=Locate_Click) new_item.add_command(label='Photo Clicker', command=Photo_Clicker) + new_item.add_command(label='Advanced Colour Clicker', command=Colour_Clicker) + new_item.add_command(label='Settings', command=settings) new_item.add_separator() new_item.add_command(label='Start', command=self.do_conversion) new_item.add_command(label='Exit', command=self.EXITME) - menu.add_cascade(label='Menu', menu=new_item) new_item2 = Menu(menu) new_item2.add_command(label='Tutorial', command=tutorial) - menu.add_cascade(label='Help', menu=new_item2) new_item2.add_command(label='Contact', command=clicked) - self.config(menu=menu) - tk.Label(self, text="Keyboard key to stop clicking:", background="#e7dff2").grid(row=1, column=2) + menu.add_cascade(label='Help', menu=new_item2) popup = Menu(self, tearoff=0) - popup.add_command(label="About", command=callback) # , command=next) etc... + popup.add_command(label="About", command=callback) + popup.add_command(label="Send Feedback", command=feedback) popup.add_command(label='GitHub Page', command=callback2) - popup.add_command(label='Send Feedback', command=feedback) - popup.add_command(label='Locate and Click', command=Locate_Click) - popup.add_command(label='Photo Clicker', command=Photo_Clicker) popup.add_command(label='Auto Clicker Mega Spam', command=clicked2) popup.add_command(label='Version Number', command=NOTIFICATION) - popup.add_command(label='Old Style Gui', command=OpenOldWindow) + popup.add_command(label='Modern Style', command=OpenModernWindow) popup.add_command(label='Settings', command=settings) popup.add_command(label='List of coordinates', command=clicked3) + popup.add_command(label='Locate and Click', command=Locate_Click) + popup.add_command(label='Photo Clicker', command=Photo_Clicker) popup.add_command(label='Advanced Colour Clicker', command=Colour_Clicker) popup.add_command(label='Find Coordinates', command=Finder) popup.add_separator() @@ -3897,46 +3092,42 @@ def do_popup(event): popup.grab_release() self.bind("", do_popup) + self.config(menu=menu) + tk.Label(self, text="Keyboard key to stop clicking:", background="#e7dff2").grid(row=1, column=2) - def _setup_tray(self): - try: - from PIL import Image - image = Image.open(_resource_path("favicon.ico")) - menu = ( - item('Restore', self._restore_from_tray), - item('Start Clicker', self.startclick), - item('Stop Clicker', self.stopclick), - item('Exit', self.EXITME) - ) - self.tray_icon = pystray.Icon("autoclicker", image, "AutoClicker", menu) - threading.Thread(target=self.tray_icon.run, daemon=True).start() - except: - pass - - def _restore_from_tray(self, icon=None, item=None): - self.deiconify() - self.lift() - self.attributes("-topmost", True) - - def _minimize_to_tray(self): - self.withdraw() - if not hasattr(self, 'tray_icon'): - self._setup_tray() - def EXITME(self): YourGUI.destroy(self) def startclick(self): - x1 = threading.Thread(target=self.do_conversion, daemon=True) - x1.start() + x1.start() def do_conversion(self): + """Wrapper that always restores the PyAutoGUI corner fail-safe. + + The branches below set FAILSAFE = False and never put it back, which left the + corner escape hatch disabled for every window opened afterwards. + """ + previous_failsafe = getattr(pyautogui, "FAILSAFE", True) + try: + self._run_classic_clicks() + except Exception as exc: + # Report on the Tk thread; a worker-thread traceback would otherwise be + # invisible in a windowed build. + message = str(exc) + try: + self.after(0, lambda: messagebox.showerror("AutoClicker", f"Run stopped: {message}")) + except Exception: + pass + finally: + try: + pyautogui.FAILSAFE = previous_failsafe + except Exception: + pass - + def _run_classic_clicks(self): if self.cmb.get() == "Left Click": - y = self.inputY.get() x = self.inputX.get() @@ -3948,25 +3139,21 @@ def do_conversion(self): except: messagebox.showerror('Invalid point', 'Invalid point') - YourGUI.destroy(self) + return while running: - pyautogui.FAILSAFE = False + pyautogui.FAILSAFE = False # disables the fail-safe pyautogui.click(x, y) - - if keyboard.is_pressed(self.inputhotkey.get()): - running = False - - - + num= int(self.inputdelayentry.get()) start_time = datetime.datetime.now() while (datetime.datetime.now() - start_time).total_seconds() < num: - if keyboard.is_pressed(self.inputhotkey.get()): - exit(0) + return else: pass - + + if keyboard.is_pressed(self.inputhotkey.get()): + break elif self.cmb.get() == "Right Click": y = self.inputY.get() @@ -3979,30 +3166,25 @@ def do_conversion(self): except: messagebox.showerror('Invalid point', 'Invalid point') - YourGUI.destroy(self) + return while running: pyautogui.FAILSAFE = False # disables the fail-safe pyautogui.click(button='right') pyautogui.click(x, y) - time.sleep(int(self.inputdelayentry.get())) - - - + + + if keyboard.is_pressed(self.inputhotkey.get()): + break + num= int(self.inputdelayentry.get()) start_time = datetime.datetime.now() while (datetime.datetime.now() - start_time).total_seconds() < num: if keyboard.is_pressed(self.inputhotkey.get()): - exit(0) + return else: pass - - - if keyboard.is_pressed(self.inputhotkey.get()): - break - - elif self.cmb.get() == "Middle Click": y = self.inputY.get() x = self.inputX.get() @@ -4026,7 +3208,7 @@ def do_conversion(self): start_time = datetime.datetime.now() while (datetime.datetime.now() - start_time).total_seconds() < num: if keyboard.is_pressed(self.inputhotkey.get()): - exit(0) + return else: pass @@ -4057,7 +3239,7 @@ def do_conversion(self): start_time = datetime.datetime.now() while (datetime.datetime.now() - start_time).total_seconds() < num: if keyboard.is_pressed(self.inputhotkey.get()): - exit(0) + return else: pass @@ -4088,7 +3270,7 @@ def do_conversion(self): start_time = datetime.datetime.now() while (datetime.datetime.now() - start_time).total_seconds() < num: if keyboard.is_pressed(self.inputhotkey.get()): - exit(0) + return else: pass @@ -4119,43 +3301,55 @@ def do_conversion(self): start_time = datetime.datetime.now() while (datetime.datetime.now() - start_time).total_seconds() < num: if keyboard.is_pressed(self.inputhotkey.get()): - exit(0) + return else: pass if keyboard.is_pressed(self.inputhotkey.get()): break - def action(): - YourGUI.destroy() - - - def getdelay(self): - tx = self.getinputdelayentry.get() - def do_hotkey(self): - hotkey = self.inputhotkey.get() + # This used to sit behind `if __name__ == '__main__'`, so Tools > Old Style GUI + # silently opened nothing whenever the module was imported rather than run directly. + your_gui = YourGUI() + your_gui.geometry("+300+300") + your_gui.attributes("-topmost", True) + your_gui.title('AutoClicker') # Set title + try: + your_gui.iconbitmap(_resource_path("favicon.ico")) + except: + pass + your_gui.resizable(False, False) + your_gui.configure(background="#e7dff2") + your_gui.mainloop() - if __name__ == '__main__': - your_gui = YourGUI() - your_gui.geometry("+300+300") - your_gui.attributes("-topmost", True) - your_gui.title('AutoClicker') # Set title - try: - your_gui.iconbitmap(_resource_path("favicon.ico")) - except: - pass - your_gui.resizable(False, False) - your_gui.configure(background="#e7dff2") - your_gui.mainloop() - time.sleep(0) def MAINWINDOW_REDESIGNED(): if not _ensure_dependencies("AutoClicker Control Center", ["pyautogui", "keyboard"]): return class YourGUI(tk.Tk): CLICK_TYPES = DEFAULT_CLICK_TYPES + ACTION_TYPES = ACTION_REGISTRY DELAY_PRESETS = ("0.00", "0.05", "0.10", "0.25", "0.50") + CPS_PRESETS = ("2", "5", "10", "20", "50") + # Build-time colour -> palette role. Resolved once per widget (see _apply_theme). + BG_ROLE_SENTINELS = { + "#dbe7f2": "main_bg", + "#f8fafc": "card_bg", + "#edf4ff": "alt_bg", + "#0f172a": "hero_bg", + "#1e293b": "hero_chip_bg", + "white": "list_bg", + } + FG_ROLE_SENTINELS = { + "#0f172a": "text", + "#475569": "sub", + "white": "hero_fg", + "#f8fafc": "hero_fg", + "#cbd5e1": "hero_sub", + "#334155": "sub", + "#0f766e": "accent", + } def __init__(self): tk.Tk.__init__(self) @@ -4172,12 +3366,24 @@ def __init__(self): self.worker_thread = None self.sequence_thread = None + # One Event per subsystem. A single shared Event let a finishing worker + # clear the flag out from under a still-running one, resurrecting a stopped run. self.stop_event = threading.Event() + self.sequence_stop_event = threading.Event() + self.playback_stop_event = threading.Event() + self.pause_event = threading.Event() + self.run_generation = 0 + self.active_run_generation = 0 self.ui_queue = queue.Queue() self.stop_reason = "idle" self.hotkey_notified = False self.was_minimized_for_run = False self.active_run_was_dry_run = False + self.rate_samples = [] + self.global_hotkey_handles = {} + self.lifetime_stats = {"runs": 0, "actions": 0, "seconds": 0.0} + self._theme_roles = {} + self.profile_undo_stack = [] self.target_x_var = tk.StringVar(value="0") self.target_y_var = tk.StringVar(value="0") @@ -4221,6 +3427,27 @@ def __init__(self): self.recording_summary_var = tk.StringVar(value="Recording: 0 point(s) | idle") self.window_summary_var = tk.StringVar(value="") self.preset_summary_var = tk.StringVar(value="") + + # V11 additions: richer action vocabulary, pacing, scheduling and telemetry. + self.action_key_var = tk.StringVar(value=ACTION_DEFAULTS["action_key"]) + self.action_text_var = tk.StringVar(value=ACTION_DEFAULTS["action_text"]) + self.scroll_amount_var = tk.StringVar(value=str(ACTION_DEFAULTS["scroll_amount"])) + self.hold_duration_var = tk.StringVar(value=f"{ACTION_DEFAULTS['hold_duration']:.2f}") + self.drag_to_x_var = tk.StringVar(value="0") + self.drag_to_y_var = tk.StringVar(value="0") + self.pacing_mode_var = tk.StringVar(value="Precise") + self.scheduled_start_var = tk.StringVar(value="") + self.target_cps_var = tk.StringVar(value="") + self.round_robin_var = tk.BooleanVar(value=False) + self.global_hotkeys_var = tk.BooleanVar(value=False) + self.start_hotkey_var = tk.StringVar(value="ctrl+shift+s") + self.pause_hotkey_var = tk.StringVar(value="ctrl+shift+p") + self.capture_hotkey_var = tk.StringVar(value="ctrl+shift+c") + self.live_rate_var = tk.StringVar(value="Rate: idle") + self.lifetime_stats_var = tk.StringVar(value="Lifetime: 0 run(s) | 0 action(s) | 0.00s") + self.action_params_var = tk.StringVar(value="") + self.hotkey_status_var = tk.StringVar(value="Global hotkeys are off.") + self.profile_file = _state_file_path(PROFILE_FILE_NAME) self.workspace_file = _state_file_path(WORKSPACE_FILE_NAME) self.saved_profiles = {} @@ -4248,6 +3475,9 @@ def __init__(self): self._build_menu() self._build_layout() self._build_context_menu() + # Snapshot each widget's build-time colour role while the tree is still in its + # authored Light palette; _apply_theme then maps roles, never live colours. + self._capture_theme_roles() self._load_profiles_from_disk() self._load_workspace_from_disk() self._apply_window_preferences() @@ -4256,16 +3486,255 @@ def __init__(self): self._append_activity("Control Center ready.") self.bind("", self._show_context_menu) - self.bind("", lambda _event: self.startclick()) + # Enter used to be bound on the root, so pressing it in any entry field launched a + # live uncapped run and immediately minimised the window. It is now scoped to Start. + self.bind("", lambda _event: self.startclick()) + self.bind("", lambda _event: self.toggle_pause()) + self.bind("", lambda _event: self.stopclick()) self.protocol("WM_DELETE_WINDOW", self._handle_close_request) self.after(100, self._pump_ui_queue) self.after(200, self._refresh_live_cursor) self.after(50, self._apply_theme) - - # Global Emergency Kill-Switch + self._refresh_run_buttons() + self._load_lifetime_stats() + + # Emergency kill-switch. It now persists the workspace before exiting rather than + # tearing the process down with os._exit and losing every unsaved change. + try: + self.emergency_hotkey_handle = keyboard.add_hotkey("ctrl+shift+k", self._emergency_stop) + except Exception: + self.emergency_hotkey_handle = None + + def _emergency_stop(self): + """Halt everything immediately, then save state and quit from the Tk thread.""" + self.pause_event.clear() + for event in (self.stop_event, self.sequence_stop_event, self.playback_stop_event): + event.set() + self.stop_reason = "emergency" + try: + self.after(0, self._emergency_finalize) + except Exception: + os._exit(0) + + def _emergency_finalize(self): + try: + self._append_activity("Emergency stop hotkey used.") + self._persist_workspace_state() + except Exception: + pass + try: + self.EXITME() + except Exception: + os._exit(0) + + def _load_lifetime_stats(self): + """Seed lifetime totals from the on-disk run log so they survive restarts.""" + try: + summary = _summarize_run_history(_read_run_log(_state_file_location(RUN_LOG_FILE_NAME))) + self.lifetime_stats = { + "runs": summary["runs"], + "actions": summary["actions"], + "seconds": summary["seconds"], + } + except Exception: + pass + + def _register_global_hotkeys(self): + """Bind start / pause / stop / capture to system-wide hotkeys.""" + self._unregister_global_hotkeys() + if keyboard is None: + self.hotkey_status_var.set("Global hotkeys need the 'keyboard' package.") + return + bindings = { + "start": (self.start_hotkey_var.get().strip(), lambda: self.after(0, self.startclick)), + "pause": (self.pause_hotkey_var.get().strip(), lambda: self.after(0, self.toggle_pause)), + "capture": (self.capture_hotkey_var.get().strip(), lambda: self.after(0, self._capture_cursor_position)), + } + registered, problems = [], [] + for name, (hotkey, callback) in bindings.items(): + if not hotkey: + continue + check = _validate_hotkey(hotkey) + if not check["valid"]: + problems.append(f"{name}: {check['reason']}") + continue + try: + self.global_hotkey_handles[name] = keyboard.add_hotkey(hotkey, callback) + registered.append(f"{name}={hotkey}") + except Exception as exc: + problems.append(f"{name}: {exc}") + + if registered and not problems: + self.hotkey_status_var.set("Global hotkeys active: " + ", ".join(registered)) + elif registered: + self.hotkey_status_var.set("Active: " + ", ".join(registered) + " | Failed: " + "; ".join(problems)) + else: + self.hotkey_status_var.set("No global hotkeys registered. " + "; ".join(problems)) + self._append_activity(self.hotkey_status_var.get()) + + def _unregister_global_hotkeys(self): + for handle in list(self.global_hotkey_handles.values()): + try: + keyboard.remove_hotkey(handle) + except Exception: + pass + self.global_hotkey_handles = {} + + def _toggle_global_hotkeys(self): + if self.global_hotkeys_var.get(): + self._register_global_hotkeys() + else: + self._unregister_global_hotkeys() + self.hotkey_status_var.set("Global hotkeys are off.") + self._schedule_workspace_save() + + def _apply_target_cps(self): + """Convert the clicks-per-second box into the delay the engine actually uses.""" + delay = _cps_to_delay(self.target_cps_var.get()) + if delay is None: + messagebox.showerror("Target rate", "Enter a clicks-per-second value greater than zero.", parent=self) + return + self.delay_var.set(f"{delay:.4f}".rstrip("0").rstrip(".") or "0") + self.status_var.set(f"Delay set to {delay:.4f}s for {float(self.target_cps_var.get()):.2f} action(s)/sec.") + self._update_plan_summary() + + def _sync_cps_from_delay(self): + cps = _delay_to_cps(self.delay_var.get()) + self.target_cps_var.set("" if cps is None else f"{cps:.2f}") + + def _refresh_action_params(self, *_args): + """Tell the user which Action Setup fields the selected action actually reads.""" + action_name = self.click_mode_var.get() + spec = ACTION_REGISTRY.get(action_name) + if not spec: + self.action_params_var.set("") + return + labels = { + "action_key": "Key name", + "action_text": "Text to type", + "scroll_amount": "Scroll notches", + "hold_duration": "Hold seconds", + "drag_to_x": "Drag to X", + "drag_to_y": "Drag to Y", + } + uses = [labels.get(name, name) for name in spec.get("uses", ())] + if uses: + self.action_params_var.set(f"{action_name} uses: {', '.join(uses)} (see Action Setup).") + elif spec["kind"] == "click": + self.action_params_var.set(f"{action_name} sends {spec['clicks']} {spec['button']} click(s) at the target.") + else: + self.action_params_var.set(f"{action_name} needs no extra parameters.") + + def _capture_drag_target(self): + try: + x_pos, y_pos = pyautogui.position() + except Exception as exc: + self.status_var.set(f"Unable to read the cursor position: {exc}") + return + self.drag_to_x_var.set(str(int(x_pos))) + self.drag_to_y_var.set(str(int(y_pos))) + self.status_var.set(f"Drag target set to {int(x_pos)}, {int(y_pos)}.") + + def _open_run_history(self): + """Browse the persistent run log with lifetime totals.""" + palette = self._theme_palette() + window = tk.Toplevel(self) + window.title("Run History") + window.geometry("760x560+300+170") + window.minsize(560, 420) + window.attributes("-topmost", True) + window.configure(bg=palette["card_bg"]) try: - self.emergency_hotkey_handle = keyboard.add_hotkey("ctrl+shift+k", lambda: os._exit(0)) - except: pass + window.iconbitmap(_resource_path("favicon.ico")) + except Exception: + pass + + window.columnconfigure(0, weight=1) + window.rowconfigure(1, weight=1) + + summary_var = tk.StringVar(value="") + tk.Label(window, textvariable=summary_var, bg=palette["card_bg"], fg=palette["text"], + font=("Segoe UI", 10, "bold"), wraplength=700, justify=LEFT).grid(row=0, column=0, sticky="ew", padx=16, pady=(16, 8)) + + text_frame = tk.Frame(window, bg=palette["card_bg"]) + text_frame.grid(row=1, column=0, sticky="nsew", padx=16) + text_frame.columnconfigure(0, weight=1) + text_frame.rowconfigure(0, weight=1) + history_text = tk.Text(text_frame, wrap="none", bg=palette["list_bg"], fg=palette["list_fg"], + font=("Consolas", 9), relief="flat") + history_text.grid(row=0, column=0, sticky="nsew") + scroll = ttk.Scrollbar(text_frame, orient="vertical", command=history_text.yview) + scroll.grid(row=0, column=1, sticky="ns") + history_text.configure(yscrollcommand=scroll.set) + + def refresh(): + log_path = _state_file_location(RUN_LOG_FILE_NAME) + records = _read_run_log(log_path) + summary = _summarize_run_history(records) + summary_var.set( + f"{summary['runs']} run(s) logged ({summary['live_runs']} live, {summary['dry_runs']} dry) | " + f"{summary['actions']} action(s) | {_format_seconds(summary['seconds'])} | " + f"{summary['average_cps']:.2f} action(s)/sec average" + ) + lines = [f"Log file: {log_path}", ""] + if not records: + lines.append("No runs recorded yet. Finish a run and it will appear here.") + for record in reversed(records): + lines.append( + f"{record.get('finished_at', '?'):<20} " + f"{'DRY ' if record.get('dry_run') else 'LIVE'} " + f"{str(record.get('click_mode', '?')):<20} " + f"{record.get('actions', 0):>8} action(s) " + f"{float(record.get('elapsed_seconds', 0) or 0):>9.2f}s " + f"{record.get('stop_reason', '?')}" + ) + history_text.configure(state="normal") + history_text.delete("1.0", END) + history_text.insert("1.0", "\n".join(lines)) + history_text.configure(state="disabled") + + def export_history(): + export_path = filedialog.asksaveasfilename( + defaultextension=".json", filetypes=[("JSON files", "*.json")], parent=window, + initialfile=f"autoclicker-run-history-{datetime.datetime.now():%Y%m%d-%H%M%S}.json", + ) + if not export_path: + return + records = _read_run_log(_state_file_location(RUN_LOG_FILE_NAME)) + try: + _atomic_write_json(export_path, { + "app_version": APP_VERSION, + "schema_version": STATE_SCHEMA_VERSION, + "summary": _summarize_run_history(records), + "runs": records, + }, sort_keys=True) + except Exception as exc: + messagebox.showerror("Export failed", f"Unable to export run history.\n{exc}", parent=window) + return + self._append_activity(f"Run history exported to {os.path.basename(export_path)}.") + messagebox.showinfo("Run history", f"Exported to:\n{export_path}", parent=window) + + def clear_history(): + if not messagebox.askyesno("Run history", "Archive the current run log and start a fresh one?", parent=window): + return + log_path = _state_file_location(RUN_LOG_FILE_NAME) + if _rotate_run_log(log_path): + self.lifetime_stats = {"runs": 0, "actions": 0, "seconds": 0.0} + self._append_activity("Run history archived.") + refresh() + else: + messagebox.showerror("Run history", "Unable to archive the run log.", parent=window) + + button_row = tk.Frame(window, bg=palette["card_bg"]) + button_row.grid(row=2, column=0, sticky="ew", padx=16, pady=16) + for index in range(4): + button_row.columnconfigure(index, weight=1) + ttk.Button(button_row, text="Refresh", style="Secondary.TButton", command=refresh).grid(row=0, column=0, sticky="ew") + ttk.Button(button_row, text="Export JSON", style="Secondary.TButton", command=export_history).grid(row=0, column=1, sticky="ew", padx=(8, 0)) + ttk.Button(button_row, text="Archive Log", style="Secondary.TButton", command=clear_history).grid(row=0, column=2, sticky="ew", padx=(8, 0)) + ttk.Button(button_row, text="Close", style="Secondary.TButton", command=window.destroy).grid(row=0, column=3, sticky="ew", padx=(8, 0)) + + refresh() def _configure_window(self): self.title("AutoClicker Control Center") @@ -4287,7 +3756,38 @@ def _configure_styles(self): self._refresh_ttk_styles(self._theme_palette()) def _theme_palette(self): - if self.theme_var.get() == "Dark": + theme_name = self.theme_var.get() + if theme_name == "System": + theme_name = _detect_system_theme() + if theme_name == "Midnight": + return { + "main_bg": "#08070f", + "card_bg": "#15131f", + "alt_bg": "#100e1a", + "hero_bg": "#050410", + "hero_chip_bg": "#221f33", + "hero_fg": "#f8fafc", + "hero_sub": "#cbd5e1", + "text": "#f8fafc", + "sub": "#cbd5e1", + "accent": "#a78bfa", + "accent_active": "#7c3aed", + "danger": "#fb7185", + "danger_active": "#e11d48", + "secondary_bg": "#2a2740", + "secondary_active": "#3b3757", + "secondary_fg": "#ede9fe", + "chip_bg": "#15131f", + "chip_fg": "#c4b5fd", + "chip_active": "#221f33", + "border": "#3b3757", + "status_bg": "#050410", + "status_fg": "#ddd6fe", + "list_bg": "#100e1a", + "list_fg": "#f8fafc", + "select_bg": "#7c3aed", + } + if theme_name == "Dark": return { "main_bg": "#0f172a", "card_bg": "#1e293b", @@ -4315,7 +3815,7 @@ def _theme_palette(self): "list_fg": "#f8fafc", "select_bg": "#0f766e", } - if self.theme_var.get() == "Ocean": + if theme_name == "Ocean": return { "main_bg": "#d8edf2", "card_bg": "#f4fbfd", @@ -4508,6 +4008,7 @@ def _refresh_preset_summary(self): "Precision": "Slower, cleaner clicks with zero jitter and zero delay variance.", "Burst Sprint": "Short, aggressive output tuned for bursts and rapid button spam.", "Human Mimic": "More natural timing, broader jitter, and optional micro-break pacing.", + "Feather Touch": "Very slow, single clean action per second for fragile or laggy UIs.", } self.preset_summary_var.set( descriptions.get(self.behaviour_preset_var.get(), "Custom run profile.") @@ -4552,6 +4053,15 @@ def _apply_behaviour_preset(self): "micro_pause_every": "10", "micro_pause_duration": "0.40", }, + "Feather Touch": { + "delay": "1.00", + "delay_variance": "0.10", + "jitter_x": "0", + "jitter_y": "0", + "human_like": True, + "micro_pause_every": "0", + "micro_pause_duration": "0.00", + }, } preset = preset_map.get(preset_name) @@ -4642,24 +4152,30 @@ def _build_layout(self): self.start_button = ttk.Button(command_strip, text="Start", style="Accent.TButton", command=self.startclick) self.start_button.grid(row=0, column=0, sticky="ew") + self.start_button.bind("", lambda _event: self.startclick()) + self.pause_button = ttk.Button(command_strip, text="Pause", style="Secondary.TButton", command=self.toggle_pause, state=DISABLED) + self.pause_button.grid(row=0, column=1, sticky="ew", padx=(8, 0)) self.stop_button = ttk.Button(command_strip, text="Stop", style="Danger.TButton", command=self.stopclick, state=DISABLED) - self.stop_button.grid(row=0, column=1, sticky="ew", padx=(8, 0)) - ttk.Button(command_strip, text="Capture Cursor", style="Secondary.TButton", command=self._capture_cursor_position).grid(row=0, column=2, sticky="ew", padx=(8, 0)) - ttk.Button(command_strip, text="Validate Plan", style="Secondary.TButton", command=self._validate_current_plan).grid(row=0, column=3, sticky="ew", padx=(8, 0)) - ttk.Checkbutton(command_strip, text="Dry Run", variable=self.dry_run_var, style="Command.TCheckbutton").grid(row=0, column=4, sticky="w", padx=(12, 0)) - ttk.Checkbutton(command_strip, text="Fail-safe", variable=self.pyautogui_failsafe_var, style="Command.TCheckbutton").grid(row=0, column=5, sticky="w", padx=(12, 0)) - ttk.Button(command_strip, text="Safety Guard", style="Secondary.TButton", command=lambda: self._set_dropdown_state("safety_guard", True)).grid(row=0, column=6, sticky="ew", padx=(8, 0)) + self.stop_button.grid(row=0, column=2, sticky="ew", padx=(8, 0)) + ttk.Button(command_strip, text="Capture Cursor", style="Secondary.TButton", command=self._capture_cursor_position).grid(row=0, column=3, sticky="ew", padx=(8, 0)) + ttk.Button(command_strip, text="Validate Plan", style="Secondary.TButton", command=self._validate_current_plan).grid(row=0, column=4, sticky="ew", padx=(8, 0)) + ttk.Checkbutton(command_strip, text="Dry Run", variable=self.dry_run_var, style="Command.TCheckbutton").grid(row=0, column=5, sticky="w", padx=(12, 0)) + ttk.Checkbutton(command_strip, text="Fail-safe", variable=self.pyautogui_failsafe_var, style="Command.TCheckbutton").grid(row=0, column=6, sticky="w", padx=(12, 0)) ttk.Button(command_strip, text="Window Settings", style="Secondary.TButton", command=self._open_settings_window).grid(row=0, column=7, sticky="ew", padx=(8, 0)) ttk.Button(command_strip, text="Expand All", style="Secondary.TButton", command=self._expand_all_sections).grid(row=1, column=0, sticky="ew", pady=(8, 0)) ttk.Button(command_strip, text="Collapse Extras", style="Secondary.TButton", command=self._collapse_all_sections).grid(row=1, column=1, sticky="ew", padx=(8, 0), pady=(8, 0)) - self.command_preset_combo = ttk.Combobox(command_strip, textvariable=self.behaviour_preset_var, values=("Balanced", "Precision", "Burst Sprint", "Human Mimic"), state="readonly") + self.command_preset_combo = ttk.Combobox(command_strip, textvariable=self.behaviour_preset_var, values=tuple(sorted(PROFILE_ENUM_FIELDS["behaviour_preset"])), state="readonly") self.command_preset_combo.grid(row=1, column=2, columnspan=2, sticky="ew", padx=(8, 0), pady=(8, 0)) ttk.Button(command_strip, text="Apply Preset", style="Secondary.TButton", command=self._apply_behaviour_preset).grid(row=1, column=4, sticky="ew", padx=(8, 0), pady=(8, 0)) - ttk.Button(command_strip, text="Health Check", style="Secondary.TButton", command=self._open_health_dashboard).grid(row=1, column=5, sticky="ew", padx=(8, 0), pady=(8, 0)) - ttk.Button(command_strip, text="Session Report", style="Secondary.TButton", command=self._export_session_report).grid(row=1, column=6, sticky="ew", padx=(8, 0), pady=(8, 0)) - ttk.Button(command_strip, text="State Folder", style="Secondary.TButton", command=self._open_state_folder).grid(row=1, column=7, sticky="ew", padx=(8, 0), pady=(8, 0)) - tk.Label(command_strip, textvariable=self.safety_status_var, bg="#edf4ff", fg="#334155", font=("Segoe UI", 9, "bold"), wraplength=1030, justify=LEFT).grid(row=2, column=0, columnspan=8, sticky="w", pady=(10, 0)) - tk.Label(command_strip, textvariable=self.preset_summary_var, bg="#edf4ff", fg="#0f766e", font=("Segoe UI", 9, "bold"), wraplength=1030, justify=LEFT).grid(row=3, column=0, columnspan=8, sticky="w", pady=(4, 0)) + ttk.Button(command_strip, text="Safety Guard", style="Secondary.TButton", command=lambda: self._set_dropdown_state("safety_guard", True)).grid(row=1, column=5, sticky="ew", padx=(8, 0), pady=(8, 0)) + ttk.Button(command_strip, text="Health Check", style="Secondary.TButton", command=self._open_health_dashboard).grid(row=1, column=6, sticky="ew", padx=(8, 0), pady=(8, 0)) + ttk.Button(command_strip, text="Run History", style="Secondary.TButton", command=self._open_run_history).grid(row=1, column=7, sticky="ew", padx=(8, 0), pady=(8, 0)) + ttk.Button(command_strip, text="Session Report", style="Secondary.TButton", command=self._export_session_report).grid(row=2, column=0, sticky="ew", pady=(8, 0)) + ttk.Button(command_strip, text="State Folder", style="Secondary.TButton", command=self._open_state_folder).grid(row=2, column=1, sticky="ew", padx=(8, 0), pady=(8, 0)) + tk.Label(command_strip, textvariable=self.live_rate_var, bg="#edf4ff", fg="#0f766e", font=("Segoe UI", 10, "bold")).grid(row=2, column=2, columnspan=3, sticky="w", padx=(12, 0), pady=(8, 0)) + tk.Label(command_strip, textvariable=self.lifetime_stats_var, bg="#edf4ff", fg="#475569", font=("Segoe UI", 9)).grid(row=2, column=5, columnspan=3, sticky="w", padx=(12, 0), pady=(8, 0)) + tk.Label(command_strip, textvariable=self.safety_status_var, bg="#edf4ff", fg="#334155", font=("Segoe UI", 9, "bold"), wraplength=1030, justify=LEFT).grid(row=3, column=0, columnspan=8, sticky="w", pady=(10, 0)) + tk.Label(command_strip, textvariable=self.preset_summary_var, bg="#edf4ff", fg="#0f766e", font=("Segoe UI", 9, "bold"), wraplength=1030, justify=LEFT).grid(row=4, column=0, columnspan=8, sticky="w", pady=(4, 0)) workspace = tk.Frame(shell, bg="#dbe7f2") workspace.grid(row=2, column=0, sticky="nsew", pady=(16, 0)) @@ -4693,9 +4209,10 @@ def _build_layout(self): ttk.Button(quick_body, text="Use Current Cursor", style="Secondary.TButton", command=self._capture_cursor_position).grid(row=1, column=0, columnspan=2, sticky="ew", pady=(0, 8)) ttk.Button(quick_body, text="Swap X / Y", style="Secondary.TButton", command=self._swap_coordinates).grid(row=1, column=2, columnspan=2, sticky="ew", padx=(14, 0), pady=(0, 8)) - tk.Label(quick_body, text="Click type", bg="#f8fafc", fg="#0f172a", font=("Segoe UI", 10, "bold")).grid(row=2, column=0, sticky="w", pady=(0, 6)) - self.click_type_combo = ttk.Combobox(quick_body, textvariable=self.click_mode_var, values=tuple(self.CLICK_TYPES.keys()), state="readonly") + tk.Label(quick_body, text="Action type", bg="#f8fafc", fg="#0f172a", font=("Segoe UI", 10, "bold")).grid(row=2, column=0, sticky="w", pady=(0, 6)) + self.click_type_combo = ttk.Combobox(quick_body, textvariable=self.click_mode_var, values=tuple(ACTION_REGISTRY.keys()), state="readonly") self.click_type_combo.grid(row=2, column=1, sticky="ew", pady=(0, 6)) + self.click_type_combo.bind("<>", self._refresh_action_params) tk.Label(quick_body, text="Repeat mode", bg="#f8fafc", fg="#0f172a", font=("Segoe UI", 10, "bold")).grid(row=2, column=2, sticky="w", padx=(14, 0), pady=(0, 6)) self.repeat_mode_combo = ttk.Combobox(quick_body, textvariable=self.repeat_mode_var, values=("Infinite", "Burst Count"), state="readonly") self.repeat_mode_combo.grid(row=2, column=3, sticky="ew", pady=(0, 6)) @@ -4705,6 +4222,38 @@ def _build_layout(self): self.repeat_count_entry.grid(row=3, column=1, sticky="ew", pady=(0, 6)) tk.Label(quick_body, text="Stop hotkey", bg="#f8fafc", fg="#0f172a", font=("Segoe UI", 10, "bold")).grid(row=3, column=2, sticky="w", padx=(14, 0), pady=(0, 6)) ttk.Entry(quick_body, textvariable=self.stop_hotkey_var).grid(row=3, column=3, sticky="ew", pady=(0, 6)) + tk.Label(quick_body, textvariable=self.action_params_var, bg="#f8fafc", fg="#0f766e", font=("Segoe UI", 9), wraplength=560, justify=LEFT).grid(row=4, column=0, columnspan=4, sticky="w", pady=(2, 0)) + + action_card, action_body = self._create_dropdown_section( + left_stack, + "action_setup", + "Action Setup", + "Parameters for keyboard, scroll, hold and drag actions.", + ) + action_card.grid(row=1, column=0, sticky="ew", pady=(12, 0)) + action_body.columnconfigure(1, weight=1) + action_body.columnconfigure(3, weight=1) + + tk.Label(action_body, text="Key name", bg="#f8fafc", fg="#0f172a", font=("Segoe UI", 10, "bold")).grid(row=0, column=0, sticky="w", pady=(0, 6)) + ttk.Entry(action_body, textvariable=self.action_key_var).grid(row=0, column=1, sticky="ew", pady=(0, 6)) + tk.Label(action_body, text="Hold seconds", bg="#f8fafc", fg="#0f172a", font=("Segoe UI", 10, "bold")).grid(row=0, column=2, sticky="w", padx=(14, 0), pady=(0, 6)) + ttk.Entry(action_body, textvariable=self.hold_duration_var).grid(row=0, column=3, sticky="ew", pady=(0, 6)) + + tk.Label(action_body, text="Text to type", bg="#f8fafc", fg="#0f172a", font=("Segoe UI", 10, "bold")).grid(row=1, column=0, sticky="w", pady=(0, 6)) + ttk.Entry(action_body, textvariable=self.action_text_var).grid(row=1, column=1, columnspan=3, sticky="ew", pady=(0, 6)) + + tk.Label(action_body, text="Scroll notches", bg="#f8fafc", fg="#0f172a", font=("Segoe UI", 10, "bold")).grid(row=2, column=0, sticky="w", pady=(0, 6)) + ttk.Entry(action_body, textvariable=self.scroll_amount_var).grid(row=2, column=1, sticky="ew", pady=(0, 6)) + tk.Label(action_body, text="Drag to X / Y", bg="#f8fafc", fg="#0f172a", font=("Segoe UI", 10, "bold")).grid(row=2, column=2, sticky="w", padx=(14, 0), pady=(0, 6)) + drag_row = tk.Frame(action_body, bg="#f8fafc") + drag_row.grid(row=2, column=3, sticky="ew", pady=(0, 6)) + drag_row.columnconfigure(0, weight=1) + drag_row.columnconfigure(1, weight=1) + ttk.Entry(drag_row, textvariable=self.drag_to_x_var, width=8).grid(row=0, column=0, sticky="ew") + ttk.Entry(drag_row, textvariable=self.drag_to_y_var, width=8).grid(row=0, column=1, sticky="ew", padx=(6, 0)) + + ttk.Button(action_body, text="Use Cursor As Drag Target", style="Secondary.TButton", command=self._capture_drag_target).grid(row=3, column=0, columnspan=2, sticky="ew", pady=(0, 6)) + ttk.Checkbutton(action_body, text="Cycle recorded points (round-robin)", variable=self.round_robin_var, style="App.TCheckbutton").grid(row=3, column=2, columnspan=2, sticky="w", padx=(14, 0), pady=(0, 6)) timing_card, timing_body = self._create_dropdown_section( left_stack, @@ -4712,7 +4261,7 @@ def _build_layout(self): "Timing and Repeat", "Delay, countdown, runtime cap, and other rhythm controls live here.", ) - timing_card.grid(row=1, column=0, sticky="ew", pady=(12, 0)) + timing_card.grid(row=2, column=0, sticky="ew", pady=(12, 0)) timing_body.columnconfigure(1, weight=1) timing_body.columnconfigure(3, weight=1) @@ -4732,13 +4281,32 @@ def _build_layout(self): for index, preset in enumerate(self.DELAY_PRESETS): ttk.Button(preset_bar, text=preset, style="Chip.TButton", command=lambda value=preset: self._set_delay_preset(value)).grid(row=0, column=index, padx=(0 if index == 0 else 4, 0)) + tk.Label(timing_body, text="Target actions/sec", bg="#f8fafc", fg="#0f172a", font=("Segoe UI", 10, "bold")).grid(row=3, column=0, sticky="w", pady=(0, 6)) + ttk.Entry(timing_body, textvariable=self.target_cps_var).grid(row=3, column=1, sticky="ew", pady=(0, 6)) + cps_row = tk.Frame(timing_body, bg="#f8fafc") + cps_row.grid(row=3, column=2, columnspan=2, sticky="w", padx=(14, 0), pady=(0, 6)) + ttk.Button(cps_row, text="Apply Rate", style="Secondary.TButton", command=self._apply_target_cps).grid(row=0, column=0) + ttk.Button(cps_row, text="Read From Delay", style="Secondary.TButton", command=self._sync_cps_from_delay).grid(row=0, column=1, padx=(6, 0)) + cps_preset_bar = tk.Frame(timing_body, bg="#f8fafc") + cps_preset_bar.grid(row=4, column=1, columnspan=3, sticky="w", pady=(0, 6)) + for index, preset in enumerate(self.CPS_PRESETS): + ttk.Button( + cps_preset_bar, text=f"{preset}/s", style="Chip.TButton", + command=lambda value=preset: (self.target_cps_var.set(value), self._apply_target_cps()), + ).grid(row=0, column=index, padx=(0 if index == 0 else 4, 0)) + + tk.Label(timing_body, text="Pacing mode", bg="#f8fafc", fg="#0f172a", font=("Segoe UI", 10, "bold")).grid(row=5, column=0, sticky="w", pady=(0, 6)) + ttk.Combobox(timing_body, textvariable=self.pacing_mode_var, values=("Precise", "Legacy V10.1"), state="readonly").grid(row=5, column=1, sticky="ew", pady=(0, 6)) + tk.Label(timing_body, text="Start at (HH:MM)", bg="#f8fafc", fg="#0f172a", font=("Segoe UI", 10, "bold")).grid(row=5, column=2, sticky="w", padx=(14, 0), pady=(0, 6)) + ttk.Entry(timing_body, textvariable=self.scheduled_start_var).grid(row=5, column=3, sticky="ew", pady=(0, 6)) + safety_card, safety_body = self._create_dropdown_section( left_stack, "safety_guard", "Safety Guard", "Action caps, corner fail-safe behaviour, and plan validation.", ) - safety_card.grid(row=2, column=0, sticky="ew", pady=(12, 0)) + safety_card.grid(row=3, column=0, sticky="ew", pady=(12, 0)) safety_body.columnconfigure(1, weight=1) safety_body.columnconfigure(3, weight=1) @@ -4761,12 +4329,12 @@ def _build_layout(self): "Innovation Lab", "Preset behaviours, jitter shaping, and fatigue-friendly pacing options.", ) - innovation_card.grid(row=3, column=0, sticky="ew", pady=(12, 0)) + innovation_card.grid(row=4, column=0, sticky="ew", pady=(12, 0)) innovation_body.columnconfigure(1, weight=1) innovation_body.columnconfigure(3, weight=1) tk.Label(innovation_body, text="Behaviour preset", bg="#f8fafc", fg="#0f172a", font=("Segoe UI", 10, "bold")).grid(row=0, column=0, sticky="w", pady=(0, 6)) - self.innovation_preset_combo = ttk.Combobox(innovation_body, textvariable=self.behaviour_preset_var, values=("Balanced", "Precision", "Burst Sprint", "Human Mimic"), state="readonly") + self.innovation_preset_combo = ttk.Combobox(innovation_body, textvariable=self.behaviour_preset_var, values=tuple(sorted(PROFILE_ENUM_FIELDS["behaviour_preset"])), state="readonly") self.innovation_preset_combo.grid(row=0, column=1, sticky="ew", pady=(0, 6)) ttk.Button(innovation_body, text="Apply", style="Secondary.TButton", command=self._apply_behaviour_preset).grid(row=0, column=2, sticky="ew", padx=(14, 0), pady=(0, 6)) ttk.Button(innovation_body, text="Open Window Studio", style="Secondary.TButton", command=lambda: self._set_dropdown_state("window_studio", True)).grid(row=0, column=3, sticky="ew", pady=(0, 6)) @@ -4790,13 +4358,35 @@ def _build_layout(self): ttk.Checkbutton(innovation_flags, text="Minimize while clicking", variable=self.minimize_on_start_var, style="App.TCheckbutton").grid(row=0, column=2, sticky="w", padx=(18, 0)) ttk.Checkbutton(innovation_flags, text="Restore after run", variable=self.restore_after_run_var, style="App.TCheckbutton").grid(row=0, column=3, sticky="w", padx=(18, 0)) + hotkey_card, hotkey_body = self._create_dropdown_section( + left_stack, + "hotkey_center", + "Hotkey Center", + "System-wide hotkeys so the app is drivable while it is minimised over the target.", + ) + hotkey_card.grid(row=5, column=0, sticky="ew", pady=(12, 0)) + hotkey_body.columnconfigure(1, weight=1) + hotkey_body.columnconfigure(3, weight=1) + + tk.Label(hotkey_body, text="Start hotkey", bg="#f8fafc", fg="#0f172a", font=("Segoe UI", 10, "bold")).grid(row=0, column=0, sticky="w", pady=(0, 6)) + ttk.Entry(hotkey_body, textvariable=self.start_hotkey_var).grid(row=0, column=1, sticky="ew", pady=(0, 6)) + tk.Label(hotkey_body, text="Pause hotkey", bg="#f8fafc", fg="#0f172a", font=("Segoe UI", 10, "bold")).grid(row=0, column=2, sticky="w", padx=(14, 0), pady=(0, 6)) + ttk.Entry(hotkey_body, textvariable=self.pause_hotkey_var).grid(row=0, column=3, sticky="ew", pady=(0, 6)) + + tk.Label(hotkey_body, text="Capture hotkey", bg="#f8fafc", fg="#0f172a", font=("Segoe UI", 10, "bold")).grid(row=1, column=0, sticky="w", pady=(0, 6)) + ttk.Entry(hotkey_body, textvariable=self.capture_hotkey_var).grid(row=1, column=1, sticky="ew", pady=(0, 6)) + ttk.Checkbutton(hotkey_body, text="Enable global hotkeys", variable=self.global_hotkeys_var, command=self._toggle_global_hotkeys, style="App.TCheckbutton").grid(row=1, column=2, columnspan=2, sticky="w", padx=(14, 0), pady=(0, 6)) + ttk.Button(hotkey_body, text="Re-register Hotkeys", style="Secondary.TButton", command=self._register_global_hotkeys).grid(row=2, column=0, columnspan=2, sticky="ew", pady=(0, 6)) + tk.Label(hotkey_body, text="In-window: F5 start, F6 pause, Esc stop.", bg="#f8fafc", fg="#475569", font=("Segoe UI", 9)).grid(row=2, column=2, columnspan=2, sticky="w", padx=(14, 0), pady=(0, 6)) + tk.Label(hotkey_body, textvariable=self.hotkey_status_var, bg="#f8fafc", fg="#0f766e", font=("Segoe UI", 9, "bold"), wraplength=620, justify=LEFT).grid(row=3, column=0, columnspan=4, sticky="w") + tools_card, tools_body = self._create_dropdown_section( left_stack, "tools", "Tools and Helpers", "Open specialist windows only when you need them instead of keeping everything on screen.", ) - tools_card.grid(row=4, column=0, sticky="ew", pady=(12, 0)) + tools_card.grid(row=6, column=0, sticky="ew", pady=(12, 0)) tools_body.columnconfigure(0, weight=1) tools_body.columnconfigure(1, weight=1) @@ -4821,7 +4411,7 @@ def _build_layout(self): "Profiles and Recall", "Save and reload working setups without keeping the controls visible all the time.", ) - profiles_card.grid(row=5, column=0, sticky="ew", pady=(12, 0)) + profiles_card.grid(row=7, column=0, sticky="ew", pady=(12, 0)) profiles_body.columnconfigure(0, weight=1) profiles_body.columnconfigure(1, weight=1) @@ -4835,6 +4425,11 @@ def _build_layout(self): ttk.Button(profiles_body, text="Delete Profile", style="Secondary.TButton", command=self._delete_selected_profile).grid(row=5, column=0, columnspan=2, sticky="ew", pady=(8, 0)) ttk.Button(profiles_body, text="Export Profiles", style="Secondary.TButton", command=self._export_profiles).grid(row=6, column=0, sticky="ew", pady=(8, 0)) ttk.Button(profiles_body, text="Import Profiles", style="Secondary.TButton", command=self._import_profiles).grid(row=6, column=1, sticky="ew", padx=(8, 0), pady=(8, 0)) + self.profile_undo_button = ttk.Button( + profiles_body, text="Undo Profile Change", style="Secondary.TButton", + command=self._undo_profile_change, state=DISABLED, + ) + self.profile_undo_button.grid(row=7, column=0, columnspan=2, sticky="ew", pady=(8, 0)) window_card, window_body = self._create_dropdown_section( left_stack, @@ -4842,7 +4437,7 @@ def _build_layout(self): "Window Studio", "Desktop-focused options, visual tuning, and layout presets stay concealed until needed.", ) - window_card.grid(row=6, column=0, sticky="ew", pady=(12, 0)) + window_card.grid(row=8, column=0, sticky="ew", pady=(12, 0)) window_body.columnconfigure(1, weight=1) tk.Label(window_body, text="Theme", bg="#f8fafc", fg="#0f172a", font=("Segoe UI", 10, "bold")).grid(row=0, column=0, sticky="w", pady=(0, 6)) @@ -4906,8 +4501,10 @@ def _build_layout(self): self.session_elapsed_var = tk.StringVar(value="Session Elapsed: 00:00:00") tk.Label(summary_body, textvariable=self.total_session_clicks_var, bg="#f8fafc", fg="#0f766e", font=("Segoe UI", 9, "bold")).grid(row=14, column=0, sticky="w") tk.Label(summary_body, textvariable=self.session_elapsed_var, bg="#f8fafc", fg="#0f766e", font=("Segoe UI", 9, "bold")).grid(row=15, column=0, sticky="w", pady=(4, 0)) - tk.Label(summary_body, text="Preset", bg="#f8fafc", fg="#0f172a", font=("Segoe UI", 10, "bold")).grid(row=16, column=0, sticky="w", pady=(12, 0)) - tk.Label(summary_body, textvariable=self.preset_summary_var, bg="#f8fafc", fg="#334155", font=("Segoe UI", 10), wraplength=360, justify=LEFT).grid(row=17, column=0, sticky="w", pady=(4, 0)) + tk.Label(summary_body, textvariable=self.live_rate_var, bg="#f8fafc", fg="#0f766e", font=("Segoe UI", 9, "bold"), wraplength=360, justify=LEFT).grid(row=16, column=0, sticky="w", pady=(4, 0)) + tk.Label(summary_body, textvariable=self.lifetime_stats_var, bg="#f8fafc", fg="#475569", font=("Segoe UI", 9), wraplength=360, justify=LEFT).grid(row=17, column=0, sticky="w", pady=(4, 0)) + tk.Label(summary_body, text="Preset", bg="#f8fafc", fg="#0f172a", font=("Segoe UI", 10, "bold")).grid(row=18, column=0, sticky="w", pady=(12, 0)) + tk.Label(summary_body, textvariable=self.preset_summary_var, bg="#f8fafc", fg="#334155", font=("Segoe UI", 10), wraplength=360, justify=LEFT).grid(row=19, column=0, sticky="w", pady=(4, 0)) support_card, support_body = self._create_card( right_stack, @@ -4935,9 +4532,26 @@ def _build_layout(self): ttk.Button(controls_body, text="Old Style GUI", style="Secondary.TButton", command=self._open_old_window).grid(row=1, column=0, sticky="ew", pady=(0, 8)) ttk.Button(controls_body, text="Recording Studio", style="Secondary.TButton", command=self._open_recording_studio).grid(row=2, column=0, sticky="ew", pady=(0, 8)) tk.Label(controls_body, text="Recent activity", bg="#f8fafc", fg="#0f172a", font=("Segoe UI", 10, "bold")).grid(row=3, column=0, sticky="w", pady=(4, 6)) - self.activity_list = Listbox(controls_body, height=10, font=("Segoe UI", 9), bg="white", fg="#0f172a", selectbackground="#1d4ed8", activestyle="none", exportselection=False) - self.activity_list.grid(row=4, column=0, sticky="nsew", pady=(0, 8)) - ttk.Button(controls_body, text="Exit", style="Secondary.TButton", command=self.EXITME).grid(row=5, column=0, sticky="ew") + # The feed kept 80 entries but only ever showed the last 12, with no scrollbar. + activity_frame = tk.Frame(controls_body, bg="#f8fafc") + activity_frame.grid(row=4, column=0, sticky="nsew", pady=(0, 8)) + activity_frame.columnconfigure(0, weight=1) + activity_frame.rowconfigure(0, weight=1) + self.activity_list = Listbox(activity_frame, height=10, font=("Segoe UI", 9), bg="white", fg="#0f172a", selectbackground="#1d4ed8", activestyle="none", exportselection=False) + self.activity_list.grid(row=0, column=0, sticky="nsew") + activity_scroll = ttk.Scrollbar(activity_frame, orient="vertical", command=self.activity_list.yview) + activity_scroll.grid(row=0, column=1, sticky="ns") + self.activity_list.configure(yscrollcommand=activity_scroll.set) + self.activity_list.bind("", self._copy_activity_selection) + self.activity_list.bind("", self._copy_activity_selection) + + activity_actions = tk.Frame(controls_body, bg="#f8fafc") + activity_actions.grid(row=5, column=0, sticky="ew", pady=(0, 8)) + activity_actions.columnconfigure(0, weight=1) + activity_actions.columnconfigure(1, weight=1) + ttk.Button(activity_actions, text="Copy Log", style="Secondary.TButton", command=self._copy_activity_log).grid(row=0, column=0, sticky="ew") + ttk.Button(activity_actions, text="Export Log", style="Secondary.TButton", command=self._export_activity_log).grid(row=0, column=1, sticky="ew", padx=(8, 0)) + ttk.Button(controls_body, text="Exit", style="Secondary.TButton", command=self.EXITME).grid(row=6, column=0, sticky="ew") footer = tk.Frame(shell, bg="#dbe7f2") footer.grid(row=3, column=0, sticky="ew", pady=(16, 0)) @@ -4968,6 +4582,14 @@ def _build_layout(self): self.theme_var, self.profile_name_var, self.profile_choice_var, + self.action_key_var, + self.action_text_var, + self.scroll_amount_var, + self.hold_duration_var, + self.drag_to_x_var, + self.drag_to_y_var, + self.pacing_mode_var, + self.scheduled_start_var, ): variable.trace_add("write", self._handle_state_change) for variable in ( @@ -4986,9 +4608,12 @@ def _build_layout(self): self.play_sound_var, self.dry_run_var, self.pyautogui_failsafe_var, + self.round_robin_var, ): variable.trace_add("write", self._handle_state_change) + self._refresh_action_params() + self.repeat_mode_combo.bind("<>", lambda _event: self._update_repeat_state()) self.profile_combo.bind("<>", self._sync_profile_name_from_choice) self.theme_combo_main.bind("<>", lambda _event: self._apply_theme()) @@ -5004,8 +4629,9 @@ def _build_menu(self): menu = Menu(self) tools_menu = Menu(menu, tearoff=0) - tools_menu.add_command(label='Start', command=self.startclick) - tools_menu.add_command(label='Stop', command=self.stopclick) + tools_menu.add_command(label='Start', command=self.startclick, accelerator='F5') + tools_menu.add_command(label='Pause / Resume', command=self.toggle_pause, accelerator='F6') + tools_menu.add_command(label='Stop', command=self.stopclick, accelerator='Esc') tools_menu.add_separator() tools_menu.add_command(label='Find Coordinates', command=self._open_finder) tools_menu.add_command(label='Coordinate Sequence', command=self._open_sequence_builder) @@ -5015,6 +4641,7 @@ def _build_menu(self): tools_menu.add_command(label='Auto Clicker Mega Spam', command=self._open_mega_spam) tools_menu.add_command(label='Recording Studio', command=self._open_recording_studio) tools_menu.add_command(label='Health Check', command=self._open_health_dashboard) + tools_menu.add_command(label='Run History', command=self._open_run_history) tools_menu.add_command(label='Export Session Report', command=self._export_session_report) tools_menu.add_command(label='Create Support Bundle', command=self._create_support_bundle) tools_menu.add_command(label='Backup State Snapshot', command=self._backup_state_snapshot) @@ -5039,8 +4666,9 @@ def _build_menu(self): def _build_context_menu(self): self.popup = Menu(self, tearoff=0) self.popup.add_command(label='Capture Current Cursor', command=self._capture_cursor_position) - self.popup.add_command(label='Start', command=self.startclick) - self.popup.add_command(label='Stop', command=self.stopclick) + self.popup.add_command(label='Start', command=self.startclick, accelerator='F5') + self.popup.add_command(label='Pause / Resume', command=self.toggle_pause, accelerator='F6') + self.popup.add_command(label='Stop', command=self.stopclick, accelerator='Esc') self.popup.add_separator() self.popup.add_command(label='Coordinate Sequence', command=self._open_sequence_builder) self.popup.add_command(label='Advanced Colour Clicker', command=Colour_Clicker) @@ -5049,6 +4677,7 @@ def _build_context_menu(self): self.popup.add_command(label='Photo Clicker', command=Photo_Clicker) self.popup.add_command(label='Recording Studio', command=self._open_recording_studio) self.popup.add_command(label='Health Check', command=self._open_health_dashboard) + self.popup.add_command(label='Run History', command=self._open_run_history) self.popup.add_command(label='Export Session Report', command=self._export_session_report) self.popup.add_command(label='Create Support Bundle', command=self._create_support_bundle) self.popup.add_command(label='Backup State Snapshot', command=self._backup_state_snapshot) @@ -5104,14 +4733,14 @@ def _normalize_window_opacity(self): opacity = float(self.window_opacity_var.get()) except Exception: opacity = 1.0 - return max(0.70, min(1.00, opacity)) + return max(WINDOW_OPACITY_RANGE[0], min(WINDOW_OPACITY_RANGE[1], opacity)) def _normalize_ui_scale(self): try: scale = float(self.ui_scale_var.get()) except Exception: scale = 1.0 - return max(0.90, min(1.35, scale)) + return max(UI_SCALE_RANGE[0], min(UI_SCALE_RANGE[1], scale)) def _refresh_window_summary(self): close_action = "tray" if self.close_to_tray_var.get() else "exit" @@ -5292,6 +4921,15 @@ def _collect_profile_data(self): "dry_run": self.dry_run_var.get(), "pyautogui_failsafe": self.pyautogui_failsafe_var.get(), "theme": self.theme_var.get(), + "action_key": self.action_key_var.get(), + "action_text": self.action_text_var.get(), + "scroll_amount": self.scroll_amount_var.get(), + "hold_duration": self.hold_duration_var.get(), + "drag_to_x": self.drag_to_x_var.get(), + "drag_to_y": self.drag_to_y_var.get(), + "pacing_mode": self.pacing_mode_var.get(), + "scheduled_start": self.scheduled_start_var.get(), + "target_cps": self.target_cps_var.get(), } def _apply_profile_data(self, profile_data): @@ -5324,7 +4962,17 @@ def _apply_profile_data(self, profile_data): self.dry_run_var.set(bool(profile_data.get("dry_run", self.dry_run_var.get()))) self.pyautogui_failsafe_var.set(bool(profile_data.get("pyautogui_failsafe", self.pyautogui_failsafe_var.get()))) self.theme_var.set(profile_data.get("theme", self.theme_var.get())) + self.action_key_var.set(str(profile_data.get("action_key", self.action_key_var.get()))) + self.action_text_var.set(str(profile_data.get("action_text", self.action_text_var.get()))) + self.scroll_amount_var.set(str(profile_data.get("scroll_amount", self.scroll_amount_var.get()))) + self.hold_duration_var.set(str(profile_data.get("hold_duration", self.hold_duration_var.get()))) + self.drag_to_x_var.set(str(profile_data.get("drag_to_x", self.drag_to_x_var.get()))) + self.drag_to_y_var.set(str(profile_data.get("drag_to_y", self.drag_to_y_var.get()))) + self.pacing_mode_var.set(profile_data.get("pacing_mode", self.pacing_mode_var.get())) + self.scheduled_start_var.set(str(profile_data.get("scheduled_start", self.scheduled_start_var.get()))) + self.target_cps_var.set(str(profile_data.get("target_cps", self.target_cps_var.get()))) self._refresh_preset_summary() + self._refresh_action_params() self._apply_theme() self._apply_window_preferences() self._update_repeat_state() @@ -5356,10 +5004,35 @@ def _append_activity(self, message): self.activity_history = self.activity_history[-80:] if hasattr(self, "activity_list") and self.activity_list.winfo_exists(): self.activity_list.delete(0, END) - for log_entry in self.activity_history[-12:]: + # Show the whole retained history; the scrollbar makes it reachable. + for log_entry in self.activity_history: self.activity_list.insert(END, log_entry) self.activity_list.yview_moveto(1.0) + def _copy_activity_selection(self, _event=None): + selection = self.activity_list.curselection() + if not selection: + return + self._copy_to_clipboard(self.activity_list.get(selection[0]), "Activity entry copied.") + + def _copy_activity_log(self): + if not self.activity_history: + self.status_var.set("No activity to copy yet.") + return + self._copy_to_clipboard( + "\n".join(self.activity_history), + f"Copied {len(self.activity_history)} activity line(s) to the clipboard.", + ) + + def _copy_to_clipboard(self, text, message): + try: + self.clipboard_clear() + self.clipboard_append(text) + self.update_idletasks() + self.status_var.set(message) + except Exception as exc: + self.status_var.set(f"Clipboard unavailable: {exc}") + def _serialize_workspace_state(self): geometry = "" if self.winfo_exists(): @@ -5370,13 +5043,20 @@ def _serialize_workspace_state(self): geometry = "" return { + "schema_version": STATE_SCHEMA_VERSION, + "app_version": APP_VERSION, "profile_data": self._collect_profile_data(), "profile_name": self.profile_name_var.get(), "profile_choice": self.profile_choice_var.get(), "geometry": geometry, "recording_data": self.recording_data[-200:], - "activity_history": self.activity_history[-40:], - "run_reports": self.run_reports[-20:], + "activity_history": self.activity_history[-80:], + "run_reports": self.run_reports[-40:], + "round_robin": bool(self.round_robin_var.get()), + "global_hotkeys": bool(self.global_hotkeys_var.get()), + "start_hotkey": self.start_hotkey_var.get(), + "pause_hotkey": self.pause_hotkey_var.get(), + "capture_hotkey": self.capture_hotkey_var.get(), } def _write_workspace_to_disk(self): @@ -5411,10 +5091,17 @@ def _load_workspace_from_disk(self): except Exception as exc: self.status_var.set(f"Workspace file could not be loaded: {exc}") return + if not isinstance(workspace_state, dict): + self.status_var.set("Workspace file is not a JSON object; keeping defaults.") + return profile_data = workspace_state.get("profile_data") if isinstance(profile_data, dict): - self._apply_profile_data(profile_data) + # A malformed persisted profile must not take the whole app down at startup. + try: + self._apply_profile_data(profile_data) + except Exception as exc: + self.status_var.set(f"Saved setup could not be restored ({exc}); defaults kept.") self.profile_name_var.set(str(workspace_state.get("profile_name", self.profile_name_var.get()))) self.profile_choice_var.set(str(workspace_state.get("profile_choice", self.profile_choice_var.get()))) @@ -5433,11 +5120,19 @@ def _load_workspace_from_disk(self): if isinstance(run_reports, list): self.run_reports = [report for report in run_reports[-20:] if isinstance(report, dict)] + self.round_robin_var.set(bool(workspace_state.get("round_robin", False))) + self.start_hotkey_var.set(str(workspace_state.get("start_hotkey", self.start_hotkey_var.get()))) + self.pause_hotkey_var.set(str(workspace_state.get("pause_hotkey", self.pause_hotkey_var.get()))) + self.capture_hotkey_var.set(str(workspace_state.get("capture_hotkey", self.capture_hotkey_var.get()))) + if bool(workspace_state.get("global_hotkeys", False)): + self.global_hotkeys_var.set(True) + self._register_global_hotkeys() + geometry = workspace_state.get("geometry") if geometry and self.remember_window_geometry_var.get(): try: - self.geometry(str(geometry)) - except: + self.geometry(_clamp_geometry_to_screen(str(geometry), self.screen_width, self.screen_height)) + except Exception: pass def _handle_state_change(self, *_args): @@ -5514,19 +5209,63 @@ def _delete_selected_profile(self): if not messagebox.askyesno("Delete profile", f"Delete profile '{profile_name}'?", parent=self): return + removed_payload = self.saved_profiles[profile_name] del self.saved_profiles[profile_name] try: self._write_profiles_to_disk() except Exception as exc: + # Put it back: the file write failed, so the in-memory set must not diverge. + self.saved_profiles[profile_name] = removed_payload messagebox.showerror("Delete failed", f"Unable to update the profile file.\n{exc}", parent=self) return + self._push_profile_undo("delete", {profile_name: removed_payload}) self._refresh_profile_choices() self._refresh_profile_state() - self.status_var.set(f"Deleted profile '{profile_name}'.") + self.status_var.set(f"Deleted profile '{profile_name}'. Use Undo Profile Change to restore it.") self._append_activity(f"Deleted profile '{profile_name}'.") self._schedule_workspace_save() + def _push_profile_undo(self, label, removed_profiles): + """Remember the profiles a destructive action removed or overwrote.""" + self.profile_undo_stack.append({ + "label": label, + "profiles": {name: dict(payload) for name, payload in removed_profiles.items()}, + "at": datetime.datetime.now().isoformat(timespec="seconds"), + }) + self.profile_undo_stack = self.profile_undo_stack[-10:] + if hasattr(self, "profile_undo_button"): + self.profile_undo_button.configure(state=NORMAL) + + def _undo_profile_change(self): + """Restore the profiles removed or overwritten by the last destructive action.""" + if not self.profile_undo_stack: + self.status_var.set("Nothing to undo.") + return + entry = self.profile_undo_stack.pop() + restored = list(entry["profiles"]) + previous = {name: self.saved_profiles.get(name) for name in restored} + self.saved_profiles.update(entry["profiles"]) + try: + self._write_profiles_to_disk() + except Exception as exc: + for name, payload in previous.items(): + if payload is None: + self.saved_profiles.pop(name, None) + else: + self.saved_profiles[name] = payload + self.profile_undo_stack.append(entry) + messagebox.showerror("Undo failed", f"Unable to update the profile file.\n{exc}", parent=self) + return + + if hasattr(self, "profile_undo_button") and not self.profile_undo_stack: + self.profile_undo_button.configure(state=DISABLED) + self._refresh_profile_choices() + self._refresh_profile_state() + self.status_var.set(f"Restored {len(restored)} profile(s) from the last {entry['label']}.") + self._append_activity(f"Undid profile {entry['label']}: restored {', '.join(sorted(restored))}.") + self._schedule_workspace_save() + def _export_profiles(self): if not self.saved_profiles: messagebox.showinfo("Export profiles", "There are no saved profiles to export.", parent=self) @@ -5542,8 +5281,7 @@ def _export_profiles(self): return try: - with open(export_path, "w", encoding="utf-8") as export_handle: - json.dump(self.saved_profiles, export_handle, indent=2, sort_keys=True) + _atomic_write_json(export_path, self.saved_profiles, sort_keys=True) except Exception as exc: messagebox.showerror("Export failed", f"Unable to export profiles.\n{exc}", parent=self) return @@ -5570,7 +5308,7 @@ def _import_profiles(self): return try: - preview = _preview_profile_import(imported_profiles, self.saved_profiles, self.CLICK_TYPES) + preview = _preview_profile_import(imported_profiles, self.saved_profiles, self.ACTION_TYPES) except Exception as exc: messagebox.showerror("Import failed", str(exc), parent=self) return @@ -5610,6 +5348,12 @@ def preview_names(label, names): self.status_var.set("Profile import cancelled after preview.") return + # Snapshot anything about to be overwritten so the import is reversible. + overwritten = { + name: dict(self.saved_profiles[name]) + for name in preview["valid_profiles"] + if name in self.saved_profiles + } self.saved_profiles.update(preview["valid_profiles"]) try: @@ -5618,9 +5362,12 @@ def preview_names(label, names): messagebox.showerror("Import failed", f"Unable to update the local profile file.\n{exc}", parent=self) return + if overwritten: + self._push_profile_undo("import overwrite", overwritten) self._refresh_profile_choices() self._refresh_profile_state() - self.status_var.set(f"Imported {preview['valid_count']} profile(s).") + overwrite_note = f" ({len(overwritten)} overwritten, undoable)" if overwritten else "" + self.status_var.set(f"Imported {preview['valid_count']} profile(s){overwrite_note}.") self._append_activity( f"Imported {preview['valid_count']} profile(s) from {os.path.basename(import_path)}." ) @@ -5843,180 +5590,6 @@ def refresh_report(): refresh_report() - def _open_recording_studio(self): - window = tk.Toplevel(self) - window.title("Recording Studio") - window.geometry("620x520+320+190") - window.resizable(False, False) - window.attributes("-topmost", True) - window.configure(bg="#edf4ff") - try: - window.iconbitmap(_resource_path("favicon.ico")) - except: - pass - - body = tk.Frame(window, bg="#edf4ff", padx=16, pady=16) - body.pack(fill="both", expand=True) - body.columnconfigure(0, weight=1) - body.columnconfigure(1, weight=1) - - summary_var = tk.StringVar(value="") - helper_var = tk.StringVar(value="Manage recorded points, then use Playback or import them into a sequence.") - state = {"snapshot": None, "recording_flag": None} - - tk.Label(body, text="Recording Studio", bg="#edf4ff", fg="#0f172a", font=("Segoe UI", 12, "bold")).grid(row=0, column=0, columnspan=2, sticky="w") - tk.Label(body, text="Capture, review, save, load, and trim recorded points without leaving the main app.", bg="#edf4ff", fg="#475569", font=("Segoe UI", 9)).grid(row=1, column=0, columnspan=2, sticky="w", pady=(2, 12)) - - list_box = Listbox(body, width=62, height=16, font=("Segoe UI", 10), bg="white", fg="#0f172a", selectbackground="#1d4ed8", activestyle="none", exportselection=False) - list_box.grid(row=2, column=0, columnspan=2, sticky="ew") - - tk.Label(body, textvariable=summary_var, bg="#edf4ff", fg="#0f766e", font=("Segoe UI", 9, "bold")).grid(row=3, column=0, columnspan=2, sticky="w", pady=(8, 12)) - - def selected_index(): - selection = list_box.curselection() - return selection[0] if selection else None - - def render_points(preferred_index=None): - snapshot = tuple(self.recording_data) - if snapshot == state["snapshot"] and self.is_recording == state["recording_flag"]: - return - - current_index = selected_index() if preferred_index is None else preferred_index - list_box.delete(0, END) - for index, (x_pos, y_pos) in enumerate(self.recording_data, start=1): - list_box.insert(END, f"{index}. ({x_pos}, {y_pos})") - - if current_index is not None and 0 <= current_index < len(self.recording_data): - list_box.selection_set(current_index) - list_box.activate(current_index) - - summary_var.set( - f"{len(self.recording_data)} point(s) captured | recording is {'active' if self.is_recording else 'idle'}" - ) - state["snapshot"] = snapshot - state["recording_flag"] = self.is_recording - - def schedule_refresh(): - if not window.winfo_exists(): - return - render_points() - window.after(250, schedule_refresh) - - def capture_cursor_point(): - if not _ensure_dependencies("Recording Studio", ["pyautogui"], parent=window): - return - - try: - x_pos, y_pos = pyautogui.position() - except Exception as exc: - messagebox.showerror("Capture failed", f"Unable to read the cursor position.\n{exc}", parent=window) - return - - self.recording_data.append((int(x_pos), int(y_pos))) - render_points(len(self.recording_data) - 1) - helper_var.set(f"Captured point ({x_pos}, {y_pos}).") - self._append_activity(f"Captured recording point {x_pos}, {y_pos}.") - self._schedule_workspace_save() - - def use_selected_for_target(): - current_index = selected_index() - if current_index is None: - return - x_pos, y_pos = self.recording_data[current_index] - self.target_x_var.set(str(x_pos)) - self.target_y_var.set(str(y_pos)) - helper_var.set(f"Loaded ({x_pos}, {y_pos}) into the main planner.") - self._append_activity(f"Loaded recording point {x_pos}, {y_pos} into the planner.") - - def undo_last_point(): - if not self.recording_data: - return - removed_point = self.recording_data.pop() - render_points(len(self.recording_data) - 1 if self.recording_data else None) - helper_var.set(f"Removed last point {removed_point}.") - self._append_activity(f"Removed last recording point {removed_point}.") - self._schedule_workspace_save() - - def delete_selected_point(): - current_index = selected_index() - if current_index is None: - return - removed_point = self.recording_data.pop(current_index) - render_points(min(current_index, len(self.recording_data) - 1) if self.recording_data else None) - helper_var.set(f"Removed point {removed_point}.") - self._append_activity(f"Removed recording point {removed_point}.") - self._schedule_workspace_save() - - def clear_points(): - if not self.recording_data: - return - if not messagebox.askyesno("Clear recording", "Remove every recorded point?", parent=window): - return - self.recording_data.clear() - render_points() - helper_var.set("Recording cleared.") - self._append_activity("Cleared all recorded points.") - self._schedule_workspace_save() - - def save_recording(): - if not self.recording_data: - messagebox.showinfo("Recording Studio", "Capture at least one point before saving.", parent=window) - return - - file_path = filedialog.asksaveasfilename( - defaultextension=".json", - filetypes=[("JSON files", "*.json")], - initialfile="autoclicker_recording.json", - parent=window, - ) - if not file_path: - return - - try: - with open(file_path, "w", encoding="utf-8") as file_handle: - json.dump(self.recording_data, file_handle, indent=2) - except Exception as exc: - messagebox.showerror("Save failed", f"Unable to save the recording.\n{exc}", parent=window) - return - - helper_var.set(f"Saved {len(self.recording_data)} point(s) to {os.path.basename(file_path)}.") - self._append_activity(f"Saved recording to {os.path.basename(file_path)}.") - - def load_recording(): - file_path = filedialog.askopenfilename( - filetypes=[("JSON files", "*.json")], - parent=window, - ) - if not file_path: - return - - try: - loaded_points = _normalize_recording_points(_load_json_file(file_path), strict=True) - except Exception as exc: - messagebox.showerror("Load failed", f"Unable to load the recording.\n{exc}", parent=window) - return - - self.recording_data = loaded_points - render_points(0 if self.recording_data else None) - helper_var.set(f"Loaded {len(self.recording_data)} point(s) from {os.path.basename(file_path)}.") - self._append_activity(f"Loaded recording from {os.path.basename(file_path)}.") - self._schedule_workspace_save() - - ttk.Button(body, text="Toggle Record", style="Secondary.TButton", command=self._toggle_recording).grid(row=4, column=0, sticky="ew", pady=(0, 8)) - ttk.Button(body, text="Capture Cursor", style="Secondary.TButton", command=capture_cursor_point).grid(row=4, column=1, sticky="ew", padx=(8, 0), pady=(0, 8)) - ttk.Button(body, text="Use Selected In Planner", style="Secondary.TButton", command=use_selected_for_target).grid(row=5, column=0, sticky="ew", pady=(0, 8)) - ttk.Button(body, text="Playback Recording", style="Secondary.TButton", command=self._play_recording).grid(row=5, column=1, sticky="ew", padx=(8, 0), pady=(0, 8)) - ttk.Button(body, text="Undo Last", style="Secondary.TButton", command=undo_last_point).grid(row=6, column=0, sticky="ew", pady=(0, 8)) - ttk.Button(body, text="Delete Selected", style="Secondary.TButton", command=delete_selected_point).grid(row=6, column=1, sticky="ew", padx=(8, 0), pady=(0, 8)) - ttk.Button(body, text="Save Recording", style="Secondary.TButton", command=save_recording).grid(row=7, column=0, sticky="ew", pady=(0, 8)) - ttk.Button(body, text="Load Recording", style="Secondary.TButton", command=load_recording).grid(row=7, column=1, sticky="ew", padx=(8, 0), pady=(0, 8)) - ttk.Button(body, text="Clear All", style="Secondary.TButton", command=clear_points).grid(row=8, column=0, columnspan=2, sticky="ew", pady=(0, 8)) - - tk.Label(body, textvariable=helper_var, bg="#edf4ff", fg="#475569", font=("Segoe UI", 9), wraplength=580, justify=LEFT).grid(row=9, column=0, columnspan=2, sticky="w", pady=(8, 0)) - - render_points() - schedule_refresh() - def _capture_cursor_position(self): try: current_x, current_y = pyautogui.position() @@ -6056,7 +5629,21 @@ def _refresh_safety_status(self): cap = f"{cap_number} action cap" if cap_number > 0 else "action cap needs attention" except Exception: cap = "action cap needs attention" - self.safety_status_var.set(f"Safety: {mode} | {fail_safe} | {cap}") + + parts = [mode, fail_safe, cap, f"{self.pacing_mode_var.get()} pacing"] + action_name = self.click_mode_var.get() + if action_name != "Left Click": + parts.append(action_name) + if self.round_robin_var.get() and self.recording_data: + parts.append(f"round-robin over {len(self.recording_data)} point(s)") + schedule = _parse_scheduled_start(self.scheduled_start_var.get()) + if schedule.get("error"): + parts.append("scheduled start invalid") + elif schedule.get("scheduled"): + parts.append(f"starts {self.scheduled_start_var.get().strip()}") + if self.global_hotkeys_var.get(): + parts.append("global hotkeys on") + self.safety_status_var.set("Safety: " + " | ".join(parts)) def _format_run_intelligence(self, config): planned_actions = config["repeat_limit"] @@ -6066,9 +5653,15 @@ def _format_run_intelligence(self, config): else: planned_actions = min(planned_actions, config["max_actions"]) - estimated_duration = config["countdown"] + # Estimate against the real seconds-per-action, not the raw delay: Legacy + # pacing adds PyAutoGUI's own inter-call pause and used to double every estimate. + period = _effective_action_period( + config["delay"], config.get("pacing_mode", "Precise"), config.get("human_like") + ) + schedule = config.get("schedule") or {} + estimated_duration = config["countdown"] + (schedule.get("delay_seconds", 0.0) or 0.0) if planned_actions is not None: - estimated_duration += max(0, planned_actions - 1) * config["delay"] + estimated_duration += max(0, planned_actions - 1) * period if config["micro_pause_every"] > 0 and config["micro_pause_duration"] > 0: pause_count = planned_actions // config["micro_pause_every"] if planned_actions > 0 and planned_actions % config["micro_pause_every"] == 0: @@ -6080,10 +5673,11 @@ def _format_run_intelligence(self, config): else: run_shape = "continuous until stopped" - if config["delay"] == 0: + measured_rate = _delay_to_cps(period) + if measured_rate is None: pace = "maximum available pace" else: - pace = f"about {1 / config['delay']:.1f} action(s)/sec" + pace = f"about {measured_rate:.1f} action(s)/sec" warnings = [] if not (0 <= config["x"] < self.screen_width and 0 <= config["y"] < self.screen_height): @@ -6092,12 +5686,19 @@ def _format_run_intelligence(self, config): warnings.append(f"target clamps to {clamped_x}, {clamped_y}") if config["delay"] == 0: warnings.append("zero delay") + if config.get("pacing_mode") == "Legacy V10.1" and config["delay"] > 0: + warnings.append(f"legacy pacing: real period {period:.3f}s, not {config['delay']:.3f}s") if config["dry_run"]: warnings.append("dry run: no clicks will be sent") if config["repeat_limit"] is None and config["runtime_limit"] == 0 and config["max_actions"] == 0: warnings.append("no runtime/action cap") if not config["pyautogui_failsafe"]: warnings.append("corner fail-safe off") + hotkey_check = _validate_hotkey(config.get("stop_hotkey")) + if config.get("stop_hotkey") and not hotkey_check["valid"]: + warnings.append("stop hotkey cannot be monitored") + if schedule.get("scheduled"): + warnings.append(schedule.get("detail", "scheduled start armed")) warning_text = "; ".join(warnings) if warnings else "no immediate warnings" return f"{run_shape}; {pace}; {warning_text}." @@ -6120,8 +5721,9 @@ def _refresh_readiness_checklist(self): except Exception as exc: self.readiness_var.set(f"Readiness: fix configuration\n- Review Configuration: {exc}") return + config["targets"] = self._active_target_list(config) readiness = _build_readiness_checklist(config, (self.screen_width, self.screen_height)) - self.readiness_var.set(_format_readiness_text(readiness)) + self.readiness_var.set(_format_readiness_text(readiness, limit=10)) def _refresh_profile_state(self): if not hasattr(self, "profile_state_var"): @@ -6202,29 +5804,48 @@ def _refresh_live_cursor(self): def _pump_ui_queue(self): try: while True: - action, payload = self.ui_queue.get_nowait() - if action == "status": - self.status_var.set(payload) - elif action == "progress": - count, elapsed, last_point = payload - action_label = "simulated action(s)" if self.active_run_was_dry_run else "click action(s)" - self.session_var.set( - f"Active run: {count} {action_label} processed over {elapsed:.2f} second(s). " - f"Last point: {last_point[0]}, {last_point[1]}." - ) - elif action == "finished": - total_actions, elapsed, last_click_point = payload - self._finish_run(total_actions, elapsed, last_click_point) - except queue.Empty: + try: + action, payload = self.ui_queue.get_nowait() + except queue.Empty: + break + try: + if action == "status": + self.status_var.set(payload) + elif action == "progress": + count, elapsed, last_point, rates = payload + action_label = "simulated action(s)" if self.active_run_was_dry_run else "click action(s)" + self.session_var.set( + f"Active run: {count} {action_label} processed over {elapsed:.2f} second(s). " + f"Last point: {last_point[0]}, {last_point[1]}." + ) + self.live_rate_var.set( + f"Rate: {rates['instant_cps']:.2f} now | {rates['average_cps']:.2f} avg | " + f"{rates['peak_cps']:.2f} peak action(s)/sec" + ) + elif action == "finished": + self._finish_run(*payload) + elif action == "refresh_buttons": + self._refresh_run_buttons() + except Exception as exc: + # One malformed message must never kill the pump; without this the + # whole UI silently stops updating for the rest of the session. + try: + self.status_var.set(f"UI update issue: {exc}") + except Exception: + pass + except Exception: pass - if self.winfo_exists(): - self.after(100, self._pump_ui_queue) + try: + if self.winfo_exists(): + self.after(100, self._pump_ui_queue) + except Exception: + pass def _build_run_config(self): click_mode = self.click_mode_var.get() - if click_mode not in self.CLICK_TYPES: - raise ValueError("Choose a valid click type.") + if click_mode not in ACTION_REGISTRY: + raise ValueError("Choose a valid action type.") x_pos = int(self.target_x_var.get()) y_pos = int(self.target_y_var.get()) @@ -6255,6 +5876,10 @@ def _build_run_config(self): raise ValueError("Micro-pause duration must be zero or greater.") stop_hotkey = self.stop_hotkey_var.get().strip() + if stop_hotkey: + hotkey_check = _validate_hotkey(stop_hotkey) + if not hotkey_check["valid"]: + raise ValueError(hotkey_check["reason"]) if self.repeat_mode_var.get() == "Infinite": repeat_limit = None if not stop_hotkey: @@ -6264,7 +5889,12 @@ def _build_run_config(self): if repeat_limit < 1: raise ValueError("Burst count must be at least 1.") - return { + schedule = _parse_scheduled_start(self.scheduled_start_var.get()) + if schedule.get("error"): + raise ValueError(schedule["error"]) + + action_spec = ACTION_REGISTRY[click_mode] + config = { "x": x_pos, "y": y_pos, "delay": delay_seconds, @@ -6276,9 +5906,19 @@ def _build_run_config(self): "max_actions": max_actions, "stop_hotkey": stop_hotkey, "repeat_limit": repeat_limit, - "button": self.CLICK_TYPES[click_mode][0], - "clicks": self.CLICK_TYPES[click_mode][1], + "button": action_spec.get("button", "left"), + "clicks": action_spec.get("clicks", 1), "click_mode": click_mode, + "action_kind": action_spec["kind"], + "action_key": self.action_key_var.get().strip(), + "action_text": self.action_text_var.get(), + "scroll_amount": int(self.scroll_amount_var.get()), + "hold_duration": float(self.hold_duration_var.get()), + "drag_to_x": int(self.drag_to_x_var.get()), + "drag_to_y": int(self.drag_to_y_var.get()), + "pacing_mode": self.pacing_mode_var.get(), + "scheduled_start": self.scheduled_start_var.get().strip(), + "schedule": schedule, "behaviour_preset": self.behaviour_preset_var.get(), "micro_pause_every": micro_pause_every, "micro_pause_duration": micro_pause_duration, @@ -6286,171 +5926,283 @@ def _build_run_config(self): "dry_run": self.dry_run_var.get(), "pyautogui_failsafe": self.pyautogui_failsafe_var.get(), } + # Surfaces bad action parameters (empty key name, zero scroll) before the mouse moves. + _resolve_action(click_mode, config) + return config - def _stop_requested(self, stop_hotkey): - if self.stop_event.is_set(): + def _stop_requested(self, stop_hotkey, stop_event=None): + stop_event = stop_event if stop_event is not None else self.stop_event + if stop_event.is_set(): return True - if not stop_hotkey: + if not stop_hotkey or keyboard is None: return False try: - if keyboard.is_pressed(stop_hotkey): - self.stop_reason = "hotkey" - self.stop_event.set() - if not self.hotkey_notified: - self.hotkey_notified = True - self.ui_queue.put(("status", f"Stop hotkey '{stop_hotkey}' pressed. Finishing the current loop.")) - return True - except: - pass + pressed = keyboard.is_pressed(stop_hotkey) + except Exception as exc: + # A hotkey that cannot be polled is reported once instead of silently + # leaving the run with no working stop key. + if not self.hotkey_notified: + self.hotkey_notified = True + self.ui_queue.put(("status", f"Stop hotkey '{stop_hotkey}' cannot be monitored ({exc}). Use the Stop button.")) + return False + if pressed: + self.stop_reason = "hotkey" + stop_event.set() + if not self.hotkey_notified: + self.hotkey_notified = True + self.ui_queue.put(("status", f"Stop hotkey '{stop_hotkey}' pressed. Finishing the current loop.")) + return True return False + def _wait_while_paused(self, config, stop_event=None): + """Block until Resume or Stop. Returns True if the run should continue.""" + if not self.pause_event.is_set(): + return True + self.ui_queue.put(("status", "Paused. Press Resume to continue.")) + while self.pause_event.is_set(): + if self._stop_requested(config.get("stop_hotkey"), stop_event): + return False + time.sleep(0.05) + self.ui_queue.put(("status", "Resumed.")) + return True + + def _emit_action(self, config, point, stop_event=None): + """Perform one configured action at `point`. No-op when the run is a dry run. + + `stop_event` decides which subsystem's stop token a held key or button honours; + without it a hold inside a sequence would watch the click run's token instead. + """ + if config["dry_run"]: + return + descriptor = _resolve_action(config["click_mode"], dict(config, x=point[0], y=point[1])) + kind, kwargs = descriptor["kind"], descriptor["kwargs"] + + if kind == "click": + pyautogui.click( + x=kwargs["x"], y=kwargs["y"], button=kwargs["button"], clicks=kwargs["clicks"], + interval=random.uniform(0.01, 0.03) if kwargs["clicks"] > 1 else 0.0, + ) + elif kind == "move": + pyautogui.moveTo(kwargs["x"], kwargs["y"]) + elif kind == "key": + pyautogui.press(kwargs["keys"]) + elif kind == "key_hold": + pyautogui.keyDown(kwargs["key"]) + try: + _interruptible_sleep(kwargs["hold_duration"], + lambda: self._stop_requested(config.get("stop_hotkey"), stop_event)) + finally: + pyautogui.keyUp(kwargs["key"]) + elif kind == "text": + pyautogui.write(kwargs["message"]) + elif kind == "scroll": + pyautogui.scroll(kwargs["clicks"], x=kwargs["x"], y=kwargs["y"]) + elif kind == "hold": + pyautogui.mouseDown(x=kwargs["x"], y=kwargs["y"], button=kwargs["button"]) + try: + _interruptible_sleep(kwargs["hold_duration"], + lambda: self._stop_requested(config.get("stop_hotkey"), stop_event)) + finally: + pyautogui.mouseUp(button=kwargs["button"]) + elif kind == "drag": + pyautogui.mouseDown(x=kwargs["x"], y=kwargs["y"], button=kwargs["button"]) + try: + pyautogui.moveTo( + max(0, min(self.screen_width - 1, kwargs["to_x"])), + max(0, min(self.screen_height - 1, kwargs["to_y"])), + duration=max(0.05, kwargs["hold_duration"]), + ) + finally: + pyautogui.mouseUp(button=kwargs["button"]) + def _click_worker(self, config): previous_failsafe = getattr(pyautogui, "FAILSAFE", True) + previous_pause = getattr(pyautogui, "PAUSE", 0.1) + generation = config.get("generation", 0) total_actions = 0 - started_at = time.perf_counter() - last_progress_update = started_at last_click_point = (config["x"], config["y"]) + targets = config.get("targets") or [(config["x"], config["y"])] + stop_event = self.stop_event + started_at = time.perf_counter() - if config["countdown"] > 0: - remaining = config["countdown"] - last_announced = None - while remaining > 0: - if self._stop_requested(config["stop_hotkey"]): - elapsed = time.perf_counter() - started_at - self.ui_queue.put(("finished", (total_actions, elapsed, last_click_point))) - return - announce_value = int(remaining) - if remaining > announce_value: - announce_value += 1 - if announce_value != last_announced: - self.ui_queue.put(("status", f"Starting in {announce_value}s. Use Stop or {config['stop_hotkey'] or 'manual stop'} to cancel.")) - last_announced = announce_value - sleep_slice = min(0.1, remaining) - time.sleep(sleep_slice) - remaining -= sleep_slice + def should_stop(): + return self._stop_requested(config["stop_hotkey"], stop_event) - try: - pyautogui.FAILSAFE = bool(config["pyautogui_failsafe"]) - except Exception: - pass + def finish(elapsed): + self.ui_queue.put(("finished", (total_actions, elapsed, last_click_point, generation))) - while not self._stop_requested(config["stop_hotkey"]): - elapsed_before_click = time.perf_counter() - started_at - if config["runtime_limit"] > 0 and elapsed_before_click >= config["runtime_limit"]: - self.stop_reason = "runtime_limit" - break + try: + schedule = config.get("schedule") or {} + if schedule.get("scheduled") and schedule.get("delay_seconds", 0) > 0: + self.ui_queue.put(("status", f"Scheduled start armed. {schedule.get('detail', '')}")) + if not _interruptible_sleep(schedule["delay_seconds"], should_stop, slice_seconds=0.2): + finish(time.perf_counter() - started_at) + return - click_x = config["x"] - click_y = config["y"] + if config["countdown"] > 0: + countdown_deadline = time.perf_counter() + config["countdown"] + last_announced = None + while True: + remaining = countdown_deadline - time.perf_counter() + if remaining <= 0: + break + if should_stop(): + finish(time.perf_counter() - started_at) + return + announce_value = int(remaining) + (1 if remaining > int(remaining) else 0) + if announce_value != last_announced: + self.ui_queue.put(("status", f"Starting in {announce_value}s. Use Stop or {config['stop_hotkey'] or 'manual stop'} to cancel.")) + last_announced = announce_value + _interruptible_sleep(min(0.1, remaining), should_stop, slice_seconds=0.05) + + # The countdown is setup time, not run time: charging it against the runtime + # cap used to produce silent zero-action runs. + started_at = time.perf_counter() + last_progress_update = started_at + self.rate_samples = [(0.0, 0)] try: - if config["human_like"]: - if config["jitter_x"] > 0: - click_x += int(random.gauss(0, config["jitter_x"] / 2)) - if config["jitter_y"] > 0: - click_y += int(random.gauss(0, config["jitter_y"] / 2)) - if not config["dry_run"]: - pyautogui.moveTo( - click_x + random.randint(-2, 2), - click_y + random.randint(-2, 2), - duration=random.uniform(0.01, 0.05), - ) - else: - if config["jitter_x"] > 0: - click_x += random.randint(-config["jitter_x"], config["jitter_x"]) - if config["jitter_y"] > 0: - click_y += random.randint(-config["jitter_y"], config["jitter_y"]) + pyautogui.FAILSAFE = bool(config["pyautogui_failsafe"]) + if config.get("pacing_mode", "Precise") == "Precise": + # PyAutoGUI's default 0.1s inter-call pause otherwise makes the + # configured delay a lie by a factor of 2-3. + pyautogui.PAUSE = 0 + except Exception: + pass + + target_index = 0 + while not should_stop(): + if not self._wait_while_paused(config, stop_event): + break - click_x = max(0, min(self.screen_width - 1, click_x)) - click_y = max(0, min(self.screen_height - 1, click_y)) - last_click_point = (click_x, click_y) + elapsed_before_action = time.perf_counter() - started_at + if config["runtime_limit"] > 0 and elapsed_before_action >= config["runtime_limit"]: + self.stop_reason = "runtime_limit" + break - if not config["dry_run"]: - pyautogui.click( - x=click_x, - y=click_y, - button=config["button"], - clicks=config["clicks"], - interval=random.uniform(0.01, 0.03) if config["clicks"] > 1 else 0.0, - ) - except Exception as exc: - if exc.__class__.__name__ == "FailSafeException": - self.stop_reason = "failsafe" - self.ui_queue.put(("status", "PyAutoGUI corner fail-safe triggered. Run stopped.")) - else: - self.stop_reason = "error" - self.ui_queue.put(("status", f"Click error: {exc}")) - break - total_actions += 1 - if not config["dry_run"]: - self.session_clicks += 1 - - now = time.perf_counter() - if total_actions == 1 or now - last_progress_update >= 0.25: - self.ui_queue.put(("progress", (total_actions, now - started_at, last_click_point))) - last_progress_update = now - - if ( - config["max_actions"] > 0 - and total_actions >= config["max_actions"] - and (config["repeat_limit"] is None or config["max_actions"] < config["repeat_limit"]) - ): - self.stop_reason = "max_actions" - break + base_x, base_y = targets[target_index % len(targets)] + target_index += 1 + click_x, click_y = base_x, base_y - if config["repeat_limit"] is not None and total_actions >= config["repeat_limit"]: - self.stop_reason = "completed" - break + try: + if config["human_like"]: + if config["jitter_x"] > 0: + click_x += int(random.gauss(0, config["jitter_x"] / 2)) + if config["jitter_y"] > 0: + click_y += int(random.gauss(0, config["jitter_y"] / 2)) + if not config["dry_run"] and _action_moves_pointer(config["click_mode"]): + pyautogui.moveTo( + max(0, min(self.screen_width - 1, click_x + random.randint(-2, 2))), + max(0, min(self.screen_height - 1, click_y + random.randint(-2, 2))), + duration=random.uniform(0.01, 0.05), + ) + else: + if config["jitter_x"] > 0: + click_x += random.randint(-config["jitter_x"], config["jitter_x"]) + if config["jitter_y"] > 0: + click_y += random.randint(-config["jitter_y"], config["jitter_y"]) + + click_x = max(0, min(self.screen_width - 1, click_x)) + click_y = max(0, min(self.screen_height - 1, click_y)) + last_click_point = (click_x, click_y) + self._emit_action(config, (click_x, click_y), stop_event) + except Exception as exc: + if exc.__class__.__name__ == "FailSafeException": + self.stop_reason = "failsafe" + self.ui_queue.put(("status", "PyAutoGUI corner fail-safe triggered. Run stopped.")) + else: + self.stop_reason = "error" + self.ui_queue.put(("status", f"Action error: {exc}")) + break + + total_actions += 1 + if not config["dry_run"]: + self.session_clicks += 1 + + now = time.perf_counter() + self.rate_samples.append((now - started_at, total_actions)) + if len(self.rate_samples) > 400: + del self.rate_samples[:-200] + if total_actions == 1 or now - last_progress_update >= 0.25: + self.ui_queue.put(( + "progress", + (total_actions, now - started_at, last_click_point, _rate_stats(self.rate_samples)), + )) + last_progress_update = now + + if ( + config["max_actions"] > 0 + and total_actions >= config["max_actions"] + and (config["repeat_limit"] is None or config["max_actions"] < config["repeat_limit"]) + ): + self.stop_reason = "max_actions" + break - if ( - config["micro_pause_every"] > 0 - and config["micro_pause_duration"] > 0 - and total_actions % config["micro_pause_every"] == 0 - ): - self.ui_queue.put( - ("status", f"Micro-pause: resting for {config['micro_pause_duration']:.2f}s after {total_actions} action(s).") - ) - wake_at = time.perf_counter() + config["micro_pause_duration"] - while time.perf_counter() < wake_at: - if config["runtime_limit"] > 0 and time.perf_counter() - started_at >= config["runtime_limit"]: - self.stop_reason = "runtime_limit" - break - if self._stop_requested(config["stop_hotkey"]): - break - time.sleep(min(0.02, wake_at - time.perf_counter())) - if self.stop_reason == "runtime_limit": + if config["repeat_limit"] is not None and total_actions >= config["repeat_limit"]: + self.stop_reason = "completed" break - actual_delay = config["delay"] - if config["delay_variance"] > 0: - actual_delay = max(0.0, config["delay"] + random.uniform(-config["delay_variance"], config["delay_variance"])) + def runtime_exhausted(): + return ( + config["runtime_limit"] > 0 + and time.perf_counter() - started_at >= config["runtime_limit"] + ) - if actual_delay > 0: - wake_at = time.perf_counter() + actual_delay - while time.perf_counter() < wake_at: - if config["runtime_limit"] > 0 and time.perf_counter() - started_at >= config["runtime_limit"]: + def wait_gate(): + if runtime_exhausted(): self.stop_reason = "runtime_limit" + return True + return should_stop() + + if ( + config["micro_pause_every"] > 0 + and config["micro_pause_duration"] > 0 + and total_actions % config["micro_pause_every"] == 0 + ): + self.ui_queue.put( + ("status", f"Micro-pause: resting for {config['micro_pause_duration']:.2f}s after {total_actions} action(s).") + ) + _interruptible_sleep(config["micro_pause_duration"], wait_gate) + if self.stop_reason == "runtime_limit": break - if self._stop_requested(config["stop_hotkey"]): - break - time.sleep(min(0.02, wake_at - time.perf_counter())) - if self.stop_reason == "runtime_limit": - break - try: - pyautogui.FAILSAFE = previous_failsafe - except Exception: - pass - elapsed = time.perf_counter() - started_at - self.ui_queue.put(("finished", (total_actions, elapsed, last_click_point))) + actual_delay = config["delay"] + if config["delay_variance"] > 0: + actual_delay = max(0.0, config["delay"] + random.uniform(-config["delay_variance"], config["delay_variance"])) + + if actual_delay > 0: + _interruptible_sleep(actual_delay, wait_gate) + if self.stop_reason == "runtime_limit": + break + else: + # Zero delay in dry-run emits no throttling call at all; without this + # yield the loop pegs a core and starves the stop-hotkey poll. + time.sleep(0) + if config["dry_run"] and total_actions % 256 == 0: + time.sleep(0.001) + except Exception as exc: + self.stop_reason = "error" + self.ui_queue.put(("status", f"Run stopped by an internal error: {exc}")) + finally: + # Always restore the globals; leaking FAILSAFE=False used to disarm the + # corner escape for every later tool window in the process. + try: + pyautogui.FAILSAFE = previous_failsafe + pyautogui.PAUSE = previous_pause + except Exception: + pass + finish(time.perf_counter() - started_at) - def _finish_run(self, total_actions, elapsed, last_click_point): + def _finish_run(self, total_actions, elapsed, last_click_point, generation=None): + # A "finished" message from an earlier run must never tear down a newer one: + # the ~100 ms pump gap used to leave a live run with no handle and a dead Stop button. + if generation is not None and generation != self.active_run_generation: + return self.worker_thread = None - if hasattr(self, "start_button"): - self.start_button.configure(state=NORMAL) - self.stop_button.configure(state=DISABLED) + self.pause_event.clear() + self._refresh_run_buttons() final_stop_reason = self.stop_reason action_label = "simulated action(s)" if self.active_run_was_dry_run else "click action(s)" run_prefix = "Dry run" if self.active_run_was_dry_run else "Run" @@ -6514,16 +6266,39 @@ def _finish_run(self, total_actions, elapsed, last_click_point): } ) self.run_reports = self.run_reports[-40:] + self.lifetime_stats["runs"] = self.lifetime_stats.get("runs", 0) + 1 + self.lifetime_stats["actions"] = self.lifetime_stats.get("actions", 0) + int(total_actions) + self.lifetime_stats["seconds"] = round(self.lifetime_stats.get("seconds", 0.0) + float(elapsed), 3) + self._write_run_log(self.run_reports[-1]) self.stop_reason = "idle" self.active_run_was_dry_run = False self._schedule_workspace_save() - def startclick(self): + def _busy_subsystem(self): + """Name whichever input-injecting subsystem is live, else None.""" if self.worker_thread and self.worker_thread.is_alive(): - self.status_var.set("A click run is already active.") - return + return "click run" + if self.sequence_thread and self.sequence_thread.is_alive(): + return "coordinate sequence" if self.is_playing: - self.status_var.set("Playback is active. Stop playback before starting a click run.") + return "recording playback" + return None + + def _refresh_run_buttons(self): + """Keep Start/Stop/Pause in step with whatever is actually running.""" + busy = self._busy_subsystem() + if hasattr(self, "start_button"): + self.start_button.configure(state=DISABLED if busy else NORMAL) + if hasattr(self, "stop_button"): + self.stop_button.configure(state=NORMAL if busy else DISABLED) + if hasattr(self, "pause_button"): + self.pause_button.configure(state=NORMAL if busy else DISABLED) + self.pause_button.configure(text="Resume" if self.pause_event.is_set() else "Pause") + + def startclick(self): + busy = self._busy_subsystem() + if busy: + self.status_var.set(f"A {busy} is already active. Stop it before starting a click run.") return try: @@ -6533,23 +6308,31 @@ def startclick(self): return self.stop_event.clear() + self.pause_event.clear() self.stop_reason = "running" self.hotkey_notified = False + self.rate_samples = [] self.active_run_was_dry_run = bool(config["dry_run"]) - if hasattr(self, "start_button"): - self.start_button.configure(state=DISABLED) - self.stop_button.configure(state=NORMAL) + self.run_generation += 1 + self.active_run_generation = self.run_generation + config["generation"] = self.run_generation + config["targets"] = self._active_target_list(config) self.session_var.set("Starting dry run..." if config["dry_run"] else "Starting click run...") - if config["countdown"] > 0: + schedule = config.get("schedule") or {} + if schedule.get("scheduled"): + self.status_var.set(f"Scheduled start armed. {schedule.get('detail', '')}") + elif config["countdown"] > 0: self.status_var.set(f"Countdown armed for {config['countdown']:.2f}s. Use Stop or the configured hotkey to cancel.") else: self.status_var.set("Dry run started. No clicks will be sent." if config["dry_run"] else "Run started. Use Stop or the configured hotkey to end it.") + target_note = f"{len(config['targets'])} target(s)" if len(config["targets"]) > 1 else f"{config['x']}, {config['y']}" self._append_activity( - f"{'Dry run' if config['dry_run'] else 'Run'} started at {config['x']}, {config['y']} using {config['click_mode']}." + f"{'Dry run' if config['dry_run'] else 'Run'} started at {target_note} using {config['click_mode']}." ) self.worker_thread = threading.Thread(target=self._click_worker, args=(config,), daemon=True) self.worker_thread.start() + self._refresh_run_buttons() if self.minimize_on_start_var.get(): self.was_minimized_for_run = True @@ -6557,56 +6340,114 @@ def startclick(self): self._schedule_workspace_save() + def _active_target_list(self, config): + """Screen points this run cycles through: the recording when round-robin is on.""" + if self.round_robin_var.get() and self.recording_data: + points = [] + for point in self.recording_data: + try: + points.append((int(point[0]), int(point[1]))) + except Exception: + continue + if points: + return points + return [(config["x"], config["y"])] + + def toggle_pause(self): + if not self._busy_subsystem(): + self.status_var.set("Nothing is running to pause.") + return + if self.pause_event.is_set(): + self.pause_event.clear() + self.status_var.set("Resuming.") + self._append_activity("Run resumed.") + else: + self.pause_event.set() + self.status_var.set("Pause requested. The run holds after the current action.") + self._append_activity("Run paused.") + self._refresh_run_buttons() + def stopclick(self): + stopped_any = False + self.pause_event.clear() if self.worker_thread and self.worker_thread.is_alive(): self.stop_reason = "manual" self.stop_event.set() self.status_var.set("Stop requested. Waiting for the current loop to finish.") self._append_activity("Stop requested for the active run.") - elif self.sequence_thread and self.sequence_thread.is_alive(): - self.stop_event.set() + stopped_any = True + if self.sequence_thread and self.sequence_thread.is_alive(): + self.sequence_stop_event.set() self.status_var.set("Stopping coordinate sequence after the current step.") self._append_activity("Stop requested for coordinate sequence.") - elif self.is_playing: - self.stop_event.set() + stopped_any = True + if self.is_playing: + self.playback_stop_event.set() self.status_var.set("Stopping playback after the current point.") self._append_activity("Stop requested for playback.") + stopped_any = True + if not stopped_any: + self.status_var.set("Nothing is running.") + self._refresh_run_buttons() + + def _write_run_log(self, report): + """Append one JSON line per finished run to an always-on, size-capped log.""" + try: + log_path = _state_file_location(RUN_LOG_FILE_NAME) + os.makedirs(os.path.dirname(log_path), exist_ok=True) + if os.path.exists(log_path) and os.path.getsize(log_path) > 1_000_000: + _rotate_run_log(log_path) + with open(log_path, "a", encoding="utf-8") as handle: + handle.write(json.dumps(report, sort_keys=True, default=str) + "\n") + except Exception: + pass + def _capture_theme_roles(self, widget=None): + """Record the palette role of every widget from its authored colour.""" + widget = self if widget is None else widget + for option, sentinels in (("bg", self.BG_ROLE_SENTINELS), ("fg", self.FG_ROLE_SENTINELS)): + key = (str(widget), option) + if key in self._theme_roles: + continue + try: + self._theme_roles[key] = sentinels.get(widget.cget(option)) + except Exception: + self._theme_roles[key] = None + for child in widget.winfo_children(): + self._capture_theme_roles(child) def _apply_theme(self): palette = self._theme_palette() self._refresh_ttk_styles(palette) + # Capture first: widgets built since the last pass are still in their authored + # colours, and reading them after configure() would record the wrong role. + self._capture_theme_roles() self.configure(bg=palette["main_bg"]) + # Roles are resolved once, from the widget's build-time colour, and cached by + # widget path. Re-deriving the role from the *current* colour on every switch + # was self-corrupting: Light hero_bg and Dark main_bg are both #0f172a, so + # Dark -> Light left page frames dark and eventually put white text on a light page. + roles = self._theme_roles + + def role_for(widget, option, sentinel_map): + key = (str(widget), option) + if key not in roles: + try: + roles[key] = sentinel_map.get(widget.cget(option)) + except Exception: + roles[key] = None + return roles[key] + def walk(widget): try: - current_bg = widget.cget("bg") - if current_bg == "#dbe7f2": - widget.configure(bg=palette["main_bg"]) - elif current_bg == "#f8fafc": - widget.configure(bg=palette["card_bg"]) - elif current_bg == "#edf4ff": - widget.configure(bg=palette["alt_bg"]) - elif current_bg == "#0f172a": - widget.configure(bg=palette["hero_bg"]) - elif current_bg == "#1e293b": - widget.configure(bg=palette["hero_chip_bg"]) - elif current_bg == "white": - widget.configure(bg=palette["list_bg"]) - - current_fg = widget.cget("fg") - if current_fg == "#0f172a": - widget.configure(fg=palette["text"]) - elif current_fg == "#475569": - widget.configure(fg=palette["sub"]) - elif current_fg in ("white", "#f8fafc"): - widget.configure(fg=palette["hero_fg"]) - elif current_fg == "#cbd5e1": - widget.configure(fg=palette["hero_sub"]) - elif current_fg == "#334155": - widget.configure(fg=palette["sub"]) - elif current_fg == "#0f766e": - widget.configure(fg=palette["accent"]) + bg_role = role_for(widget, "bg", self.BG_ROLE_SENTINELS) + if bg_role: + widget.configure(bg=palette[bg_role]) + + fg_role = role_for(widget, "fg", self.FG_ROLE_SENTINELS) + if fg_role: + widget.configure(fg=palette[fg_role]) if isinstance(widget, (tk.Frame, tk.Label, tk.Canvas, tk.Text, tk.Listbox)): try: @@ -6668,324 +6509,66 @@ def make_section(row, title): frame.grid(row=row, column=0, sticky="ew", pady=(0, 12)) frame.columnconfigure(0, weight=1) tk.Label(frame, text=title, bg=palette["card_bg"], fg=palette["text"], font=("Segoe UI", 11, "bold")).grid(row=0, column=0, sticky="w", pady=(0, 10)) - return frame - - tk.Label(body, text="Window Settings", bg=palette["card_bg"], fg=palette["text"], font=("Segoe UI", 13, "bold")).grid(row=0, column=0, sticky="w") - tk.Label(body, textvariable=self.window_summary_var, bg=palette["card_bg"], fg=palette["accent"], font=("Segoe UI", 9, "bold"), wraplength=520, justify=LEFT).grid(row=1, column=0, sticky="w", pady=(4, 14)) - - appearance = make_section(2, "Appearance") - appearance.columnconfigure(1, weight=1) - tk.Label(appearance, text="Theme", bg=palette["card_bg"], fg=palette["text"], font=("Segoe UI", 10, "bold")).grid(row=1, column=0, sticky="w", pady=(0, 8)) - theme_combo = ttk.Combobox(appearance, textvariable=self.theme_var, values=("Light", "Dark", "Ocean"), state="readonly") - theme_combo.grid(row=1, column=1, sticky="ew", pady=(0, 8)) - theme_combo.bind("<>", lambda _event: self._apply_theme()) - - tk.Label(appearance, text="Window opacity", bg=palette["card_bg"], fg=palette["text"], font=("Segoe UI", 10, "bold")).grid(row=2, column=0, sticky="w", pady=(0, 8)) - ttk.Scale(appearance, variable=self.window_opacity_var, from_=0.70, to=1.00, style="App.Horizontal.TScale", command=lambda _value: self._apply_window_preferences()).grid(row=2, column=1, sticky="ew", pady=(0, 8)) - opacity_row = tk.Frame(appearance, bg=palette["card_bg"]) - opacity_row.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(0, 10)) - for index, (label, value) in enumerate((("70%", 0.70), ("85%", 0.85), ("100%", 1.0))): - opacity_row.columnconfigure(index, weight=1) - ttk.Button(opacity_row, text=label, style="Chip.TButton", command=lambda preset=value: self._set_window_opacity_preset(preset)).grid(row=0, column=index, sticky="ew", padx=(0 if index == 0 else 6, 0)) - - tk.Label(appearance, text="UI scale", bg=palette["card_bg"], fg=palette["text"], font=("Segoe UI", 10, "bold")).grid(row=4, column=0, sticky="w", pady=(0, 8)) - ttk.Scale(appearance, variable=self.ui_scale_var, from_=0.90, to=1.35, style="App.Horizontal.TScale", command=lambda _value: self._apply_window_preferences()).grid(row=4, column=1, sticky="ew", pady=(0, 8)) - scale_row = tk.Frame(appearance, bg=palette["card_bg"]) - scale_row.grid(row=5, column=0, columnspan=2, sticky="ew") - for index, (label, value) in enumerate((("90%", 0.90), ("100%", 1.0), ("115%", 1.15), ("130%", 1.30))): - scale_row.columnconfigure(index, weight=1) - ttk.Button(scale_row, text=label, style="Chip.TButton", command=lambda preset=value: self._set_ui_scale_preset(preset)).grid(row=0, column=index, sticky="ew", padx=(0 if index == 0 else 6, 0)) - - behavior = make_section(3, "Desktop Behavior") - ttk.Checkbutton(behavior, text="Keep window on top", variable=self.topmost_var, style="App.TCheckbutton").grid(row=1, column=0, sticky="w", pady=(0, 6)) - ttk.Checkbutton(behavior, text="Minimize while clicking", variable=self.minimize_on_start_var, style="App.TCheckbutton").grid(row=2, column=0, sticky="w", pady=(0, 6)) - ttk.Checkbutton(behavior, text="Restore after run", variable=self.restore_after_run_var, style="App.TCheckbutton").grid(row=3, column=0, sticky="w", pady=(0, 6)) - ttk.Checkbutton(behavior, text="Close button sends app to tray", variable=self.close_to_tray_var, style="App.TCheckbutton").grid(row=4, column=0, sticky="w", pady=(0, 6)) - ttk.Checkbutton(behavior, text="Remember size and position", variable=self.remember_window_geometry_var, style="App.TCheckbutton").grid(row=5, column=0, sticky="w", pady=(0, 6)) - ttk.Checkbutton(behavior, text="Fullscreen mode", variable=self.fullscreen_var, style="App.TCheckbutton").grid(row=6, column=0, sticky="w") - - layout = make_section(4, "Layout Presets") - for index in range(3): - layout.columnconfigure(index, weight=1) - ttk.Button(layout, text="Compact", style="Secondary.TButton", command=lambda: self._set_window_size_preset(920, 640)).grid(row=1, column=0, sticky="ew") - ttk.Button(layout, text="Wide", style="Secondary.TButton", command=lambda: self._set_window_size_preset(1180, 760)).grid(row=1, column=1, sticky="ew", padx=8) - ttk.Button(layout, text="Studio", style="Secondary.TButton", command=lambda: self._set_window_size_preset(1360, 860)).grid(row=1, column=2, sticky="ew") - ttk.Button(layout, text="Fit to Screen", style="Secondary.TButton", command=self._fit_window_to_screen).grid(row=2, column=0, sticky="ew", pady=(8, 0)) - ttk.Button(layout, text="Center", style="Secondary.TButton", command=self._center_window).grid(row=2, column=1, sticky="ew", padx=8, pady=(8, 0)) - ttk.Button(layout, text="Save Current", style="Secondary.TButton", command=self._remember_current_window_layout).grid(row=2, column=2, sticky="ew", pady=(8, 0)) - - support = make_section(5, "Maintenance") - support.columnconfigure(0, weight=1) - support.columnconfigure(1, weight=1) - ttk.Button(support, text="Reset Visuals", style="Secondary.TButton", command=self._reset_visual_preferences).grid(row=1, column=0, sticky="ew", pady=(0, 8)) - ttk.Button(support, text="Reset Layout", style="Secondary.TButton", command=self._reset_window_layout).grid(row=1, column=1, sticky="ew", padx=(8, 0), pady=(0, 8)) - ttk.Button(support, text="Health Check", style="Secondary.TButton", command=self._open_health_dashboard).grid(row=2, column=0, sticky="ew") - ttk.Button(support, text="Open State Folder", style="Secondary.TButton", command=self._open_state_folder).grid(row=2, column=1, sticky="ew", padx=(8, 0)) - - footer_row = tk.Frame(body, bg=palette["card_bg"]) - footer_row.grid(row=6, column=0, sticky="ew") - footer_row.columnconfigure(0, weight=1) - footer_row.columnconfigure(1, weight=1) - ttk.Button(footer_row, text="Restart Program", style="Secondary.TButton", command=self._restart_program).grid(row=0, column=0, sticky="ew", padx=(0, 8)) - ttk.Button(footer_row, text="Close", style="Secondary.TButton", command=window.destroy).grid(row=0, column=1, sticky="ew") - - def _open_sequence_builder(self): - if not _ensure_dependencies("Coordinate Sequence", ["pyautogui"], parent=self): - return - - window = tk.Toplevel(self) - window.title("Coordinate Sequence") - window.geometry("660x540+300+180") - window.resizable(False, False) - window.attributes("-topmost", True) - window.configure(bg="#edf4ff") - try: - window.iconbitmap(_resource_path("favicon.ico")) - except: - pass - - steps = [] - x_var = tk.StringVar(value=self.target_x_var.get()) - y_var = tk.StringVar(value=self.target_y_var.get()) - action_var = tk.StringVar(value=self.click_mode_var.get()) - delay_var = tk.StringVar(value=self.delay_var.get()) - summary_var = tk.StringVar(value="0 step(s) | 0.00s total wait") - helper_var = tk.StringVar(value="Build, reorder, or import a click path before running it.") - - body = tk.Frame(window, bg="#edf4ff", padx=16, pady=16) - body.pack(fill="both", expand=True) - body.columnconfigure(0, weight=1) - body.columnconfigure(1, weight=1) - - tk.Label(body, text="Coordinate Sequence Builder", bg="#edf4ff", fg="#0f172a", font=("Segoe UI", 12, "bold")).grid(row=0, column=0, columnspan=2, sticky="w") - tk.Label(body, text="Build a short step-by-step click path, validate it, and run it once.", bg="#edf4ff", fg="#475569", font=("Segoe UI", 9)).grid(row=1, column=0, columnspan=2, sticky="w", pady=(2, 12)) - - list_box = Listbox(body, width=64, height=14, font=("Segoe UI", 10), bg="white", fg="#0f172a", selectbackground="#1d4ed8", activestyle="none") - list_box.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(0, 8)) - - tk.Label(body, textvariable=summary_var, bg="#edf4ff", fg="#0f766e", font=("Segoe UI", 9, "bold")).grid(row=3, column=0, columnspan=2, sticky="w", pady=(0, 10)) - - def normalize_steps(raw_steps): - return _normalize_sequence_steps(raw_steps, self.CLICK_TYPES) - - def selected_index(): - selection = list_box.curselection() - return selection[0] if selection else None - - def update_summary(): - total_wait = sum(step[3] for step in steps) - summary_var.set(f"{len(steps)} step(s) | {total_wait:.2f}s total wait") - - def render_steps(selected_step=None): - list_box.delete(0, END) - for index, (x_pos, y_pos, action_name, delay_seconds) in enumerate(steps, start=1): - list_box.insert(END, f"{index}. {action_name} at ({x_pos}, {y_pos}) then wait {delay_seconds:.2f}s") - update_summary() - if selected_step is not None and 0 <= selected_step < len(steps): - list_box.selection_clear(0, END) - list_box.selection_set(selected_step) - list_box.activate(selected_step) - - def add_step(): - try: - x_pos = int(x_var.get()) - y_pos = int(y_var.get()) - delay_seconds = float(delay_var.get()) - if delay_seconds < 0: - raise ValueError - except: - messagebox.showerror("Invalid step", "Enter valid X, Y, and delay values.", parent=window) - return - - steps.append((x_pos, y_pos, action_var.get(), delay_seconds)) - render_steps(len(steps) - 1) - helper_var.set("Step added to the sequence.") - - def add_cursor_step(): - try: - current_x, current_y = pyautogui.position() - x_var.set(str(current_x)) - y_var.set(str(current_y)) - add_step() - except Exception as exc: - messagebox.showerror("Capture failed", f"Unable to read the cursor position.\n{exc}", parent=window) - - def import_recording(): - if not self.recording_data: - messagebox.showinfo("Sequence builder", "There is no active recording to import.", parent=window) - return - - try: - delay_seconds = float(delay_var.get()) - if delay_seconds < 0: - raise ValueError - except: - messagebox.showerror("Invalid delay", "Enter a valid delay before importing recorded points.", parent=window) - return - - for x_pos, y_pos in self.recording_data: - steps.append((int(x_pos), int(y_pos), action_var.get(), delay_seconds)) - render_steps(len(steps) - 1) - helper_var.set(f"Imported {len(self.recording_data)} recorded point(s).") - - def duplicate_step(): - current_step = selected_index() - if current_step is None: - return - steps.insert(current_step + 1, steps[current_step]) - render_steps(current_step + 1) - helper_var.set("Selected step duplicated.") - - def move_step(offset): - current_step = selected_index() - if current_step is None: - return - - new_index = current_step + offset - if new_index < 0 or new_index >= len(steps): - return - - steps[current_step], steps[new_index] = steps[new_index], steps[current_step] - render_steps(new_index) - helper_var.set("Selected step moved.") - - def delete_step(): - current_step = selected_index() - if current_step is None: - return - del steps[current_step] - render_steps(min(current_step, len(steps) - 1) if steps else None) - helper_var.set("Selected step removed.") - - def clear_steps(): - if not steps: - return - if not messagebox.askyesno("Clear sequence", "Remove every step from the current sequence?", parent=window): - return - steps.clear() - render_steps() - helper_var.set("Sequence cleared.") - - def run_sequence_worker(sequence_copy): - completed_steps = 0 - try: - pyautogui.FAILSAFE = False - for x_pos, y_pos, action_name, delay_seconds in sequence_copy: - if self.stop_event.is_set(): - self.ui_queue.put(("status", f"Coordinate sequence stopped after {completed_steps} step(s).")) - self._append_activity(f"Sequence stopped after {completed_steps} step(s).") - return - - button_name, click_count = self.CLICK_TYPES[action_name] - pyautogui.click(x=x_pos, y=y_pos, button=button_name, clicks=click_count, interval=0.02 if click_count > 1 else 0.0) - completed_steps += 1 - - if delay_seconds > 0: - wake_at = time.perf_counter() + delay_seconds - while time.perf_counter() < wake_at: - if self.stop_event.is_set(): - self.ui_queue.put(("status", f"Coordinate sequence stopped after {completed_steps} step(s).")) - self._append_activity(f"Sequence stopped after {completed_steps} step(s).") - return - time.sleep(min(0.02, wake_at - time.perf_counter())) - - self.ui_queue.put(("status", f"Coordinate sequence complete. Ran {completed_steps} step(s).")) - self._append_activity(f"Sequence complete. Ran {completed_steps} step(s).") - except Exception as exc: - self.ui_queue.put(("status", f"Sequence error: {exc}")) - self._append_activity(f"Sequence error: {exc}") - finally: - self.sequence_thread = None - self.stop_event.clear() - - def run_sequence(): - if not steps: - messagebox.showinfo("Sequence builder", "Add at least one step before running the sequence.", parent=window) - return - if self.worker_thread and self.worker_thread.is_alive(): - messagebox.showinfo("Sequence builder", "Stop the active click run before starting a sequence.", parent=window) - return - if self.sequence_thread and self.sequence_thread.is_alive(): - messagebox.showinfo("Sequence builder", "A sequence is already running.", parent=window) - return - if self.is_playing: - messagebox.showinfo("Sequence builder", "Stop playback before starting a sequence.", parent=window) - return - - sequence_copy = list(steps) - self.stop_event.clear() - self.status_var.set(f"Running coordinate sequence with {len(sequence_copy)} step(s).") - self._append_activity(f"Sequence started with {len(sequence_copy)} step(s).") - self.sequence_thread = threading.Thread(target=run_sequence_worker, args=(sequence_copy,), daemon=True) - self.sequence_thread.start() - - def save_sequence(): - if not steps: - messagebox.showinfo("Sequence builder", "Add at least one step before saving.", parent=window) - return + return frame - file_path = filedialog.asksaveasfilename(defaultextension=".json", filetypes=[("JSON files", "*.json")], parent=window) - if not file_path: - return + tk.Label(body, text="Window Settings", bg=palette["card_bg"], fg=palette["text"], font=("Segoe UI", 13, "bold")).grid(row=0, column=0, sticky="w") + tk.Label(body, textvariable=self.window_summary_var, bg=palette["card_bg"], fg=palette["accent"], font=("Segoe UI", 9, "bold"), wraplength=520, justify=LEFT).grid(row=1, column=0, sticky="w", pady=(4, 14)) - try: - with open(file_path, "w", encoding="utf-8") as file_handle: - json.dump(steps, file_handle, indent=2) - except Exception as exc: - messagebox.showerror("Save failed", f"Unable to save the sequence.\n{exc}", parent=window) - return + appearance = make_section(2, "Appearance") + appearance.columnconfigure(1, weight=1) + tk.Label(appearance, text="Theme", bg=palette["card_bg"], fg=palette["text"], font=("Segoe UI", 10, "bold")).grid(row=1, column=0, sticky="w", pady=(0, 8)) + theme_combo = ttk.Combobox(appearance, textvariable=self.theme_var, values=("Light", "Dark", "Ocean"), state="readonly") + theme_combo.grid(row=1, column=1, sticky="ew", pady=(0, 8)) + theme_combo.bind("<>", lambda _event: self._apply_theme()) - helper_var.set(f"Sequence saved to {os.path.basename(file_path)}.") + tk.Label(appearance, text="Window opacity", bg=palette["card_bg"], fg=palette["text"], font=("Segoe UI", 10, "bold")).grid(row=2, column=0, sticky="w", pady=(0, 8)) + ttk.Scale(appearance, variable=self.window_opacity_var, from_=0.70, to=1.00, style="App.Horizontal.TScale", command=lambda _value: self._apply_window_preferences()).grid(row=2, column=1, sticky="ew", pady=(0, 8)) + opacity_row = tk.Frame(appearance, bg=palette["card_bg"]) + opacity_row.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(0, 10)) + for index, (label, value) in enumerate((("70%", 0.70), ("85%", 0.85), ("100%", 1.0))): + opacity_row.columnconfigure(index, weight=1) + ttk.Button(opacity_row, text=label, style="Chip.TButton", command=lambda preset=value: self._set_window_opacity_preset(preset)).grid(row=0, column=index, sticky="ew", padx=(0 if index == 0 else 6, 0)) - def load_sequence(): - file_path = filedialog.askopenfilename(filetypes=[("JSON files", "*.json")], parent=window) - if not file_path: - return + tk.Label(appearance, text="UI scale", bg=palette["card_bg"], fg=palette["text"], font=("Segoe UI", 10, "bold")).grid(row=4, column=0, sticky="w", pady=(0, 8)) + ttk.Scale(appearance, variable=self.ui_scale_var, from_=0.90, to=1.35, style="App.Horizontal.TScale", command=lambda _value: self._apply_window_preferences()).grid(row=4, column=1, sticky="ew", pady=(0, 8)) + scale_row = tk.Frame(appearance, bg=palette["card_bg"]) + scale_row.grid(row=5, column=0, columnspan=2, sticky="ew") + for index, (label, value) in enumerate((("90%", 0.90), ("100%", 1.0), ("115%", 1.15), ("130%", 1.30))): + scale_row.columnconfigure(index, weight=1) + ttk.Button(scale_row, text=label, style="Chip.TButton", command=lambda preset=value: self._set_ui_scale_preset(preset)).grid(row=0, column=index, sticky="ew", padx=(0 if index == 0 else 6, 0)) - try: - normalized_steps = normalize_steps(_load_json_file(file_path)) - except Exception as exc: - messagebox.showerror("Load failed", f"Unable to load the sequence.\n{exc}", parent=window) - return + behavior = make_section(3, "Desktop Behavior") + ttk.Checkbutton(behavior, text="Keep window on top", variable=self.topmost_var, style="App.TCheckbutton").grid(row=1, column=0, sticky="w", pady=(0, 6)) + ttk.Checkbutton(behavior, text="Minimize while clicking", variable=self.minimize_on_start_var, style="App.TCheckbutton").grid(row=2, column=0, sticky="w", pady=(0, 6)) + ttk.Checkbutton(behavior, text="Restore after run", variable=self.restore_after_run_var, style="App.TCheckbutton").grid(row=3, column=0, sticky="w", pady=(0, 6)) + ttk.Checkbutton(behavior, text="Close button sends app to tray", variable=self.close_to_tray_var, style="App.TCheckbutton").grid(row=4, column=0, sticky="w", pady=(0, 6)) + ttk.Checkbutton(behavior, text="Remember size and position", variable=self.remember_window_geometry_var, style="App.TCheckbutton").grid(row=5, column=0, sticky="w", pady=(0, 6)) + ttk.Checkbutton(behavior, text="Fullscreen mode", variable=self.fullscreen_var, style="App.TCheckbutton").grid(row=6, column=0, sticky="w") - steps.clear() - steps.extend(normalized_steps) - render_steps(0 if steps else None) - helper_var.set(f"Loaded {len(steps)} validated step(s).") + layout = make_section(4, "Layout Presets") + for index in range(3): + layout.columnconfigure(index, weight=1) + ttk.Button(layout, text="Compact", style="Secondary.TButton", command=lambda: self._set_window_size_preset(920, 640)).grid(row=1, column=0, sticky="ew") + ttk.Button(layout, text="Wide", style="Secondary.TButton", command=lambda: self._set_window_size_preset(1180, 760)).grid(row=1, column=1, sticky="ew", padx=8) + ttk.Button(layout, text="Studio", style="Secondary.TButton", command=lambda: self._set_window_size_preset(1360, 860)).grid(row=1, column=2, sticky="ew") + ttk.Button(layout, text="Fit to Screen", style="Secondary.TButton", command=self._fit_window_to_screen).grid(row=2, column=0, sticky="ew", pady=(8, 0)) + ttk.Button(layout, text="Center", style="Secondary.TButton", command=self._center_window).grid(row=2, column=1, sticky="ew", padx=8, pady=(8, 0)) + ttk.Button(layout, text="Save Current", style="Secondary.TButton", command=self._remember_current_window_layout).grid(row=2, column=2, sticky="ew", pady=(8, 0)) - tk.Label(body, text="X", bg="#edf4ff", fg="#0f172a", font=("Segoe UI", 10, "bold")).grid(row=4, column=0, sticky="w") - ttk.Entry(body, textvariable=x_var, width=12).grid(row=5, column=0, sticky="w", pady=(4, 8)) - tk.Label(body, text="Y", bg="#edf4ff", fg="#0f172a", font=("Segoe UI", 10, "bold")).grid(row=4, column=1, sticky="w") - ttk.Entry(body, textvariable=y_var, width=12).grid(row=5, column=1, sticky="w", pady=(4, 8)) - - tk.Label(body, text="Action", bg="#edf4ff", fg="#0f172a", font=("Segoe UI", 10, "bold")).grid(row=6, column=0, sticky="w") - ttk.Combobox(body, textvariable=action_var, values=tuple(self.CLICK_TYPES.keys()), state="readonly", width=18).grid(row=7, column=0, sticky="w", pady=(4, 8)) - tk.Label(body, text="Delay after step", bg="#edf4ff", fg="#0f172a", font=("Segoe UI", 10, "bold")).grid(row=6, column=1, sticky="w") - ttk.Entry(body, textvariable=delay_var, width=12).grid(row=7, column=1, sticky="w", pady=(4, 8)) - - action_row = tk.Frame(body, bg="#edf4ff") - action_row.grid(row=8, column=0, columnspan=2, sticky="ew", pady=(4, 0)) - ttk.Button(action_row, text="Add Step", style="Secondary.TButton", command=add_step).grid(row=0, column=0, padx=(0, 8)) - ttk.Button(action_row, text="Add Cursor", style="Secondary.TButton", command=add_cursor_step).grid(row=0, column=1, padx=(0, 8)) - ttk.Button(action_row, text="Import Recording", style="Secondary.TButton", command=import_recording).grid(row=0, column=2, padx=(0, 8)) - ttk.Button(action_row, text="Duplicate", style="Secondary.TButton", command=duplicate_step).grid(row=0, column=3) - - manage_row = tk.Frame(body, bg="#edf4ff") - manage_row.grid(row=9, column=0, columnspan=2, sticky="ew", pady=(8, 0)) - ttk.Button(manage_row, text="Move Up", style="Secondary.TButton", command=lambda: move_step(-1)).grid(row=0, column=0, padx=(0, 8)) - ttk.Button(manage_row, text="Move Down", style="Secondary.TButton", command=lambda: move_step(1)).grid(row=0, column=1, padx=(0, 8)) - ttk.Button(manage_row, text="Delete", style="Secondary.TButton", command=delete_step).grid(row=0, column=2, padx=(0, 8)) - ttk.Button(manage_row, text="Clear", style="Secondary.TButton", command=clear_steps).grid(row=0, column=3) - - file_row = tk.Frame(body, bg="#edf4ff") - file_row.grid(row=10, column=0, columnspan=2, sticky="ew", pady=(8, 0)) - ttk.Button(file_row, text="Save", style="Secondary.TButton", command=save_sequence).grid(row=0, column=0, padx=(0, 8)) - ttk.Button(file_row, text="Load", style="Secondary.TButton", command=load_sequence).grid(row=0, column=1, padx=(0, 8)) - ttk.Button(file_row, text="Run Sequence", style="Accent.TButton", command=run_sequence).grid(row=0, column=2) - - tk.Label(body, textvariable=helper_var, bg="#edf4ff", fg="#475569", font=("Segoe UI", 9), wraplength=620, justify=LEFT).grid(row=11, column=0, columnspan=2, sticky="w", pady=(12, 0)) + support = make_section(5, "Maintenance") + support.columnconfigure(0, weight=1) + support.columnconfigure(1, weight=1) + ttk.Button(support, text="Reset Visuals", style="Secondary.TButton", command=self._reset_visual_preferences).grid(row=1, column=0, sticky="ew", pady=(0, 8)) + ttk.Button(support, text="Reset Layout", style="Secondary.TButton", command=self._reset_window_layout).grid(row=1, column=1, sticky="ew", padx=(8, 0), pady=(0, 8)) + ttk.Button(support, text="Health Check", style="Secondary.TButton", command=self._open_health_dashboard).grid(row=2, column=0, sticky="ew") + ttk.Button(support, text="Open State Folder", style="Secondary.TButton", command=self._open_state_folder).grid(row=2, column=1, sticky="ew", padx=(8, 0)) - render_steps() + footer_row = tk.Frame(body, bg=palette["card_bg"]) + footer_row.grid(row=6, column=0, sticky="ew") + footer_row.columnconfigure(0, weight=1) + footer_row.columnconfigure(1, weight=1) + ttk.Button(footer_row, text="Restart Program", style="Secondary.TButton", command=self._restart_program).grid(row=0, column=0, sticky="ew", padx=(0, 8)) + ttk.Button(footer_row, text="Close", style="Secondary.TButton", command=window.destroy).grid(row=0, column=1, sticky="ew") def _toggle_recording(self): if not self.is_recording: @@ -7020,77 +6603,159 @@ def _toggle_recording(self): self._schedule_workspace_save() def _record_point(self): - if self.is_recording: + """Capture a point. Runs on the `keyboard` hook thread, not the Tk thread.""" + if not self.is_recording: + return + try: + x_pos, y_pos = pyautogui.position() + except Exception: + return + # The Tk thread rebinds self.recording_data wholesale (load, clear, shift), so + # appending from this thread could write into a list that is no longer the + # live one. Marshalling the mutation to the Tk thread removes the race. + try: + self.after(0, self._append_recorded_point, int(x_pos), int(y_pos)) + except Exception: + return + if winsound: try: - x, y = pyautogui.position() - self.recording_data.append((x, y)) - self._set_status_safe(f"Captured ({x}, {y}). Total: {len(self.recording_data)}") - if winsound: - winsound.Beep(800, 50) - except: + winsound.Beep(800, 50) + except Exception: pass + def _append_recorded_point(self, x_pos, y_pos): + if not self.is_recording: + return + self.recording_data.append((x_pos, y_pos)) + self.status_var.set(f"Captured ({x_pos}, {y_pos}). Total: {len(self.recording_data)}") + def _play_recording(self): if not self.recording_data: messagebox.showinfo("Playback", "No recording found. Record some points first.") return - if self.is_playing: + busy = self._busy_subsystem() + if busy: + self.status_var.set(f"Stop the active {busy} before starting playback.") return - if self.worker_thread and self.worker_thread.is_alive(): - self.status_var.set("Stop the active click run before starting playback.") + + # Snapshot everything off the Tk thread; the worker must not read Tk vars. + try: + playback_config = self._build_run_config() + except ValueError as exc: + messagebox.showerror("Invalid configuration", str(exc), parent=self) return - - self.stop_event.clear() + playback_config["points"] = [(int(p[0]), int(p[1])) for p in list(self.recording_data)] + + self.playback_stop_event.clear() + self.pause_event.clear() + self.hotkey_notified = False self.is_playing = True - threading.Thread(target=self._playback_worker, daemon=True).start() - self._append_activity(f"{'Playback simulation' if self.dry_run_var.get() else 'Playback'} started for {len(self.recording_data)} recorded point(s).") + self._refresh_run_buttons() + threading.Thread(target=self._playback_worker, args=(playback_config,), daemon=True).start() + self._append_activity( + f"{'Playback simulation' if playback_config['dry_run'] else 'Playback'} started for " + f"{len(playback_config['points'])} recorded point(s)." + ) + + def _playback_worker(self, config): + """Play recorded points using the same action, fail-safe and stop rules as a run.""" + dry_run = bool(config["dry_run"]) + points = config["points"] + stop_event = self.playback_stop_event + previous_failsafe = getattr(pyautogui, "FAILSAFE", True) + previous_pause = getattr(pyautogui, "PAUSE", 0.1) + completed = 0 + + def should_stop(): + return self._stop_requested(config["stop_hotkey"], stop_event) - def _playback_worker(self): - dry_run = bool(self.dry_run_var.get()) - self._set_status_safe(f"{'Simulating playback for' if dry_run else 'Playing back'} {len(self.recording_data)} points...") + self._set_status_safe(f"{'Simulating playback for' if dry_run else 'Playing back'} {len(points)} points...") try: - delay = float(self.delay_var.get()) - except: - delay = 0.5 - - for i, (x, y) in enumerate(self.recording_data): - if self.stop_event.is_set(): - break - if not dry_run: - pyautogui.click(x=x, y=y) - self.session_clicks += 1 - self._set_status_safe(f"{'Playback simulation' if dry_run else 'Playback'}: {i+1}/{len(self.recording_data)}") - time.sleep(delay) - - self.is_playing = False - if self.stop_event.is_set(): - self._set_status_safe("Playback stopped.") - self._append_activity("Playback stopped before completion.") - else: - self._set_status_safe("Playback simulation finished." if dry_run else "Playback finished.") - self._append_activity(f"{'Playback simulation' if dry_run else 'Playback'} finished after {len(self.recording_data)} point(s).") - if self.play_sound_var.get() and winsound: - winsound.Beep(1200, 200) - self.stop_event.clear() - self._schedule_workspace_save() + try: + pyautogui.FAILSAFE = bool(config["pyautogui_failsafe"]) + if config.get("pacing_mode", "Precise") == "Precise": + pyautogui.PAUSE = 0 + except Exception: + pass + + delay = max(0.0, float(config.get("delay", 0.5))) + for index, (x_pos, y_pos) in enumerate(points): + if should_stop(): + break + if not self._wait_while_paused(config, stop_event): + break + point = ( + max(0, min(self.screen_width - 1, int(x_pos))), + max(0, min(self.screen_height - 1, int(y_pos))), + ) + self._emit_action(config, point, stop_event) + if not dry_run: + self.session_clicks += 1 + completed = index + 1 + self._set_status_safe(f"{'Playback simulation' if dry_run else 'Playback'}: {completed}/{len(points)}") + if config["max_actions"] > 0 and completed >= config["max_actions"]: + self._set_status_safe(f"Playback stopped at the {config['max_actions']} action cap.") + break + if not _interruptible_sleep(delay, should_stop): + break + except Exception as exc: + if exc.__class__.__name__ == "FailSafeException": + self._set_status_safe("Corner fail-safe triggered. Playback stopped.") + else: + self._set_status_safe(f"Playback error: {exc}") + finally: + # is_playing must be cleared on every path, or Start / Play / Run Sequence + # all stay locked out for the rest of the session. + try: + pyautogui.FAILSAFE = previous_failsafe + pyautogui.PAUSE = previous_pause + except Exception: + pass + self.is_playing = False + stopped = stop_event.is_set() + if stopped: + self._set_status_safe("Playback stopped.") + self._append_activity(f"Playback stopped after {completed} of {len(points)} point(s).") + else: + self._set_status_safe("Playback simulation finished." if dry_run else "Playback finished.") + self._append_activity( + f"{'Playback simulation' if dry_run else 'Playback'} finished after {completed} point(s)." + ) + if self.play_sound_var.get() and winsound: + try: + winsound.Beep(1200, 200) + except Exception: + pass + stop_event.clear() + self.ui_queue.put(("refresh_buttons", None)) + self._schedule_workspace_save() def _update_dashboard(self): try: - # Update total session clicks self.total_session_clicks_var.set(f"Total Session Clicks: {self.session_clicks}") self.recording_summary_var.set( f"Recording: {len(self.recording_data)} point(s) | {'active' if self.is_recording else 'idle'}" ) - - # Update elapsed time + elapsed = int(time.time() - self.session_start_time) hours, remainder = divmod(elapsed, 3600) minutes, seconds = divmod(remainder, 60) self.session_elapsed_var.set(f"Session Elapsed: {hours:02}:{minutes:02}:{seconds:02}") - - self.after(1000, self._update_dashboard) - except: + + lifetime = self.lifetime_stats + self.lifetime_stats_var.set( + f"Lifetime: {lifetime.get('runs', 0)} run(s) | {lifetime.get('actions', 0)} action(s) | " + f"{_format_seconds(lifetime.get('seconds', 0.0))}" + ) + except Exception: pass + finally: + # Rescheduling from `finally` means one bad frame can no longer freeze + # the dashboard for the rest of the session. + try: + self.after(1000, self._update_dashboard) + except Exception: + pass def _open_recording_studio(self): palette = self._theme_palette() @@ -7318,8 +6983,7 @@ def save_recording(): return try: - with open(file_path, "w", encoding="utf-8") as file_handle: - json.dump(self.recording_data, file_handle, indent=2) + _atomic_write_json(file_path, self.recording_data) except Exception as exc: messagebox.showerror("Save failed", f"Unable to save the recording.\n{exc}", parent=window) return @@ -7450,7 +7114,7 @@ def _open_sequence_builder(self): tk.Label(body, textvariable=summary_var, bg=palette["alt_bg"], fg=palette["accent"], font=("Segoe UI", 9, "bold")).grid(row=3, column=0, sticky="w", pady=(8, 12)) def normalize_steps(raw_steps): - return _normalize_sequence_steps(raw_steps, self.CLICK_TYPES) + return _normalize_sequence_steps(raw_steps, self.ACTION_TYPES) def selected_index(): selection = list_box.curselection() @@ -7463,7 +7127,7 @@ def read_editor_step(): if delay_seconds < 0: raise ValueError("Delay must be zero or greater.") action_name = action_var.get() - if action_name not in self.CLICK_TYPES: + if action_name not in self.ACTION_TYPES: raise ValueError("Choose a valid action.") return (x_pos, y_pos, action_name, delay_seconds) @@ -7590,53 +7254,62 @@ def clear_steps(): render_steps() helper_var.set("Sequence cleared.") - def run_sequence_worker(sequence_copy, loop_count, countdown_seconds, dry_run): + def run_sequence_worker(sequence_copy, loop_count, countdown_seconds, dry_run, run_settings): completed_steps = 0 previous_failsafe = getattr(pyautogui, "FAILSAFE", True) + previous_pause = getattr(pyautogui, "PAUSE", 0.1) + stop_event = self.sequence_stop_event + stop_hotkey = run_settings.get("stop_hotkey", "") + + def should_stop(): + return self._stop_requested(stop_hotkey, stop_event) + try: - pyautogui.FAILSAFE = bool(self.pyautogui_failsafe_var.get()) + pyautogui.FAILSAFE = bool(run_settings.get("pyautogui_failsafe")) + if run_settings.get("pacing_mode", "Precise") == "Precise": + pyautogui.PAUSE = 0 if countdown_seconds > 0: target_time = time.perf_counter() + countdown_seconds last_announced = None - while time.perf_counter() < target_time: - if self.stop_event.is_set(): + while True: + remaining = target_time - time.perf_counter() + if remaining <= 0: + break + if should_stop(): self.ui_queue.put(("status", "Coordinate sequence cancelled during countdown.")) self._append_activity("Sequence cancelled during countdown.") return - remaining = max(0, target_time - time.perf_counter()) announced_value = int(math.ceil(remaining)) if announced_value != last_announced: self.ui_queue.put(("status", f"Sequence starting in {announced_value}s.")) last_announced = announced_value - time.sleep(min(0.1, remaining)) + _interruptible_sleep(min(0.1, remaining), should_stop, slice_seconds=0.05) for loop_index in range(loop_count): for x_pos, y_pos, action_name, delay_seconds in sequence_copy: - if self.stop_event.is_set(): + if should_stop(): self.ui_queue.put(("status", f"Coordinate sequence stopped after {completed_steps} step(s).")) self._append_activity(f"Sequence stopped after {completed_steps} step(s).") return - button_name, click_count = self.CLICK_TYPES[action_name] - if not dry_run: - try: - pyautogui.click(x=x_pos, y=y_pos, button=button_name, clicks=click_count, interval=0.02 if click_count > 1 else 0.0) - except Exception as exc: - if exc.__class__.__name__ == "FailSafeException": - self.ui_queue.put(("status", f"Corner fail-safe stopped the sequence after {completed_steps} step(s).")) - self._append_activity(f"Corner fail-safe stopped the sequence after {completed_steps} step(s).") - else: - self.ui_queue.put(("status", f"Sequence click error: {exc}")) - self._append_activity(f"Sequence click error: {exc}") - return + if not self._wait_while_paused(run_settings, stop_event): + self.ui_queue.put(("status", f"Coordinate sequence stopped after {completed_steps} step(s).")) + return + step_config = dict(run_settings, click_mode=action_name, dry_run=dry_run) + try: + self._emit_action(step_config, (x_pos, y_pos), stop_event) + except Exception as exc: + if exc.__class__.__name__ == "FailSafeException": + self.ui_queue.put(("status", f"Corner fail-safe stopped the sequence after {completed_steps} step(s).")) + self._append_activity(f"Corner fail-safe stopped the sequence after {completed_steps} step(s).") + else: + self.ui_queue.put(("status", f"Sequence step error: {exc}")) + self._append_activity(f"Sequence step error: {exc}") + return completed_steps += 1 - if delay_seconds > 0: - wake_at = time.perf_counter() + delay_seconds - while time.perf_counter() < wake_at: - if self.stop_event.is_set(): - self.ui_queue.put(("status", f"Coordinate sequence stopped after {completed_steps} step(s).")) - self._append_activity(f"Sequence stopped after {completed_steps} step(s).") - return - time.sleep(min(0.02, wake_at - time.perf_counter())) + if delay_seconds > 0 and not _interruptible_sleep(delay_seconds, should_stop): + self.ui_queue.put(("status", f"Coordinate sequence stopped after {completed_steps} step(s).")) + self._append_activity(f"Sequence stopped after {completed_steps} step(s).") + return self.ui_queue.put(("status", f"Sequence loop {loop_index + 1}/{loop_count} complete.")) self.ui_queue.put(("status", f"{'Dry-run sequence' if dry_run else 'Coordinate sequence'} complete. Ran {completed_steps} step(s).")) self._append_activity(f"{'Dry-run sequence' if dry_run else 'Sequence'} complete. Ran {completed_steps} step(s).") @@ -7646,23 +7319,20 @@ def run_sequence_worker(sequence_copy, loop_count, countdown_seconds, dry_run): finally: try: pyautogui.FAILSAFE = previous_failsafe + pyautogui.PAUSE = previous_pause except Exception: pass self.sequence_thread = None - self.stop_event.clear() + stop_event.clear() + self.ui_queue.put(("refresh_buttons", None)) def launch_sequence(start_from_selected=False): if not steps: messagebox.showinfo("Sequence builder", "Add at least one step before running the sequence.", parent=window) return - if self.worker_thread and self.worker_thread.is_alive(): - messagebox.showinfo("Sequence builder", "Stop the active click run before starting a sequence.", parent=window) - return - if self.sequence_thread and self.sequence_thread.is_alive(): - messagebox.showinfo("Sequence builder", "A sequence is already running.", parent=window) - return - if self.is_playing: - messagebox.showinfo("Sequence builder", "Stop playback before starting a sequence.", parent=window) + busy = self._busy_subsystem() + if busy: + messagebox.showinfo("Sequence builder", f"Stop the active {busy} before starting a sequence.", parent=window) return try: @@ -7687,8 +7357,23 @@ def launch_sequence(start_from_selected=False): sequence_copy = list(steps) start_label = "from the beginning" - self.stop_event.clear() + self.sequence_stop_event.clear() + self.pause_event.clear() + self.hotkey_notified = False dry_run = bool(self.dry_run_var.get()) + # Snapshot Tk state here so the worker thread never reads a Tk variable. + run_settings = { + "stop_hotkey": self.stop_hotkey_var.get().strip(), + "pyautogui_failsafe": bool(self.pyautogui_failsafe_var.get()), + "pacing_mode": self.pacing_mode_var.get(), + "dry_run": dry_run, + "action_key": self.action_key_var.get().strip(), + "action_text": self.action_text_var.get(), + "scroll_amount": int(self.scroll_amount_var.get() or 3), + "hold_duration": float(self.hold_duration_var.get() or 0.25), + "drag_to_x": int(self.drag_to_x_var.get() or 0), + "drag_to_y": int(self.drag_to_y_var.get() or 0), + } self.status_var.set( f"{'Simulating' if dry_run else 'Running'} coordinate sequence {start_label} with {len(sequence_copy)} step(s)." ) @@ -7697,10 +7382,11 @@ def launch_sequence(start_from_selected=False): ) self.sequence_thread = threading.Thread( target=run_sequence_worker, - args=(sequence_copy, loop_count, countdown_seconds, dry_run), + args=(sequence_copy, loop_count, countdown_seconds, dry_run, run_settings), daemon=True, ) self.sequence_thread.start() + self._refresh_run_buttons() def save_sequence(): if not steps: @@ -7710,8 +7396,7 @@ def save_sequence(): if not file_path: return try: - with open(file_path, "w", encoding="utf-8") as file_handle: - json.dump(steps, file_handle, indent=2) + _atomic_write_json(file_path, steps) except Exception as exc: messagebox.showerror("Save failed", f"Unable to save the sequence.\n{exc}", parent=window) return @@ -7739,7 +7424,7 @@ def load_sequence(): tk.Label(editor_card, text="Y", bg=palette["card_bg"], fg=palette["text"], font=("Segoe UI", 10, "bold")).grid(row=1, column=1, sticky="w", padx=(12, 0), pady=(8, 0)) ttk.Entry(editor_card, textvariable=y_var, width=12).grid(row=2, column=1, sticky="w", padx=(12, 0), pady=(4, 0)) tk.Label(editor_card, text="Action", bg=palette["card_bg"], fg=palette["text"], font=("Segoe UI", 10, "bold")).grid(row=1, column=2, sticky="w", padx=(12, 0), pady=(8, 0)) - ttk.Combobox(editor_card, textvariable=action_var, values=tuple(self.CLICK_TYPES.keys()), state="readonly", width=18).grid(row=2, column=2, sticky="w", padx=(12, 0), pady=(4, 0)) + ttk.Combobox(editor_card, textvariable=action_var, values=tuple(self.ACTION_TYPES.keys()), state="readonly", width=18).grid(row=2, column=2, sticky="w", padx=(12, 0), pady=(4, 0)) tk.Label(editor_card, text="Delay after step", bg=palette["card_bg"], fg=palette["text"], font=("Segoe UI", 10, "bold")).grid(row=1, column=3, sticky="w", padx=(12, 0), pady=(8, 0)) ttk.Entry(editor_card, textvariable=delay_var, width=12).grid(row=2, column=3, sticky="w", padx=(12, 0), pady=(4, 0)) @@ -7783,33 +7468,67 @@ def load_sequence(): render_steps() def _open_mini_control(self): + palette = self._theme_palette() mini = tk.Toplevel(self) mini.overrideredirect(True) mini.attributes("-topmost", True) - mini.attributes("-alpha", 0.9) - mini.geometry("140x50+100+100") - mini.configure(bg="#1e293b") - - # Drag logic - def start_move(event): mini.x = event.x; mini.y = event.y + mini.attributes("-alpha", 0.92) + mini.geometry("232x74+100+100") + mini.configure(bg=palette["hero_chip_bg"]) + + # Drag logic. The grip label is the handle, so the buttons stay clickable. + def start_move(event): + mini.x, mini.y = event.x, event.y + def do_move(event): - x = mini.winfo_x() + (event.x - mini.x) - y = mini.winfo_y() + (event.y - mini.y) - mini.geometry(f"+{x}+{y}") - mini.bind("", start_move) - mini.bind("", do_move) + x_pos = mini.winfo_x() + (event.x - mini.x) + y_pos = mini.winfo_y() + (event.y - mini.y) + mini.geometry(f"+{x_pos}+{y_pos}") - frame = tk.Frame(mini, bg="#1e293b", padx=5, pady=5) + frame = tk.Frame(mini, bg=palette["hero_chip_bg"], padx=6, pady=5) frame.pack(fill="both", expand=True) - - btn_start = tk.Button(frame, text="Start", bg="#0f766e", fg="white", font=("Segoe UI", 10, "bold"), bd=0, command=self.startclick, width=6) + + grip = tk.Label(frame, text="::", bg=palette["hero_chip_bg"], fg=palette["hero_sub"], + font=("Segoe UI", 11, "bold"), cursor="fleur") + grip.pack(side="left", padx=(2, 6)) + for widget in (mini, frame, grip): + widget.bind("", start_move) + widget.bind("", do_move) + + btn_start = tk.Button(frame, text="Start", bg=palette["accent"], fg="white", + font=("Segoe UI", 9, "bold"), bd=0, command=self.startclick, width=5) btn_start.pack(side="left", padx=2) - - btn_stop = tk.Button(frame, text="Stop", bg="#b91c1c", fg="white", font=("Segoe UI", 10, "bold"), bd=0, command=self.stopclick, width=6) + btn_pause = tk.Button(frame, text="Pause", bg=palette["secondary_bg"], fg=palette["secondary_fg"], + font=("Segoe UI", 9, "bold"), bd=0, command=self.toggle_pause, width=6) + btn_pause.pack(side="left", padx=2) + btn_stop = tk.Button(frame, text="Stop", bg=palette["danger"], fg="white", + font=("Segoe UI", 9, "bold"), bd=0, command=self.stopclick, width=5) btn_stop.pack(side="left", padx=2) - - btn_close = tk.Button(frame, text="X", bg="#1e293b", fg="#94a3b8", font=("Segoe UI", 10, "bold"), bd=0, command=mini.destroy, width=2) - btn_close.pack(side="right") + tk.Button(frame, text="X", bg=palette["hero_chip_bg"], fg=palette["hero_sub"], + font=("Segoe UI", 9, "bold"), bd=0, command=mini.destroy, width=2).pack(side="right") + + state_var = tk.StringVar(value="Idle") + tk.Label(mini, textvariable=state_var, bg=palette["hero_chip_bg"], fg=palette["hero_sub"], + font=("Segoe UI", 8)).place(x=8, y=54) + + def refresh_state(): + """Mini Control used to give no indication of what the app was doing.""" + if not mini.winfo_exists(): + return + busy = self._busy_subsystem() + if busy and self.pause_event.is_set(): + state_var.set(f"Paused - {busy}") + elif busy: + state_var.set(f"Running - {busy}") + else: + state_var.set("Idle") + btn_start.configure(state=DISABLED if busy else NORMAL) + btn_pause.configure(state=NORMAL if busy else DISABLED, + text="Resume" if self.pause_event.is_set() else "Pause") + btn_stop.configure(state=NORMAL if busy else DISABLED) + mini.after(250, refresh_state) + + refresh_state() @@ -7819,8 +7538,10 @@ def _setup_tray(self): image = Image.open(_resource_path("favicon.ico")) menu = ( item('Restore', self._restore_from_tray), - item('Start Clicker', self.startclick), - item('Stop Clicker', self.stopclick), + # Tray callbacks arrive on the pystray thread; hop to the Tk thread. + item('Start Clicker', lambda: self.after(0, self.startclick)), + item('Pause / Resume', lambda: self.after(0, self.toggle_pause)), + item('Stop Clicker', lambda: self.after(0, self.stopclick)), item('Exit', self.EXITME) ) self.tray_icon = pystray.Icon("autoclicker", image, "AutoClicker", menu) @@ -7846,7 +7567,9 @@ def _minimize_to_tray(self): def EXITME(self): self.is_recording = False - self.stop_event.set() + self.pause_event.clear() + for event in (self.stop_event, self.sequence_stop_event, self.playback_stop_event): + event.set() try: if self.record_hotkey_handle is not None: keyboard.remove_hotkey(self.record_hotkey_handle) @@ -7857,6 +7580,7 @@ def EXITME(self): keyboard.remove_hotkey(self.emergency_hotkey_handle) except: pass + self._unregister_global_hotkeys() if self.tray_icon is not None: try: self.tray_icon.stop() @@ -7864,130 +7588,439 @@ def EXITME(self): pass self.tray_icon = None self._persist_workspace_state() - self.stop_event.set() self.destroy() your_gui = YourGUI() your_gui.mainloop() time.sleep(0) -if __name__ == '__main__': - cli_args = sys.argv[1:] - - if "--state-json" in cli_args or ("--state-summary" in cli_args and "--json" in cli_args): - print(json.dumps(_collect_state_summary_data(), indent=2, sort_keys=True)) - raise SystemExit(0) - if "--state-summary" in cli_args: - print(_build_state_summary_report()) - raise SystemExit(0) - if "--backup-state" in cli_args: - backup_target = _argument_value(cli_args, "--backup-state") - backup_result = _backup_state_files(backup_target) - if "--json" in cli_args: - print(json.dumps(backup_result, indent=2, sort_keys=True)) - else: - print(f"State backup directory: {backup_result['backup_dir']}") - print(f"Copied files: {backup_result['count']}") - for copied_file in backup_result["copied_files"]: - print(f"- {copied_file}") - raise SystemExit(0) - if "--validate-recording" in cli_args: - recording_path = _argument_value(cli_args, "--validate-recording") - if not recording_path: - print("--validate-recording requires a JSON file path.", file=sys.stderr) - raise SystemExit(2) +# -------------------------------------------------------------------------------------- +# Headless command line +# -------------------------------------------------------------------------------------- + +CLI_EXIT_OK = 0 +CLI_EXIT_FAILED = 1 +CLI_EXIT_USAGE = 2 + +# Legacy V10.1 flags stay supported so existing scripts keep working. +LEGACY_FLAG_COMMANDS = { + "--health-check": ["health"], + "--health": ["health"], + "--health-json": ["health", "--json"], + "--state-summary": ["state-summary"], + "--state-json": ["state-summary", "--json"], + "--backup-state": ["backup-state"], + "--validate-recording": ["validate-recording"], + "--validate-sequence": ["validate-sequence"], + "--validate-profiles": ["validate-profiles"], + "--profile-import-preview": ["profile-preview"], +} + + +def _cli_envelope(command, ok, data=None, errors=None): + """Uniform JSON shape for every command, including usage errors.""" + return { + "schema_version": STATE_SCHEMA_VERSION, + "app_version": APP_VERSION, + "command": command, + "ok": bool(ok), + "generated_at": datetime.datetime.now().isoformat(timespec="seconds"), + "data": data if data is not None else {}, + "errors": list(errors or []), + } + + +def _translate_legacy_args(argv): + """Map old single-flag invocations onto the subcommand grammar. + + Returns (translated_args, ignored_flags). Unknown input passes through so argparse can + report it, instead of silently falling through and opening the GUI on a CI runner. + """ + argv = list(argv or []) + if not argv: + return argv, [] + + legacy_flags = [arg for arg in argv if arg.split("=", 1)[0] in LEGACY_FLAG_COMMANDS] + if not legacy_flags: + return argv, [] + + # First flag wins, matching V10.1 dispatch order, but any extras are now reported + # rather than silently discarded behind a success exit code. + primary = legacy_flags[0] + flag_name, _, inline_value = primary.partition("=") + translated = list(LEGACY_FLAG_COMMANDS[flag_name]) + + value = inline_value or _argument_value(argv, flag_name) + if value: + translated.append(value) + if "--json" in argv and "--json" not in translated: + translated.append("--json") + return translated, list(legacy_flags[1:]) + + +def _build_cli_parser(): + import argparse + + parser = argparse.ArgumentParser( + prog="AutoClicker", + description="AutoClicker Control Center. Run with no arguments to open the GUI.", + epilog="Exit codes: 0 success, 1 check or validation failure, 2 usage error, 3 internal error.", + ) + parser.add_argument( + "--version", + action="version", + version=f"AutoClicker {APP_VERSION} (Python {sys.version.split()[0]}, {platform.system()})", + ) + subparsers = parser.add_subparsers(dest="command") + + def add(name, help_text): + sub = subparsers.add_parser(name, help=help_text) + sub.add_argument("--json", action="store_true", help="emit a machine-readable JSON envelope") + return sub + + add("gui", "open the Control Center window") + + health = add("health", "report dependency, version and state-file health") + health.add_argument("--strict", action="store_true", help="exit 1 when a required dependency is missing") + + add("state-summary", "summarise the saved profile and workspace files") + + backup = add("backup-state", "copy the app-state files into a timestamped folder") + backup.add_argument("destination", nargs="?", help="target directory (defaults to the state folder)") + backup.add_argument("--dry-run", action="store_true", help="show what would be copied without writing") + + bundle = add("support-bundle", "write a full support bundle for troubleshooting") + bundle.add_argument("destination", nargs="?", help="target directory") + + for name, help_text in ( + ("validate-recording", "validate one or more recording JSON files"), + ("validate-sequence", "validate one or more sequence JSON files"), + ("validate-profiles", "validate one or more profiles JSON files"), + ): + sub = add(name, help_text) + sub.add_argument("paths", nargs="+", help="one or more JSON files") + + preview = add("profile-preview", "preview importing a profiles file over the saved profiles") + preview.add_argument("paths", nargs="+", help="one or more JSON files") + preview.add_argument("--show-values", action="store_true", help="include full profile payloads in JSON output") + + profiles = add("profiles", "list saved profiles, or show one by name") + profiles.add_argument("name", nargs="?", help="show a single profile instead of listing all") + + readiness = add("readiness", "run the pre-flight readiness checklist for a saved profile") + readiness.add_argument("name", help="profile name") + + schema = add("schema", "print the accepted shape of a file or action type") + schema.add_argument("kind", choices=("recording", "sequence", "profile", "action")) + + history = add("history", "summarise the persistent run log") + history.add_argument("--limit", type=int, default=20, help="how many recent runs to show") + + add("doctor", "report health plus the exact pip command that fixes it") + + return parser + + +def _cli_load_saved_profiles(): + path = _state_file_location(PROFILE_FILE_NAME) + if not os.path.exists(path): + return {}, path + loaded = _load_json_file(path) + return (loaded if isinstance(loaded, dict) else {}), path + + +def _cli_emit(args, command, ok, data, errors, text_lines): + """Print either the JSON envelope or the human report, and return the exit code.""" + errors = list(errors or []) + if getattr(args, "json", False): + print(json.dumps(_cli_envelope(command, ok, data, errors), indent=2, sort_keys=True, default=str)) + else: + for line in text_lines: + print(line) + for error in errors: + print(error, file=sys.stderr) + return CLI_EXIT_OK if ok else CLI_EXIT_FAILED + + +def _cli_validate_files(args, command, validator, describe, extra_errors=None): + """Validate every supplied path; the command fails if any single file fails.""" + results, errors, lines = [], list(extra_errors or []), [] + file_type = command.replace("validate-", "") + for path in args.paths: try: - validation_result = _validate_recording_file(recording_path) + result = validator(path) except Exception as exc: - validation_result = {"valid": False, "type": "recording", "path": recording_path, "error": str(exc)} - if "--json" not in cli_args: - print(f"Recording invalid: {exc}", file=sys.stderr) - else: - print(json.dumps(validation_result, indent=2, sort_keys=True)) - raise SystemExit(1) - if "--json" in cli_args: - print(json.dumps(validation_result, indent=2, sort_keys=True)) + result = {"valid": False, "path": path, "error": str(exc)} + result.setdefault("path", path) + result["type"] = file_type + results.append(result) + if result.get("valid"): + lines.append(f"OK {path}: {describe(result)}") else: - print(f"Recording valid: {validation_result['points']} point(s).") - raise SystemExit(0) - if "--validate-sequence" in cli_args: - sequence_path = _argument_value(cli_args, "--validate-sequence") - if not sequence_path: - print("--validate-sequence requires a JSON file path.", file=sys.stderr) - raise SystemExit(2) + errors.append(f"FAIL {path}: {result.get('error', 'did not pass validation')}") + ok = bool(results) and all(r.get("valid") for r in results) + return _cli_emit(args, command, ok, {"results": results, "count": len(results)}, errors, lines) + + +def _readiness_config_from_profile(profile): + """Project a saved profile onto the run-config shape the readiness checker expects.""" + profile = profile or {} + + def number(name, default=0.0): try: - validation_result = _validate_sequence_file(sequence_path) - except Exception as exc: - validation_result = {"valid": False, "type": "sequence", "path": sequence_path, "error": str(exc)} - if "--json" not in cli_args: - print(f"Sequence invalid: {exc}", file=sys.stderr) - else: - print(json.dumps(validation_result, indent=2, sort_keys=True)) - raise SystemExit(1) - if "--json" in cli_args: - print(json.dumps(validation_result, indent=2, sort_keys=True)) - else: - print( - f"Sequence valid: {validation_result['steps']} step(s), " - f"{validation_result['total_wait_seconds']:.3f}s total wait." - ) - raise SystemExit(0) - if "--validate-profiles" in cli_args or "--profile-import-preview" in cli_args: - profile_flag = "--profile-import-preview" if "--profile-import-preview" in cli_args else "--validate-profiles" - profiles_path = _argument_value(cli_args, profile_flag) - if not profiles_path: - print(f"{profile_flag} requires a JSON file path.", file=sys.stderr) - raise SystemExit(2) - existing_profiles = None - if profile_flag == "--profile-import-preview": - existing_profile_path = _state_file_location(PROFILE_FILE_NAME) - if os.path.exists(existing_profile_path): - try: - loaded_existing_profiles = _load_json_file(existing_profile_path) - if isinstance(loaded_existing_profiles, dict): - existing_profiles = loaded_existing_profiles - except Exception: - existing_profiles = None + return float(profile.get(name, default)) + except Exception: + return default + + repeat_limit = None + if str(profile.get("repeat_mode", "Infinite")) == "Burst Count": try: - validation_result = _validate_profiles_file( - profiles_path, - existing_profiles=existing_profiles, - ) - except Exception as exc: - validation_result = { - "valid": False, - "type": "profiles", - "path": profiles_path, - "mode": profile_flag.lstrip("-"), - "error": str(exc), + repeat_limit = int(profile.get("repeat_count", 0)) + except Exception: + repeat_limit = None + + return { + "x": int(number("target_x")), + "y": int(number("target_y")), + "delay": number("delay"), + "runtime_limit": number("runtime_limit"), + "max_actions": int(number("max_actions")), + "repeat_limit": repeat_limit, + "stop_hotkey": str(profile.get("stop_hotkey", "")), + "dry_run": bool(profile.get("dry_run")), + "pyautogui_failsafe": bool(profile.get("pyautogui_failsafe")), + "human_like": bool(profile.get("human_like")), + "pacing_mode": str(profile.get("pacing_mode", "Precise")), + "click_mode": str(profile.get("click_mode", "Left Click")), + "action_key": profile.get("action_key", ACTION_DEFAULTS["action_key"]), + "action_text": profile.get("action_text", ACTION_DEFAULTS["action_text"]), + "scroll_amount": profile.get("scroll_amount", ACTION_DEFAULTS["scroll_amount"]), + "hold_duration": profile.get("hold_duration", ACTION_DEFAULTS["hold_duration"]), + "drag_to_x": profile.get("drag_to_x", ACTION_DEFAULTS["drag_to_x"]), + "drag_to_y": profile.get("drag_to_y", ACTION_DEFAULTS["drag_to_y"]), + "schedule": _parse_scheduled_start(profile.get("scheduled_start")), + } + + +def _describe_schema(kind): + """Machine-readable description of each accepted file or action shape.""" + if kind == "recording": + return { + "kind": "recording", + "description": "JSON array of [x, y] screen points.", + "max_points": 200, + "example": [[100, 200], [340, 512]], + } + if kind == "sequence": + return { + "kind": "sequence", + "description": "JSON array of [x, y, action_name, delay_seconds] steps.", + "action_names": sorted(ACTION_REGISTRY), + "example": [[100, 200, "Left Click", 0.5], [340, 512, "Scroll Down", 0.25]], + } + if kind == "profile": + return { + "kind": "profile", + "description": "JSON object mapping profile name to a settings object.", + "fields": sorted(PROFILE_FIELDS), + "int_fields": sorted(PROFILE_INT_FIELDS), + "float_fields": sorted(PROFILE_FLOAT_FIELDS), + "bool_fields": sorted(PROFILE_BOOL_FIELDS), + "enum_fields": {name: sorted(values) for name, values in PROFILE_ENUM_FIELDS.items()}, + } + return { + "kind": "action", + "description": "Actions the run engine can emit.", + "actions": { + name: { + "kind": spec["kind"], + "uses": list(spec.get("uses", ())), + "button": spec.get("button"), + "clicks": spec.get("clicks"), } - if "--json" in cli_args: - print(json.dumps(validation_result, indent=2, sort_keys=True)) + for name, spec in ACTION_REGISTRY.items() + }, + "defaults": dict(ACTION_DEFAULTS), + } + + +def _run_cli(argv): + """Dispatch a headless command. Returns an exit code, or None to open the GUI.""" + translated, ignored_flags = _translate_legacy_args(argv) + parser = _build_cli_parser() + args = parser.parse_args(translated) + command = args.command + + if command is None or command == "gui": + return None + + conflict_errors = [] + if ignored_flags: + # V10.1 dropped later subcommands silently and still exited 0, so a CI job could + # turn green without running the check it asked for. + conflict_errors.append( + f"Ignored extra legacy flag(s): {', '.join(ignored_flags)}. Run one command at a time." + ) + + if command in ("health", "doctor"): + data = _collect_headless_health_data() + missing = data.get("missing_required") or [] + missing_optional = sorted( + name for name, item in data["dependencies"].items() + if not item["available"] and not item.get("required") + ) + strict = bool(getattr(args, "strict", False)) + data["fix_command"] = f"pip install {' '.join(missing)}" if missing else "" + data["missing_optional"] = missing_optional + lines = _build_headless_health_report().split("\n") + if command == "doctor": + lines += ["", "Doctor"] + if missing: + lines.append(f"- Required, install now: pip install {' '.join(missing)}") + if missing_optional: + lines.append(f"- Optional, unlocks more features: pip install {' '.join(missing_optional)}") + if not missing and not missing_optional: + lines.append("- Everything this app can use is installed.") + # `health` stays exit 0 by default for backwards compatibility; --strict and + # `doctor` are the gate-able forms. + fail_on_missing = strict or command == "doctor" + errors = conflict_errors + ([f"Missing required dependency: {name}" for name in missing] if fail_on_missing else []) + ok = not (fail_on_missing and missing) + return _cli_emit(args, command, ok, data, errors, lines) + + if command == "state-summary": + data = _collect_state_summary_data() + errors = list(conflict_errors) + for key in ("profiles", "workspace"): + entry = data.get(key) + if isinstance(entry, dict) and entry.get("error"): + errors.append(f"{key}: {entry['error']}") + ok = not any(e for e in errors if not e.startswith("Ignored")) + return _cli_emit(args, command, ok, data, errors, _build_state_summary_report().split("\n")) + + if command == "backup-state": + if args.dry_run: + summary = _collect_state_summary_data() + planned = [entry["path"] for entry in summary.values() + if isinstance(entry, dict) and entry.get("present")] + lines = ["Dry run: nothing written.", f"Would copy {len(planned)} file(s):"] + lines += [f"- {path}" for path in planned] + return _cli_emit(args, command, True, {"dry_run": True, "planned_files": planned}, conflict_errors, lines) + result = _backup_state_files(args.destination) + lines = [f"State backup directory: {result['backup_dir']}", f"Copied files: {result['count']}"] + lines += [f"- {name}" for name in result["copied_files"]] + return _cli_emit(args, command, True, result, conflict_errors, lines) + + if command == "support-bundle": + result = _build_support_bundle(args.destination) + lines = [f"Support bundle: {result.get('bundle_dir', '?')}", f"Files: {len(result.get('files', []))}"] + lines += [f"- {name}" for name in result.get("files", [])] + return _cli_emit(args, command, True, result, conflict_errors, lines) + + if command == "validate-recording": + return _cli_validate_files(args, command, _validate_recording_file, + lambda r: f"{r['points']} point(s)", conflict_errors) + if command == "validate-sequence": + return _cli_validate_files(args, command, _validate_sequence_file, + lambda r: f"{r['steps']} step(s), {r['total_wait_seconds']:.3f}s total wait", + conflict_errors) + if command == "validate-profiles": + return _cli_validate_files(args, command, _validate_profiles_file, + lambda r: f"{r['valid_count']} valid, {r['invalid_count']} invalid", + conflict_errors) + + if command == "profile-preview": + existing, _path = _cli_load_saved_profiles() + results, errors, lines = [], list(conflict_errors), [] + for path in args.paths: + try: + result = _validate_profiles_file(path, existing_profiles=existing) + except Exception as exc: + result = {"valid": False, "path": path, "error": str(exc)} + result.setdefault("path", path) + if not args.show_values: + # Payloads carry hotkeys and window layouts; dumping them is opt-in. + result.pop("profiles", None) + results.append(result) + if result.get("valid"): + lines.append( + f"OK {path}: {result['valid_count']} valid, {result['invalid_count']} invalid, " + f"{result['overwrite_count']} overwrite(s)" + ) else: - print(f"Profiles invalid: {exc}", file=sys.stderr) - raise SystemExit(1) - validation_result["type"] = "profiles" - validation_result["mode"] = profile_flag.lstrip("-") - if "--json" in cli_args: - print(json.dumps(validation_result, indent=2, sort_keys=True)) - else: - print( - f"Profiles {'valid' if validation_result['valid'] else 'need attention'}: " - f"{validation_result['valid_count']} valid, " - f"{validation_result['invalid_count']} invalid, " - f"{validation_result['overwrite_count']} overwrite(s)." + errors.append(f"FAIL {path}: {result.get('error', 'did not pass validation')}") + ok = bool(results) and all(r.get("valid") for r in results) + return _cli_emit(args, command, ok, {"results": results}, errors, lines) + + if command == "profiles": + profiles, path = _cli_load_saved_profiles() + if args.name: + profile = profiles.get(args.name) + if profile is None: + return _cli_emit(args, command, False, {"path": path, "name": args.name}, + conflict_errors + [f"No saved profile named '{args.name}'."], []) + lines = [f"Profile: {args.name}"] + [f"- {key}: {value}" for key, value in sorted(profile.items())] + return _cli_emit(args, command, True, + {"path": path, "name": args.name, "profile": profile}, conflict_errors, lines) + names = sorted(profiles) + lines = [f"Profiles file: {path}", f"Saved profiles: {len(names)}"] + [f"- {name}" for name in names] + return _cli_emit(args, command, True, + {"path": path, "names": names, "count": len(names)}, conflict_errors, lines) + + if command == "readiness": + profiles, path = _cli_load_saved_profiles() + profile = profiles.get(args.name) + if profile is None: + return _cli_emit(args, command, False, {"path": path, "name": args.name}, + conflict_errors + [f"No saved profile named '{args.name}'."], []) + validation = _validate_profile_data(args.name, profile) + readiness = _build_readiness_checklist(_readiness_config_from_profile(profile)) + ok = bool(readiness["ready"] and validation["valid"]) + errors = conflict_errors + ([] if validation["valid"] else list(validation["errors"])) + return _cli_emit(args, command, ok, + {"name": args.name, "readiness": readiness, "validation": validation}, + errors, _format_readiness_text(readiness, limit=20).split("\n")) + + if command == "schema": + data = _describe_schema(args.kind) + lines = [f"{args.kind} schema", ""] + json.dumps(data, indent=2, sort_keys=True).split("\n") + return _cli_emit(args, command, True, data, conflict_errors, lines) + + if command == "history": + log_path = _state_file_location(RUN_LOG_FILE_NAME) + records = _read_run_log(log_path, limit=max(1, args.limit)) + summary = _summarize_run_history(records) + lines = [ + f"Run log: {log_path}", + f"{summary['runs']} run(s) ({summary['live_runs']} live, {summary['dry_runs']} dry), " + f"{summary['actions']} action(s), {_format_seconds(summary['seconds'])}, " + f"{summary['average_cps']:.2f} action(s)/sec average", + "", + ] + for record in reversed(records): + lines.append( + f"{record.get('finished_at', '?')} {'DRY ' if record.get('dry_run') else 'LIVE'} " + f"{record.get('click_mode', '?')} {record.get('actions', 0)} action(s) " + f"{record.get('stop_reason', '?')}" ) - if validation_result["invalid_count"]: - for invalid_profile in validation_result["invalid_profiles"]: - print(f"- {invalid_profile['name']}: {'; '.join(invalid_profile['errors'])}") - raise SystemExit(0 if validation_result["valid"] else 1) - if "--health-json" in cli_args or ("--health-check" in cli_args and "--json" in cli_args): - print(json.dumps(_collect_headless_health_data(), indent=2, sort_keys=True)) - raise SystemExit(0) - if any(argument in ("--health-check", "--health") for argument in cli_args): - print(_build_headless_health_report()) - raise SystemExit(0) - MAINWINDOW_REDESIGNED() + if not records: + lines.append("No runs recorded yet.") + return _cli_emit(args, command, True, + {"log_path": log_path, "summary": summary, "runs": records}, conflict_errors, lines) + + parser.error(f"Unknown command: {command}") +if __name__ == '__main__': + try: + _exit_code = _run_cli(sys.argv[1:]) + except SystemExit: + raise + except Exception as _exc: + print(f"AutoClicker failed: {_exc}", file=sys.stderr) + raise SystemExit(3) + if _exit_code is None: + MAINWINDOW_REDESIGNED() + else: + raise SystemExit(_exit_code) diff --git a/Builds/V10.0/Executable/creds.json b/Builds/V10.0/Executable/creds.json deleted file mode 100644 index 2471897..0000000 --- a/Builds/V10.0/Executable/creds.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "type": "service_account", - "project_id": "test-sheet-api-234320", - "private_key_id": "48452743358cc126854b8b50d6ba531df867f728", - "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDRVXAU9pHssVNF\nEkjyCDHrtoL7QANqCscxfpAQfstHomvHAE42a/hfCqVCaIl0W1mWo6zD54n2nxGL\n1r52y35KmBiM6zL7H4YBUA/Es6RMNR/Ea9kBxJnrwu86dQi2G1j83Z+7UwcIdTct\nEWoeFfXMJUB1e1EqxhucehYpV2vUWaLPLVS3MKBQi4OMReOIKcJmmxCCzsL8LdxS\nA5EG6xtExrrwVNBx+ibEJfOxTmPkOlVIHnnHMHCW9L9m5OgQ9yXznY09cr4EfaoV\nrzM60P1cTz6zYsHtZSc8FnmqXatT+mZFEKgicB3HJCdzxlL+7MYvlpJjIigyPpr+\nGbXzcWnLAgMBAAECggEAWRRa+Ei+6zLAhR2VVg1/tNc7YNCqQ0dn4ZuCkcprFcY7\nx/jMz6LdlBAzKBHVIGM5Of3XqJwQnRauwEu3TFUnHnYfyGPUE4IkI83ej+2EJnva\n01qzg2mWuHmBFOa1uMzlbuGetffMf+BlhPOqrTfaG6/kgLnj3ehk2o8nfBs8AyYG\nh0Ds92xjY4OAfLYjf2DcNREXxnIHATOnfKB0P0D+qXq1BrrlHNY/d+7/th2XiUAe\nq/bgEl4mzL1nVzv8fMytuZEXPktpHg2rV+/iKDvgYharBWjtGriTm7LAowP/sNpg\nO1kQPtUI53pWA+wF6/O/mKRgWxyUvfavr7amlEB47QKBgQDyBr6MeLkO+XaN91Ud\nOenbPyuSfXtOHYv7aapYrsFslixyJ1FdfB+augiQQTq7TlVfi6f2zjNRcPScN6Gr\nDSn7X2/WBimeKHWiNSk+8HvumeI3i3hGdkKrFqsca8HA3AEocMrudi6ZaDpnAh+f\nSShK1tAz1giSAC6dIiTNwgNKHwKBgQDda3uB51CBqHVWqUpFn7o1aMDiChB751CJ\nve+C2zlnIiP5tbI1SCJ3BFm1qB+dAwEG09VSIZ6O9ZFu2WeVCcaUcoKbzqqeqgmk\niCoteh2I8BDBaPiN35JhAmIDDey/dbqG6eHwjGC6v90jqEEqVfXfBunD4f8OEa7I\n7oAllM+C1QKBgQDhTuoAtzn/+LgnPUMdLBbKpioX+3zYoaT47ctJN5aVUdAajQOv\n9acuUVU7hZ35VNkhU0+P8THQ9UiiTR/RrzWP766bHa2MsLmhwb+d+h5FZzgy4UGc\nxucVgsrUcHQ06Bqj3SzthVOoOGF2F4H8tQwvb37vYqcj08uROIHuklihnQKBgQDV\n1cOFhRte3I3Vj9DGxKdUhUIiCo442wAQxnnU6lrZm0MbGbptcqi2QGFS6nb9b7VC\nHrpZg1ZZwhcd8p/yc2pwWtaAGmr0qEp/t3DkUpbLdOz5nKLOgSggv+hgAsatcMkf\nGii14xxXvouWsBufAz1ckpSYNF3Z/IHMU2k24iw1sQKBgGMwB1h1VVry03rye+fC\nzhkIQNkH4CesPq+Exkyoevkt7YVnnxQ0dN7Ho370ymXVziaj3Qb1UngKxLOKRE+q\ntA8XBS0bnK2cMckcCfWBbdlpaRGjU26pmYsThBhE99QG8Fnlhd2ptLSZNmlAzWJc\n0jL3L0A0bzfl2/8NU87hfNqT\n-----END PRIVATE KEY-----\n", - "client_email": "kai-60@test-sheet-api-234320.iam.gserviceaccount.com", - "client_id": "110138069390492700372", - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": "https://oauth2.googleapis.com/token", - "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", - "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/kai-60%40test-sheet-api-234320.iam.gserviceaccount.com" -} diff --git a/Builds/V11.0/Executable/AutoClicker.exe b/Builds/V11.0/Executable/AutoClicker.exe new file mode 100644 index 0000000..0a70b35 Binary files /dev/null and b/Builds/V11.0/Executable/AutoClicker.exe differ diff --git a/Builds/V11.0/Executable/AutoClickerLite.exe b/Builds/V11.0/Executable/AutoClickerLite.exe new file mode 100644 index 0000000..6a5bd10 Binary files /dev/null and b/Builds/V11.0/Executable/AutoClickerLite.exe differ diff --git a/Builds/V11.0/Executable/LICENSE b/Builds/V11.0/Executable/LICENSE new file mode 100644 index 0000000..245d0c5 --- /dev/null +++ b/Builds/V11.0/Executable/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 kai9987kai + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Builds/V11.0/Executable/README.md b/Builds/V11.0/Executable/README.md new file mode 100644 index 0000000..f851f9b --- /dev/null +++ b/Builds/V11.0/Executable/README.md @@ -0,0 +1,230 @@ +# AutoClicker + +Windows desktop input-automation utilities built in Python with Tkinter and PyAutoGUI. + +## Current apps + +- `AutoClicker.py` + The full Control Center experience with profiles, quick tools, recording/playback, tray support, and richer run controls. +- `lite-version.py` + A smaller launcher for fast coordinate clicking with burst mode and safer stop handling. + +## What is new in V11.0 + +V11.0 is additive: every V10.1 feature is still present. The headline changes are a wider +action vocabulary, real pause/resume, honest timing, and a proper command line. + +### New capabilities + +- **Beyond clicking.** The action list now covers `Key Press`, `Key Hold`, `Type Text`, + `Scroll Up`/`Scroll Down`, `Click And Hold`, `Drag To`, `Move Only` and `Triple Left Click` + alongside all six original click types. Sequences accept the new actions too. +- **Pause and resume.** A Pause button sits beside Start/Stop and holds a run after the + current action without losing its position. Works for click runs, sequences and playback. +- **Hotkey Center.** Optional system-wide hotkeys for start, pause and capture-cursor, so the + app is drivable while minimised over the target window. In-window: `F5` start, `F6` pause, + `Esc` stop. +- **Clicks-per-second targeting.** Enter a target rate and the app derives the delay, or read + the rate back from an existing delay. Live instant/average/peak rate shows during a run. +- **Scheduled start.** Give a `HH:MM` wall-clock time and the run arms itself until then. +- **Round-robin targets.** Cycle a recorded point list instead of hammering one coordinate. +- **Run history.** Every finished run is appended to a rotating log under the app-data folder, + with a viewer (`Run History`), lifetime totals that survive restarts, and JSON export. +- **New themes.** `Midnight`, plus `System` which follows the OS light/dark preference. +- **`Feather Touch` behaviour preset** for fragile or laggy UIs. +- **Real CLI.** `--help`, `--version`, subcommands, a stable JSON envelope, and a documented + exit-code contract (see below). + +### Fixes worth knowing about + +- **Timing is now honest.** PyAutoGUI adds a hidden 0.1s pause to every call, so a `0.10` + delay actually produced about 5 actions/sec while the UI promised 10. `Precise` pacing + (the new default) zeroes that pause; `Legacy V10.1` pacing restores the old behaviour + exactly if you were relying on it. The readiness panel reports the real rate. +- **Stop always works.** The click run, sequence run and playback each own their stop + signal. Previously one finishing worker cleared the shared flag and could restart a run + the user had already stopped. The Stop button is now enabled during sequences and playback, + and the stop hotkey works in all three. +- **Inert stop hotkeys are caught.** A hotkey `keyboard` cannot poll (for example `a, b`, or + an unmapped key name) is now rejected at validation time instead of being reported green + and silently never firing. +- **Countdown no longer eats the runtime cap.** A 5s countdown with a 3s cap used to produce + a zero-action run. The cap now starts when the run does. +- **Themes no longer corrupt.** Switching Dark to Light left the UI dark, and a further + switch produced white text on a light page. Widgets now remember their palette role. +- **Crash-safety.** The run loop, playback loop and UI pump all restore global state and keep + running after an error, instead of wedging the app until restart. +- **The emergency hotkey saves your work.** `ctrl+shift+k` stops everything, persists the + workspace, then exits, instead of calling `os._exit(0)` and discarding unsaved state. +- **Files are written atomically.** Saving a recording, sequence or profile export no longer + truncates the previous good file if the write is interrupted. +- **22% smaller, 10x faster to import.** ~1,760 lines of dead and shadowed code removed, and + four unused packages (`gspread`, `oauth2client`, `colormap`, `pywin32`) dropped. `import + AutoClicker` went from ~15s to ~1.4s. + +## Existing highlights (all retained) + +- V10.1 Safety Guard with optional PyAutoGUI corner fail-safe control, max action caps, run validation, and live run intelligence. +- V10.1 command-strip safety controls with dry-run, fail-safe, validation, health, session report, and state-folder shortcuts. +- V10.1 Support Hub in the right rail for health checks, session reports, state backups, and opening the app-data folder. +- V10.1 run readiness checklist in Live Review with target, output, fail-safe, stop-boundary, pace, and stop-hotkey checks. +- V10.1 Safety Guard presets for Simulation, Guarded Live, and Manual Stop Live modes. +- V10.1 profile state in Live Review shows saved, modified, new, or missing profile state for the current controls. +- V10.1 support bundle workflow packages health, session, state summary, and known app-state backup files for troubleshooting. +- V10.1 dry-run mode for simulating click runs, sequence runs, and recording playback without sending mouse clicks. +- V10.1 session report export with recent activity, run summaries, active profile settings, and state-file paths. +- V10.1 profile/workspace persistence in the user app-data directory with migration from legacy local files. +- V10.1 profile import preview, profile JSON validator, and state backup manifests. +- V10 scroll-first tool windows for Photo Clicker, Colour Clicker, Recording Studio, and Sequence Builder. +- Photo presets, click offsets, settle delay, cursor-focused region tools, and match chime support in Photo Clicker. +- Colour presets, recent swatches, max-click caps, cursor-focused scan regions, and safer pipette hotkey cleanup in Colour Clicker. +- Recording Studio supports duplicate, move, reverse, offset, and direct handoff into the sequence workflow. +- Sequence Builder supports selected-step editing, reverse order, loop counts, countdowns, and running from the selected step. +- V9 accordion-style control center with dropdown sections so the main page starts compact. +- Quick action strip for start, stop, capture, preset application, and section reveal/hide control. +- Innovation Lab presets including `Balanced`, `Precision`, `Burst Sprint`, and `Human Mimic`. +- Optional micro-pause rhythm controls for long sessions. +- Scrollable control center with vertical and horizontal scrollbars. +- Window Studio controls for theme switching, opacity, UI scale, size presets, fullscreen, and tray-close behaviour. +- Saved profile import/export from JSON, workspace persistence, runtime caps, and delay variance. +- Health Check window, session activity feed, tray restore, and graceful dependency handling. + +## Full app quick start + +1. Install Python 3.10+ on Windows. +2. Install dependencies: + +```bash +pip install -r requirements.txt +``` + +3. Launch the main app: + +```bash +python AutoClicker.py +``` + +## Lite app quick start + +```bash +python lite-version.py +``` + +The lite app supports: + +- current-cursor capture (via a global hotkey, so it no longer captures its own button) +- infinite or burst runs +- configurable stop hotkey +- left, right, middle, and double-left click modes + +## Main app workflow + +1. Capture or enter a target X/Y coordinate. +2. Choose an action type, delay (or target actions/sec), optional delay variance, jitter, and countdown. +3. Fill in any action parameters the chosen action needs — the Quick Start panel tells you which. +4. Pick either `Infinite` or `Burst Count`. +5. Set a stop hotkey for any infinite run. +6. Press `Start` (or `F5`). + +Optional tools inside the full app: + +- Coordinate finder, coordinate sequence builder, Recording Studio +- Health Check, Run History, Safety Guard and plan validation +- Run readiness checklist, safety presets, profile state review +- Support bundle creation, dry-run simulation, session report export +- Photo clicker, colour clicker, recording and playback, saved profiles + +## Generated local files + +Stored under the user app-data directory, usually `%APPDATA%\AutoClicker`: + +- `autoclicker_profiles.json` — saved clicker profiles. +- `autoclicker_workspace.json` — restored workspace state. +- `autoclicker_runs.log` — one JSON line per finished run, rotated at 1 MB to `.log.1`. + +If older copies exist beside the script, the app copies them forward on first launch. The +legacy local filenames remain ignored by git. + +## Command line + +```bash +python AutoClicker.py --help +python AutoClicker.py --version +``` + +| Command | What it does | +|---|---| +| `health [--strict] [--json]` | dependency, version and state-file report; `--strict` exits 1 on a missing required package | +| `doctor` | health plus the exact `pip install` line that fixes it | +| `state-summary [--json]` | summarise the saved profile and workspace files | +| `backup-state [DIR] [--dry-run] [--json]` | copy app-state files into a timestamped folder | +| `support-bundle [DIR] [--json]` | write a full troubleshooting bundle (previously GUI-only) | +| `validate-recording PATH...` | validate one or more recording files | +| `validate-sequence PATH...` | validate one or more sequence files | +| `validate-profiles PATH...` | validate one or more profile files | +| `profile-preview PATH... [--show-values]` | preview importing profiles over the saved set | +| `profiles [NAME] [--json]` | list saved profiles, or show one | +| `readiness NAME [--json]` | run the pre-flight checklist against a saved profile, no display needed | +| `schema {recording,sequence,profile,action}` | print the accepted file/action shapes | +| `history [--limit N] [--json]` | summarise the persistent run log | +| `gui` | open the Control Center (also the default with no arguments) | + +Every command accepts `--json`, which emits a stable envelope: +`{schema_version, app_version, command, ok, generated_at, data, errors}`. + +**Exit codes:** `0` success, `1` check or validation failure, `2` usage error, `3` internal error. + +All V10.1 flags (`--health-check`, `--health-json`, `--state-summary`, `--state-json`, +`--backup-state`, `--validate-recording`, `--validate-sequence`, `--validate-profiles`, +`--profile-import-preview`) still work and are translated to the new subcommands. Two +differences, both deliberate: an unrecognised flag is now a usage error instead of silently +opening the GUI, and stacking two legacy commands reports the extra one instead of dropping +it behind a success exit code. + +## Dependencies + +Required — the app cannot send input without these: + +- `pyautogui` +- `keyboard` + +Optional — each unlocks a feature and degrades gracefully when absent: + +- `pystray` — close-to-tray and the tray menu +- `Pillow` — Photo Clicker previews and screen sampling +- `numpy` — fast Colour Clicker region scanning +- `opencv-python` — Photo Clicker confidence matching +- `win10toast` — Windows toast notifications + +## Dependency troubleshooting + +Ask the app what is missing: + +```bash +python AutoClicker.py doctor +``` + +It prints the exact install command for anything absent. Or reinstall everything: + +```bash +pip install -r requirements.txt +``` + +## Running the tests + +```bash +python -m unittest discover -s tests -t . -v +``` + +`pytest` also works from the repository root. CI runs the suite on Windows and Linux across +Python 3.10 and 3.12; the tests need no third-party package. + +## Building a release + +```bash +pip install -r requirements-build.txt +python packaging/build_release.py +``` + +The build reads its version straight from `APP_VERSION` in `AutoClicker.py`, checks its +inputs before deleting anything, and verifies the artifacts it produced. diff --git a/Builds/V11.0/Executable/favicon.ico b/Builds/V11.0/Executable/favicon.ico new file mode 100644 index 0000000..1f6a861 Binary files /dev/null and b/Builds/V11.0/Executable/favicon.ico differ diff --git a/Builds/V11.0/Executable/requirements.txt b/Builds/V11.0/Executable/requirements.txt new file mode 100644 index 0000000..04917a7 --- /dev/null +++ b/Builds/V11.0/Executable/requirements.txt @@ -0,0 +1,15 @@ +# Required: the app cannot send input without these. +pyautogui>=0.9.54 +keyboard>=0.13.5 + +# Optional: each unlocks a feature and degrades gracefully when absent. +# `python AutoClicker.py doctor` prints exactly which of these you are missing. +pystray>=0.19.5 # close-to-tray and the tray menu +Pillow>=10.0.0 # Photo Clicker previews and screen sampling +numpy>=1.24.0 # fast Colour Clicker region scanning +opencv-python>=4.8.0 # Photo Clicker confidence matching (pyautogui needs it for confidence=) +win10toast>=0.9; sys_platform == "win32" # Windows toast notifications + +# Removed in V11.0: gspread, oauth2client, colormap and pywin32 were installed but never +# referenced by any code path. Dropping them cut `import AutoClicker` from ~15s to ~1.4s +# and removed the last tie to the retired Google Sheets feedback integration. diff --git a/Builds/V11.0/Installer/AutoClickerInstaller.exe b/Builds/V11.0/Installer/AutoClickerInstaller.exe new file mode 100644 index 0000000..c630fd1 Binary files /dev/null and b/Builds/V11.0/Installer/AutoClickerInstaller.exe differ diff --git a/Builds/V6.0/Executable/creds.json b/Builds/V6.0/Executable/creds.json deleted file mode 100644 index 2471897..0000000 --- a/Builds/V6.0/Executable/creds.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "type": "service_account", - "project_id": "test-sheet-api-234320", - "private_key_id": "48452743358cc126854b8b50d6ba531df867f728", - "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDRVXAU9pHssVNF\nEkjyCDHrtoL7QANqCscxfpAQfstHomvHAE42a/hfCqVCaIl0W1mWo6zD54n2nxGL\n1r52y35KmBiM6zL7H4YBUA/Es6RMNR/Ea9kBxJnrwu86dQi2G1j83Z+7UwcIdTct\nEWoeFfXMJUB1e1EqxhucehYpV2vUWaLPLVS3MKBQi4OMReOIKcJmmxCCzsL8LdxS\nA5EG6xtExrrwVNBx+ibEJfOxTmPkOlVIHnnHMHCW9L9m5OgQ9yXznY09cr4EfaoV\nrzM60P1cTz6zYsHtZSc8FnmqXatT+mZFEKgicB3HJCdzxlL+7MYvlpJjIigyPpr+\nGbXzcWnLAgMBAAECggEAWRRa+Ei+6zLAhR2VVg1/tNc7YNCqQ0dn4ZuCkcprFcY7\nx/jMz6LdlBAzKBHVIGM5Of3XqJwQnRauwEu3TFUnHnYfyGPUE4IkI83ej+2EJnva\n01qzg2mWuHmBFOa1uMzlbuGetffMf+BlhPOqrTfaG6/kgLnj3ehk2o8nfBs8AyYG\nh0Ds92xjY4OAfLYjf2DcNREXxnIHATOnfKB0P0D+qXq1BrrlHNY/d+7/th2XiUAe\nq/bgEl4mzL1nVzv8fMytuZEXPktpHg2rV+/iKDvgYharBWjtGriTm7LAowP/sNpg\nO1kQPtUI53pWA+wF6/O/mKRgWxyUvfavr7amlEB47QKBgQDyBr6MeLkO+XaN91Ud\nOenbPyuSfXtOHYv7aapYrsFslixyJ1FdfB+augiQQTq7TlVfi6f2zjNRcPScN6Gr\nDSn7X2/WBimeKHWiNSk+8HvumeI3i3hGdkKrFqsca8HA3AEocMrudi6ZaDpnAh+f\nSShK1tAz1giSAC6dIiTNwgNKHwKBgQDda3uB51CBqHVWqUpFn7o1aMDiChB751CJ\nve+C2zlnIiP5tbI1SCJ3BFm1qB+dAwEG09VSIZ6O9ZFu2WeVCcaUcoKbzqqeqgmk\niCoteh2I8BDBaPiN35JhAmIDDey/dbqG6eHwjGC6v90jqEEqVfXfBunD4f8OEa7I\n7oAllM+C1QKBgQDhTuoAtzn/+LgnPUMdLBbKpioX+3zYoaT47ctJN5aVUdAajQOv\n9acuUVU7hZ35VNkhU0+P8THQ9UiiTR/RrzWP766bHa2MsLmhwb+d+h5FZzgy4UGc\nxucVgsrUcHQ06Bqj3SzthVOoOGF2F4H8tQwvb37vYqcj08uROIHuklihnQKBgQDV\n1cOFhRte3I3Vj9DGxKdUhUIiCo442wAQxnnU6lrZm0MbGbptcqi2QGFS6nb9b7VC\nHrpZg1ZZwhcd8p/yc2pwWtaAGmr0qEp/t3DkUpbLdOz5nKLOgSggv+hgAsatcMkf\nGii14xxXvouWsBufAz1ckpSYNF3Z/IHMU2k24iw1sQKBgGMwB1h1VVry03rye+fC\nzhkIQNkH4CesPq+Exkyoevkt7YVnnxQ0dN7Ho370ymXVziaj3Qb1UngKxLOKRE+q\ntA8XBS0bnK2cMckcCfWBbdlpaRGjU26pmYsThBhE99QG8Fnlhd2ptLSZNmlAzWJc\n0jL3L0A0bzfl2/8NU87hfNqT\n-----END PRIVATE KEY-----\n", - "client_email": "kai-60@test-sheet-api-234320.iam.gserviceaccount.com", - "client_id": "110138069390492700372", - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": "https://oauth2.googleapis.com/token", - "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", - "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/kai-60%40test-sheet-api-234320.iam.gserviceaccount.com" -} diff --git a/Builds/V7.0/Executable/creds.json b/Builds/V7.0/Executable/creds.json deleted file mode 100644 index 2471897..0000000 --- a/Builds/V7.0/Executable/creds.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "type": "service_account", - "project_id": "test-sheet-api-234320", - "private_key_id": "48452743358cc126854b8b50d6ba531df867f728", - "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDRVXAU9pHssVNF\nEkjyCDHrtoL7QANqCscxfpAQfstHomvHAE42a/hfCqVCaIl0W1mWo6zD54n2nxGL\n1r52y35KmBiM6zL7H4YBUA/Es6RMNR/Ea9kBxJnrwu86dQi2G1j83Z+7UwcIdTct\nEWoeFfXMJUB1e1EqxhucehYpV2vUWaLPLVS3MKBQi4OMReOIKcJmmxCCzsL8LdxS\nA5EG6xtExrrwVNBx+ibEJfOxTmPkOlVIHnnHMHCW9L9m5OgQ9yXznY09cr4EfaoV\nrzM60P1cTz6zYsHtZSc8FnmqXatT+mZFEKgicB3HJCdzxlL+7MYvlpJjIigyPpr+\nGbXzcWnLAgMBAAECggEAWRRa+Ei+6zLAhR2VVg1/tNc7YNCqQ0dn4ZuCkcprFcY7\nx/jMz6LdlBAzKBHVIGM5Of3XqJwQnRauwEu3TFUnHnYfyGPUE4IkI83ej+2EJnva\n01qzg2mWuHmBFOa1uMzlbuGetffMf+BlhPOqrTfaG6/kgLnj3ehk2o8nfBs8AyYG\nh0Ds92xjY4OAfLYjf2DcNREXxnIHATOnfKB0P0D+qXq1BrrlHNY/d+7/th2XiUAe\nq/bgEl4mzL1nVzv8fMytuZEXPktpHg2rV+/iKDvgYharBWjtGriTm7LAowP/sNpg\nO1kQPtUI53pWA+wF6/O/mKRgWxyUvfavr7amlEB47QKBgQDyBr6MeLkO+XaN91Ud\nOenbPyuSfXtOHYv7aapYrsFslixyJ1FdfB+augiQQTq7TlVfi6f2zjNRcPScN6Gr\nDSn7X2/WBimeKHWiNSk+8HvumeI3i3hGdkKrFqsca8HA3AEocMrudi6ZaDpnAh+f\nSShK1tAz1giSAC6dIiTNwgNKHwKBgQDda3uB51CBqHVWqUpFn7o1aMDiChB751CJ\nve+C2zlnIiP5tbI1SCJ3BFm1qB+dAwEG09VSIZ6O9ZFu2WeVCcaUcoKbzqqeqgmk\niCoteh2I8BDBaPiN35JhAmIDDey/dbqG6eHwjGC6v90jqEEqVfXfBunD4f8OEa7I\n7oAllM+C1QKBgQDhTuoAtzn/+LgnPUMdLBbKpioX+3zYoaT47ctJN5aVUdAajQOv\n9acuUVU7hZ35VNkhU0+P8THQ9UiiTR/RrzWP766bHa2MsLmhwb+d+h5FZzgy4UGc\nxucVgsrUcHQ06Bqj3SzthVOoOGF2F4H8tQwvb37vYqcj08uROIHuklihnQKBgQDV\n1cOFhRte3I3Vj9DGxKdUhUIiCo442wAQxnnU6lrZm0MbGbptcqi2QGFS6nb9b7VC\nHrpZg1ZZwhcd8p/yc2pwWtaAGmr0qEp/t3DkUpbLdOz5nKLOgSggv+hgAsatcMkf\nGii14xxXvouWsBufAz1ckpSYNF3Z/IHMU2k24iw1sQKBgGMwB1h1VVry03rye+fC\nzhkIQNkH4CesPq+Exkyoevkt7YVnnxQ0dN7Ho370ymXVziaj3Qb1UngKxLOKRE+q\ntA8XBS0bnK2cMckcCfWBbdlpaRGjU26pmYsThBhE99QG8Fnlhd2ptLSZNmlAzWJc\n0jL3L0A0bzfl2/8NU87hfNqT\n-----END PRIVATE KEY-----\n", - "client_email": "kai-60@test-sheet-api-234320.iam.gserviceaccount.com", - "client_id": "110138069390492700372", - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": "https://oauth2.googleapis.com/token", - "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", - "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/kai-60%40test-sheet-api-234320.iam.gserviceaccount.com" -} diff --git a/Builds/V7.0/SmokeInstall/creds.json b/Builds/V7.0/SmokeInstall/creds.json deleted file mode 100644 index 2471897..0000000 --- a/Builds/V7.0/SmokeInstall/creds.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "type": "service_account", - "project_id": "test-sheet-api-234320", - "private_key_id": "48452743358cc126854b8b50d6ba531df867f728", - "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDRVXAU9pHssVNF\nEkjyCDHrtoL7QANqCscxfpAQfstHomvHAE42a/hfCqVCaIl0W1mWo6zD54n2nxGL\n1r52y35KmBiM6zL7H4YBUA/Es6RMNR/Ea9kBxJnrwu86dQi2G1j83Z+7UwcIdTct\nEWoeFfXMJUB1e1EqxhucehYpV2vUWaLPLVS3MKBQi4OMReOIKcJmmxCCzsL8LdxS\nA5EG6xtExrrwVNBx+ibEJfOxTmPkOlVIHnnHMHCW9L9m5OgQ9yXznY09cr4EfaoV\nrzM60P1cTz6zYsHtZSc8FnmqXatT+mZFEKgicB3HJCdzxlL+7MYvlpJjIigyPpr+\nGbXzcWnLAgMBAAECggEAWRRa+Ei+6zLAhR2VVg1/tNc7YNCqQ0dn4ZuCkcprFcY7\nx/jMz6LdlBAzKBHVIGM5Of3XqJwQnRauwEu3TFUnHnYfyGPUE4IkI83ej+2EJnva\n01qzg2mWuHmBFOa1uMzlbuGetffMf+BlhPOqrTfaG6/kgLnj3ehk2o8nfBs8AyYG\nh0Ds92xjY4OAfLYjf2DcNREXxnIHATOnfKB0P0D+qXq1BrrlHNY/d+7/th2XiUAe\nq/bgEl4mzL1nVzv8fMytuZEXPktpHg2rV+/iKDvgYharBWjtGriTm7LAowP/sNpg\nO1kQPtUI53pWA+wF6/O/mKRgWxyUvfavr7amlEB47QKBgQDyBr6MeLkO+XaN91Ud\nOenbPyuSfXtOHYv7aapYrsFslixyJ1FdfB+augiQQTq7TlVfi6f2zjNRcPScN6Gr\nDSn7X2/WBimeKHWiNSk+8HvumeI3i3hGdkKrFqsca8HA3AEocMrudi6ZaDpnAh+f\nSShK1tAz1giSAC6dIiTNwgNKHwKBgQDda3uB51CBqHVWqUpFn7o1aMDiChB751CJ\nve+C2zlnIiP5tbI1SCJ3BFm1qB+dAwEG09VSIZ6O9ZFu2WeVCcaUcoKbzqqeqgmk\niCoteh2I8BDBaPiN35JhAmIDDey/dbqG6eHwjGC6v90jqEEqVfXfBunD4f8OEa7I\n7oAllM+C1QKBgQDhTuoAtzn/+LgnPUMdLBbKpioX+3zYoaT47ctJN5aVUdAajQOv\n9acuUVU7hZ35VNkhU0+P8THQ9UiiTR/RrzWP766bHa2MsLmhwb+d+h5FZzgy4UGc\nxucVgsrUcHQ06Bqj3SzthVOoOGF2F4H8tQwvb37vYqcj08uROIHuklihnQKBgQDV\n1cOFhRte3I3Vj9DGxKdUhUIiCo442wAQxnnU6lrZm0MbGbptcqi2QGFS6nb9b7VC\nHrpZg1ZZwhcd8p/yc2pwWtaAGmr0qEp/t3DkUpbLdOz5nKLOgSggv+hgAsatcMkf\nGii14xxXvouWsBufAz1ckpSYNF3Z/IHMU2k24iw1sQKBgGMwB1h1VVry03rye+fC\nzhkIQNkH4CesPq+Exkyoevkt7YVnnxQ0dN7Ho370ymXVziaj3Qb1UngKxLOKRE+q\ntA8XBS0bnK2cMckcCfWBbdlpaRGjU26pmYsThBhE99QG8Fnlhd2ptLSZNmlAzWJc\n0jL3L0A0bzfl2/8NU87hfNqT\n-----END PRIVATE KEY-----\n", - "client_email": "kai-60@test-sheet-api-234320.iam.gserviceaccount.com", - "client_id": "110138069390492700372", - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": "https://oauth2.googleapis.com/token", - "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", - "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/kai-60%40test-sheet-api-234320.iam.gserviceaccount.com" -} diff --git a/Builds/V8.0/Executable/creds.json b/Builds/V8.0/Executable/creds.json deleted file mode 100644 index 2471897..0000000 --- a/Builds/V8.0/Executable/creds.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "type": "service_account", - "project_id": "test-sheet-api-234320", - "private_key_id": "48452743358cc126854b8b50d6ba531df867f728", - "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDRVXAU9pHssVNF\nEkjyCDHrtoL7QANqCscxfpAQfstHomvHAE42a/hfCqVCaIl0W1mWo6zD54n2nxGL\n1r52y35KmBiM6zL7H4YBUA/Es6RMNR/Ea9kBxJnrwu86dQi2G1j83Z+7UwcIdTct\nEWoeFfXMJUB1e1EqxhucehYpV2vUWaLPLVS3MKBQi4OMReOIKcJmmxCCzsL8LdxS\nA5EG6xtExrrwVNBx+ibEJfOxTmPkOlVIHnnHMHCW9L9m5OgQ9yXznY09cr4EfaoV\nrzM60P1cTz6zYsHtZSc8FnmqXatT+mZFEKgicB3HJCdzxlL+7MYvlpJjIigyPpr+\nGbXzcWnLAgMBAAECggEAWRRa+Ei+6zLAhR2VVg1/tNc7YNCqQ0dn4ZuCkcprFcY7\nx/jMz6LdlBAzKBHVIGM5Of3XqJwQnRauwEu3TFUnHnYfyGPUE4IkI83ej+2EJnva\n01qzg2mWuHmBFOa1uMzlbuGetffMf+BlhPOqrTfaG6/kgLnj3ehk2o8nfBs8AyYG\nh0Ds92xjY4OAfLYjf2DcNREXxnIHATOnfKB0P0D+qXq1BrrlHNY/d+7/th2XiUAe\nq/bgEl4mzL1nVzv8fMytuZEXPktpHg2rV+/iKDvgYharBWjtGriTm7LAowP/sNpg\nO1kQPtUI53pWA+wF6/O/mKRgWxyUvfavr7amlEB47QKBgQDyBr6MeLkO+XaN91Ud\nOenbPyuSfXtOHYv7aapYrsFslixyJ1FdfB+augiQQTq7TlVfi6f2zjNRcPScN6Gr\nDSn7X2/WBimeKHWiNSk+8HvumeI3i3hGdkKrFqsca8HA3AEocMrudi6ZaDpnAh+f\nSShK1tAz1giSAC6dIiTNwgNKHwKBgQDda3uB51CBqHVWqUpFn7o1aMDiChB751CJ\nve+C2zlnIiP5tbI1SCJ3BFm1qB+dAwEG09VSIZ6O9ZFu2WeVCcaUcoKbzqqeqgmk\niCoteh2I8BDBaPiN35JhAmIDDey/dbqG6eHwjGC6v90jqEEqVfXfBunD4f8OEa7I\n7oAllM+C1QKBgQDhTuoAtzn/+LgnPUMdLBbKpioX+3zYoaT47ctJN5aVUdAajQOv\n9acuUVU7hZ35VNkhU0+P8THQ9UiiTR/RrzWP766bHa2MsLmhwb+d+h5FZzgy4UGc\nxucVgsrUcHQ06Bqj3SzthVOoOGF2F4H8tQwvb37vYqcj08uROIHuklihnQKBgQDV\n1cOFhRte3I3Vj9DGxKdUhUIiCo442wAQxnnU6lrZm0MbGbptcqi2QGFS6nb9b7VC\nHrpZg1ZZwhcd8p/yc2pwWtaAGmr0qEp/t3DkUpbLdOz5nKLOgSggv+hgAsatcMkf\nGii14xxXvouWsBufAz1ckpSYNF3Z/IHMU2k24iw1sQKBgGMwB1h1VVry03rye+fC\nzhkIQNkH4CesPq+Exkyoevkt7YVnnxQ0dN7Ho370ymXVziaj3Qb1UngKxLOKRE+q\ntA8XBS0bnK2cMckcCfWBbdlpaRGjU26pmYsThBhE99QG8Fnlhd2ptLSZNmlAzWJc\n0jL3L0A0bzfl2/8NU87hfNqT\n-----END PRIVATE KEY-----\n", - "client_email": "kai-60@test-sheet-api-234320.iam.gserviceaccount.com", - "client_id": "110138069390492700372", - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": "https://oauth2.googleapis.com/token", - "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", - "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/kai-60%40test-sheet-api-234320.iam.gserviceaccount.com" -} diff --git a/Builds/V9.0/Executable/creds.json b/Builds/V9.0/Executable/creds.json deleted file mode 100644 index 2471897..0000000 --- a/Builds/V9.0/Executable/creds.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "type": "service_account", - "project_id": "test-sheet-api-234320", - "private_key_id": "48452743358cc126854b8b50d6ba531df867f728", - "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDRVXAU9pHssVNF\nEkjyCDHrtoL7QANqCscxfpAQfstHomvHAE42a/hfCqVCaIl0W1mWo6zD54n2nxGL\n1r52y35KmBiM6zL7H4YBUA/Es6RMNR/Ea9kBxJnrwu86dQi2G1j83Z+7UwcIdTct\nEWoeFfXMJUB1e1EqxhucehYpV2vUWaLPLVS3MKBQi4OMReOIKcJmmxCCzsL8LdxS\nA5EG6xtExrrwVNBx+ibEJfOxTmPkOlVIHnnHMHCW9L9m5OgQ9yXznY09cr4EfaoV\nrzM60P1cTz6zYsHtZSc8FnmqXatT+mZFEKgicB3HJCdzxlL+7MYvlpJjIigyPpr+\nGbXzcWnLAgMBAAECggEAWRRa+Ei+6zLAhR2VVg1/tNc7YNCqQ0dn4ZuCkcprFcY7\nx/jMz6LdlBAzKBHVIGM5Of3XqJwQnRauwEu3TFUnHnYfyGPUE4IkI83ej+2EJnva\n01qzg2mWuHmBFOa1uMzlbuGetffMf+BlhPOqrTfaG6/kgLnj3ehk2o8nfBs8AyYG\nh0Ds92xjY4OAfLYjf2DcNREXxnIHATOnfKB0P0D+qXq1BrrlHNY/d+7/th2XiUAe\nq/bgEl4mzL1nVzv8fMytuZEXPktpHg2rV+/iKDvgYharBWjtGriTm7LAowP/sNpg\nO1kQPtUI53pWA+wF6/O/mKRgWxyUvfavr7amlEB47QKBgQDyBr6MeLkO+XaN91Ud\nOenbPyuSfXtOHYv7aapYrsFslixyJ1FdfB+augiQQTq7TlVfi6f2zjNRcPScN6Gr\nDSn7X2/WBimeKHWiNSk+8HvumeI3i3hGdkKrFqsca8HA3AEocMrudi6ZaDpnAh+f\nSShK1tAz1giSAC6dIiTNwgNKHwKBgQDda3uB51CBqHVWqUpFn7o1aMDiChB751CJ\nve+C2zlnIiP5tbI1SCJ3BFm1qB+dAwEG09VSIZ6O9ZFu2WeVCcaUcoKbzqqeqgmk\niCoteh2I8BDBaPiN35JhAmIDDey/dbqG6eHwjGC6v90jqEEqVfXfBunD4f8OEa7I\n7oAllM+C1QKBgQDhTuoAtzn/+LgnPUMdLBbKpioX+3zYoaT47ctJN5aVUdAajQOv\n9acuUVU7hZ35VNkhU0+P8THQ9UiiTR/RrzWP766bHa2MsLmhwb+d+h5FZzgy4UGc\nxucVgsrUcHQ06Bqj3SzthVOoOGF2F4H8tQwvb37vYqcj08uROIHuklihnQKBgQDV\n1cOFhRte3I3Vj9DGxKdUhUIiCo442wAQxnnU6lrZm0MbGbptcqi2QGFS6nb9b7VC\nHrpZg1ZZwhcd8p/yc2pwWtaAGmr0qEp/t3DkUpbLdOz5nKLOgSggv+hgAsatcMkf\nGii14xxXvouWsBufAz1ckpSYNF3Z/IHMU2k24iw1sQKBgGMwB1h1VVry03rye+fC\nzhkIQNkH4CesPq+Exkyoevkt7YVnnxQ0dN7Ho370ymXVziaj3Qb1UngKxLOKRE+q\ntA8XBS0bnK2cMckcCfWBbdlpaRGjU26pmYsThBhE99QG8Fnlhd2ptLSZNmlAzWJc\n0jL3L0A0bzfl2/8NU87hfNqT\n-----END PRIVATE KEY-----\n", - "client_email": "kai-60@test-sheet-api-234320.iam.gserviceaccount.com", - "client_id": "110138069390492700372", - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": "https://oauth2.googleapis.com/token", - "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", - "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/kai-60%40test-sheet-api-234320.iam.gserviceaccount.com" -} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b13d510..b048931 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -94,52 +94,58 @@ refactor/cleanup-cli-options ## Installing Dependencies -Install the project dependencies before making changes: +This is a Python 3.10+ project. Install the runtime dependencies before making changes: ```bash -npm install +pip install -r requirements.txt ``` -If the project uses a different package manager, use the appropriate command: +Only `pyautogui` and `keyboard` are required; everything else is optional and the app +degrades gracefully without it. To check what you are missing: ```bash -yarn install -``` - -or - -```bash -pnpm install +python AutoClicker.py doctor ``` --- ## Running the Project Locally -Use the relevant development command: +Launch the full Control Center: ```bash -npm run dev +python AutoClicker.py ``` -or, if the project does not have a development server: +Or the lite launcher: ```bash -npm start +python lite-version.py ``` -Check the `package.json` scripts for the exact commands available. +The headless commands need no display, which makes them the fastest way to sanity-check a +change: + +```bash +python AutoClicker.py --help +``` --- ## Running Tests -Before submitting a pull request, run the test suite: +Before submitting a pull request, run the test suite from the repository root: ```bash -npm test +python -m unittest discover -s tests -t . -v ``` +`pytest` also works. The tests need no third-party package, so they run anywhere. + +New logic belongs in a module-level `_helper()` function wherever possible: the GUI class +lives inside `MAINWINDOW_REDESIGNED()` and is not importable, so only module-level code can +be unit-tested. + If you add new functionality, please add tests when possible. Useful test contributions include: @@ -167,13 +173,13 @@ This project prefers: Run linting with: ```bash -npm run lint +python -m compileall -q AutoClicker.py lite-version.py packaging/ tests/ ``` If formatting is available, run: ```bash -npm run format +python -m ruff check . ``` --- @@ -245,7 +251,7 @@ What happened instead? - OS: - Browser: -- Node version: +- Python version: - Transcriptase version/commit: ## Extra Context diff --git a/README.md b/README.md index 0ee86ef..f851f9b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # AutoClicker -Windows desktop auto-clicker utilities built in Python with Tkinter and PyAutoGUI. +Windows desktop input-automation utilities built in Python with Tkinter and PyAutoGUI. ## Current apps @@ -9,7 +9,60 @@ Windows desktop auto-clicker utilities built in Python with Tkinter and PyAutoGU - `lite-version.py` A smaller launcher for fast coordinate clicking with burst mode and safer stop handling. -## Newer full-app highlights +## What is new in V11.0 + +V11.0 is additive: every V10.1 feature is still present. The headline changes are a wider +action vocabulary, real pause/resume, honest timing, and a proper command line. + +### New capabilities + +- **Beyond clicking.** The action list now covers `Key Press`, `Key Hold`, `Type Text`, + `Scroll Up`/`Scroll Down`, `Click And Hold`, `Drag To`, `Move Only` and `Triple Left Click` + alongside all six original click types. Sequences accept the new actions too. +- **Pause and resume.** A Pause button sits beside Start/Stop and holds a run after the + current action without losing its position. Works for click runs, sequences and playback. +- **Hotkey Center.** Optional system-wide hotkeys for start, pause and capture-cursor, so the + app is drivable while minimised over the target window. In-window: `F5` start, `F6` pause, + `Esc` stop. +- **Clicks-per-second targeting.** Enter a target rate and the app derives the delay, or read + the rate back from an existing delay. Live instant/average/peak rate shows during a run. +- **Scheduled start.** Give a `HH:MM` wall-clock time and the run arms itself until then. +- **Round-robin targets.** Cycle a recorded point list instead of hammering one coordinate. +- **Run history.** Every finished run is appended to a rotating log under the app-data folder, + with a viewer (`Run History`), lifetime totals that survive restarts, and JSON export. +- **New themes.** `Midnight`, plus `System` which follows the OS light/dark preference. +- **`Feather Touch` behaviour preset** for fragile or laggy UIs. +- **Real CLI.** `--help`, `--version`, subcommands, a stable JSON envelope, and a documented + exit-code contract (see below). + +### Fixes worth knowing about + +- **Timing is now honest.** PyAutoGUI adds a hidden 0.1s pause to every call, so a `0.10` + delay actually produced about 5 actions/sec while the UI promised 10. `Precise` pacing + (the new default) zeroes that pause; `Legacy V10.1` pacing restores the old behaviour + exactly if you were relying on it. The readiness panel reports the real rate. +- **Stop always works.** The click run, sequence run and playback each own their stop + signal. Previously one finishing worker cleared the shared flag and could restart a run + the user had already stopped. The Stop button is now enabled during sequences and playback, + and the stop hotkey works in all three. +- **Inert stop hotkeys are caught.** A hotkey `keyboard` cannot poll (for example `a, b`, or + an unmapped key name) is now rejected at validation time instead of being reported green + and silently never firing. +- **Countdown no longer eats the runtime cap.** A 5s countdown with a 3s cap used to produce + a zero-action run. The cap now starts when the run does. +- **Themes no longer corrupt.** Switching Dark to Light left the UI dark, and a further + switch produced white text on a light page. Widgets now remember their palette role. +- **Crash-safety.** The run loop, playback loop and UI pump all restore global state and keep + running after an error, instead of wedging the app until restart. +- **The emergency hotkey saves your work.** `ctrl+shift+k` stops everything, persists the + workspace, then exits, instead of calling `os._exit(0)` and discarding unsaved state. +- **Files are written atomically.** Saving a recording, sequence or profile export no longer + truncates the previous good file if the write is interrupted. +- **22% smaller, 10x faster to import.** ~1,760 lines of dead and shadowed code removed, and + four unused packages (`gspread`, `oauth2client`, `colormap`, `pywin32`) dropped. `import + AutoClicker` went from ~15s to ~1.4s. + +## Existing highlights (all retained) - V10.1 Safety Guard with optional PyAutoGUI corner fail-safe control, max action caps, run validation, and live run intelligence. - V10.1 command-strip safety controls with dry-run, fail-safe, validation, health, session report, and state-folder shortcuts. @@ -20,36 +73,21 @@ Windows desktop auto-clicker utilities built in Python with Tkinter and PyAutoGU - V10.1 support bundle workflow packages health, session, state summary, and known app-state backup files for troubleshooting. - V10.1 dry-run mode for simulating click runs, sequence runs, and recording playback without sending mouse clicks. - V10.1 session report export with recent activity, run summaries, active profile settings, and state-file paths. -- V10.1 Start button state now reflects active click runs, making duplicate start attempts visibly unavailable. -- V10.1 profile/workspace persistence moved to the user app-data directory with migration from legacy local files. -- V10.1 headless state summary, state backup, recording validator, and sequence validator commands. -- V10.1 profile import preview and profile JSON validator. -- V10.1 state backup manifests for support-friendly backup folders. -- V10.1 headless health check via `python AutoClicker.py --health-check` or `python AutoClicker.py --health-json` for support and packaging verification. -- V10.1 release packaging no longer bundles `creds.json`. -- V10 scroll-first tool windows for Photo Clicker, Colour Clicker, Recording Studio, and Sequence Builder so every control stays reachable on smaller screens. -- New photo presets, click offsets, settle delay, cursor-focused region tools, and match chime support in Photo Clicker. -- New colour presets, recent swatches, max-click caps, cursor-focused scan regions, and safer pipette hotkey cleanup in Colour Clicker. -- Recording Studio now supports duplicate, move, reverse, offset, and direct handoff into the sequence workflow. -- Sequence Builder now supports selected-step editing, reverse order, loop counts, countdowns, and running from the selected step. -- V9 accordion-style control center with dropdown sections so the main page starts compact instead of showing every option at once. +- V10.1 profile/workspace persistence in the user app-data directory with migration from legacy local files. +- V10.1 profile import preview, profile JSON validator, and state backup manifests. +- V10 scroll-first tool windows for Photo Clicker, Colour Clicker, Recording Studio, and Sequence Builder. +- Photo presets, click offsets, settle delay, cursor-focused region tools, and match chime support in Photo Clicker. +- Colour presets, recent swatches, max-click caps, cursor-focused scan regions, and safer pipette hotkey cleanup in Colour Clicker. +- Recording Studio supports duplicate, move, reverse, offset, and direct handoff into the sequence workflow. +- Sequence Builder supports selected-step editing, reverse order, loop counts, countdowns, and running from the selected step. +- V9 accordion-style control center with dropdown sections so the main page starts compact. - Quick action strip for start, stop, capture, preset application, and section reveal/hide control. - Innovation Lab presets including `Balanced`, `Precision`, `Burst Sprint`, and `Human Mimic`. -- Optional micro-pause rhythm controls for long sessions to break up repetitive runs. -- Scrollable control center with vertical and horizontal scrollbars for smaller screens and denser setups. -- New Window Studio controls for theme switching, opacity, UI scale, size presets, fullscreen, and tray-close behaviour. -- Window Settings now has a scrollable page with opacity presets, UI-scale presets, fit-to-screen, save-current-layout, reset-visuals, health, and state-folder shortcuts. -- Improved theme handling with Light, Dark, and Ocean modes across the main control center. -- Redesigned control center layout with clearer planner, tools, summary, and activity sections. -- Saved profile import/export from JSON. -- Workspace persistence for the last-used setup, recording points, and recent activity. -- Runtime cap support for auto-stopping long runs. -- Delay variance support for less rigid timing. -- Stronger sequence builder with validation, reordering, duplication, and recording import. -- Recording Studio for saving, loading, trimming, and reusing captured points. -- Health Check window for dependency visibility, session state, and local config files. -- Session activity feed for starts, stops, profile actions, and playback. -- Improved tray restore, dependency handling, and shutdown cleanup. +- Optional micro-pause rhythm controls for long sessions. +- Scrollable control center with vertical and horizontal scrollbars. +- Window Studio controls for theme switching, opacity, UI scale, size presets, fullscreen, and tray-close behaviour. +- Saved profile import/export from JSON, workspace persistence, runtime caps, and delay variance. +- Health Check window, session activity feed, tray restore, and graceful dependency handling. ## Full app quick start @@ -74,7 +112,7 @@ python lite-version.py The lite app supports: -- current-cursor capture +- current-cursor capture (via a global hotkey, so it no longer captures its own button) - infinite or burst runs - configurable stop hotkey - left, right, middle, and double-left click modes @@ -82,80 +120,111 @@ The lite app supports: ## Main app workflow 1. Capture or enter a target X/Y coordinate. -2. Choose click type, delay, optional delay variance, jitter, and countdown. -3. Pick either `Infinite` or `Burst Count`. -4. Set a stop hotkey for any infinite run. -5. Press `Start`. +2. Choose an action type, delay (or target actions/sec), optional delay variance, jitter, and countdown. +3. Fill in any action parameters the chosen action needs — the Quick Start panel tells you which. +4. Pick either `Infinite` or `Burst Count`. +5. Set a stop hotkey for any infinite run. +6. Press `Start` (or `F5`). Optional tools inside the full app: -- Coordinate finder -- Coordinate sequence builder -- Recording Studio -- Health Check -- Safety Guard and plan validation -- Run readiness checklist and safety presets -- Profile state review -- Support bundle creation -- Dry-run simulation -- Session report export -- Support Hub state backup and app-data folder shortcuts -- Photo clicker -- Colour clicker -- Recording and playback -- Saved profiles +- Coordinate finder, coordinate sequence builder, Recording Studio +- Health Check, Run History, Safety Guard and plan validation +- Run readiness checklist, safety presets, profile state review +- Support bundle creation, dry-run simulation, session report export +- Photo clicker, colour clicker, recording and playback, saved profiles ## Generated local files -- `autoclicker_profiles.json` - Saved clicker profiles from the full app. -- `autoclicker_workspace.json` - Restored workspace state for the full app. +Stored under the user app-data directory, usually `%APPDATA%\AutoClicker`: -New V10.1 runs store these files under the user app-data directory, usually `%APPDATA%\AutoClicker`. -If older copies exist beside the script, the app copies them forward on first launch. The legacy local filenames remain ignored by git. +- `autoclicker_profiles.json` — saved clicker profiles. +- `autoclicker_workspace.json` — restored workspace state. +- `autoclicker_runs.log` — one JSON line per finished run, rotated at 1 MB to `.log.1`. -## Headless support commands +If older copies exist beside the script, the app copies them forward on first launch. The +legacy local filenames remain ignored by git. + +## Command line ```bash -python AutoClicker.py --health-check -python AutoClicker.py --health-json -python AutoClicker.py --state-summary -python AutoClicker.py --state-json -python AutoClicker.py --backup-state -python AutoClicker.py --validate-profiles path/to/profiles.json -python AutoClicker.py --profile-import-preview path/to/profiles.json -python AutoClicker.py --validate-recording path/to/recording.json -python AutoClicker.py --validate-sequence path/to/sequence.json +python AutoClicker.py --help +python AutoClicker.py --version ``` -These print dependency, version, resource, state-file, backup, and validator information without opening the Tkinter UI. JSON forms are meant for scripts and support bundles. +| Command | What it does | +|---|---| +| `health [--strict] [--json]` | dependency, version and state-file report; `--strict` exits 1 on a missing required package | +| `doctor` | health plus the exact `pip install` line that fixes it | +| `state-summary [--json]` | summarise the saved profile and workspace files | +| `backup-state [DIR] [--dry-run] [--json]` | copy app-state files into a timestamped folder | +| `support-bundle [DIR] [--json]` | write a full troubleshooting bundle (previously GUI-only) | +| `validate-recording PATH...` | validate one or more recording files | +| `validate-sequence PATH...` | validate one or more sequence files | +| `validate-profiles PATH...` | validate one or more profile files | +| `profile-preview PATH... [--show-values]` | preview importing profiles over the saved set | +| `profiles [NAME] [--json]` | list saved profiles, or show one | +| `readiness NAME [--json]` | run the pre-flight checklist against a saved profile, no display needed | +| `schema {recording,sequence,profile,action}` | print the accepted file/action shapes | +| `history [--limit N] [--json]` | summarise the persistent run log | +| `gui` | open the Control Center (also the default with no arguments) | + +Every command accepts `--json`, which emits a stable envelope: +`{schema_version, app_version, command, ok, generated_at, data, errors}`. + +**Exit codes:** `0` success, `1` check or validation failure, `2` usage error, `3` internal error. + +All V10.1 flags (`--health-check`, `--health-json`, `--state-summary`, `--state-json`, +`--backup-state`, `--validate-recording`, `--validate-sequence`, `--validate-profiles`, +`--profile-import-preview`) still work and are translated to the new subcommands. Two +differences, both deliberate: an unrecognised flag is now a usage error instead of silently +opening the GUI, and stacking two legacy commands reports the extra one instead of dropping +it behind a success exit code. ## Dependencies +Required — the app cannot send input without these: + - `pyautogui` - `keyboard` -- `pystray` -- `Pillow` -- `pywin32` -- `numpy` -Some legacy optional features also use: +Optional — each unlocks a feature and degrades gracefully when absent: -- `gspread` -- `oauth2client` -- `win10toast` -- `colormap` +- `pystray` — close-to-tray and the tray menu +- `Pillow` — Photo Clicker previews and screen sampling +- `numpy` — fast Colour Clicker region scanning +- `opencv-python` — Photo Clicker confidence matching +- `win10toast` — Windows toast notifications ## Dependency troubleshooting -The project now fails more gracefully when a package is missing, but the core clickers still need: +Ask the app what is missing: -- `pyautogui` -- `keyboard` +```bash +python AutoClicker.py doctor +``` -If the full app or lite app reports a missing dependency, install the requirements again: +It prints the exact install command for anything absent. Or reinstall everything: ```bash pip install -r requirements.txt ``` + +## Running the tests + +```bash +python -m unittest discover -s tests -t . -v +``` + +`pytest` also works from the repository root. CI runs the suite on Windows and Linux across +Python 3.10 and 3.12; the tests need no third-party package. + +## Building a release + +```bash +pip install -r requirements-build.txt +python packaging/build_release.py +``` + +The build reads its version straight from `APP_VERSION` in `AutoClicker.py`, checks its +inputs before deleting anything, and verifies the artifacts it produced. diff --git a/SECURITY.md b/SECURITY.md index 8c487a2..ebd64f1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,18 +8,18 @@ We provide security updates for the following versions: | Version | Supported | | ------- | --------- | -| 5.1.x | ✅ | -| 5.0.x | ❌ | -| 4.0.x | ✅ | -| < 4.0 | ❌ | +| V11.0 | ✅ | +| V10.1 | ✅ | +| V10.0 | ❌ | +| < V10 | ❌ | Only the versions marked as supported will receive security fixes. ## Reporting a Vulnerability -Please report security vulnerabilities by emailing: - -**[SECURITY_CONTACT_EMAIL]** +Please report security vulnerabilities privately through +[GitHub Security Advisories](https://github.com/kai9987kai/AutoClicker/security/advisories/new) +on this repository. That keeps the report private until a fix is available. Include as much detail as possible, such as: @@ -35,9 +35,9 @@ We ask that you do not publicly disclose the vulnerability until we have had a r After receiving a report, we will: -- Acknowledge receipt within **[X business days]** +- Acknowledge receipt within **5 business days** - Review and validate the issue -- Provide a status update within **[Y business days]** +- Provide a status update within **10 business days** - Fix confirmed vulnerabilities in a future release or issue a mitigation if needed If the report is accepted, we will work to remediate it as quickly as reasonably possible based on severity and impact. @@ -54,7 +54,14 @@ We appreciate responsible disclosure practices and ask that reporters: ## Scope -This policy applies to security issues in the main project codebase and officially supported releases. +This policy applies to security issues in the main project codebase and officially supported releases. + +This project automates mouse and keyboard input on the local machine. Reports about it +*being able* to send input are out of scope — that is the entire purpose of the tool. +In-scope examples include: code execution from a crafted profile, recording or sequence +file; secrets written to disk or into a support bundle; and a run that cannot be stopped +by the documented stop controls. + Issues in unsupported versions may be reviewed on a best-effort basis, but security fixes are not guaranteed. ## Recognition diff --git a/creds.json b/creds.json deleted file mode 100644 index 2471897..0000000 --- a/creds.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "type": "service_account", - "project_id": "test-sheet-api-234320", - "private_key_id": "48452743358cc126854b8b50d6ba531df867f728", - "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDRVXAU9pHssVNF\nEkjyCDHrtoL7QANqCscxfpAQfstHomvHAE42a/hfCqVCaIl0W1mWo6zD54n2nxGL\n1r52y35KmBiM6zL7H4YBUA/Es6RMNR/Ea9kBxJnrwu86dQi2G1j83Z+7UwcIdTct\nEWoeFfXMJUB1e1EqxhucehYpV2vUWaLPLVS3MKBQi4OMReOIKcJmmxCCzsL8LdxS\nA5EG6xtExrrwVNBx+ibEJfOxTmPkOlVIHnnHMHCW9L9m5OgQ9yXznY09cr4EfaoV\nrzM60P1cTz6zYsHtZSc8FnmqXatT+mZFEKgicB3HJCdzxlL+7MYvlpJjIigyPpr+\nGbXzcWnLAgMBAAECggEAWRRa+Ei+6zLAhR2VVg1/tNc7YNCqQ0dn4ZuCkcprFcY7\nx/jMz6LdlBAzKBHVIGM5Of3XqJwQnRauwEu3TFUnHnYfyGPUE4IkI83ej+2EJnva\n01qzg2mWuHmBFOa1uMzlbuGetffMf+BlhPOqrTfaG6/kgLnj3ehk2o8nfBs8AyYG\nh0Ds92xjY4OAfLYjf2DcNREXxnIHATOnfKB0P0D+qXq1BrrlHNY/d+7/th2XiUAe\nq/bgEl4mzL1nVzv8fMytuZEXPktpHg2rV+/iKDvgYharBWjtGriTm7LAowP/sNpg\nO1kQPtUI53pWA+wF6/O/mKRgWxyUvfavr7amlEB47QKBgQDyBr6MeLkO+XaN91Ud\nOenbPyuSfXtOHYv7aapYrsFslixyJ1FdfB+augiQQTq7TlVfi6f2zjNRcPScN6Gr\nDSn7X2/WBimeKHWiNSk+8HvumeI3i3hGdkKrFqsca8HA3AEocMrudi6ZaDpnAh+f\nSShK1tAz1giSAC6dIiTNwgNKHwKBgQDda3uB51CBqHVWqUpFn7o1aMDiChB751CJ\nve+C2zlnIiP5tbI1SCJ3BFm1qB+dAwEG09VSIZ6O9ZFu2WeVCcaUcoKbzqqeqgmk\niCoteh2I8BDBaPiN35JhAmIDDey/dbqG6eHwjGC6v90jqEEqVfXfBunD4f8OEa7I\n7oAllM+C1QKBgQDhTuoAtzn/+LgnPUMdLBbKpioX+3zYoaT47ctJN5aVUdAajQOv\n9acuUVU7hZ35VNkhU0+P8THQ9UiiTR/RrzWP766bHa2MsLmhwb+d+h5FZzgy4UGc\nxucVgsrUcHQ06Bqj3SzthVOoOGF2F4H8tQwvb37vYqcj08uROIHuklihnQKBgQDV\n1cOFhRte3I3Vj9DGxKdUhUIiCo442wAQxnnU6lrZm0MbGbptcqi2QGFS6nb9b7VC\nHrpZg1ZZwhcd8p/yc2pwWtaAGmr0qEp/t3DkUpbLdOz5nKLOgSggv+hgAsatcMkf\nGii14xxXvouWsBufAz1ckpSYNF3Z/IHMU2k24iw1sQKBgGMwB1h1VVry03rye+fC\nzhkIQNkH4CesPq+Exkyoevkt7YVnnxQ0dN7Ho370ymXVziaj3Qb1UngKxLOKRE+q\ntA8XBS0bnK2cMckcCfWBbdlpaRGjU26pmYsThBhE99QG8Fnlhd2ptLSZNmlAzWJc\n0jL3L0A0bzfl2/8NU87hfNqT\n-----END PRIVATE KEY-----\n", - "client_email": "kai-60@test-sheet-api-234320.iam.gserviceaccount.com", - "client_id": "110138069390492700372", - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": "https://oauth2.googleapis.com/token", - "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", - "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/kai-60%40test-sheet-api-234320.iam.gserviceaccount.com" -} diff --git a/lite-version.py b/lite-version.py index 0760a72..a205ea1 100644 --- a/lite-version.py +++ b/lite-version.py @@ -6,6 +6,24 @@ IMPORT_ERRORS = {} + +def _resource_path(file_name): + """Resolve bundled resources from source checkouts and PyInstaller builds. + + A bare relative path meant the icon vanished whenever the app was launched from + any directory other than the one holding the script. + """ + import os + + for root in (getattr(sys, "_MEIPASS", None), os.path.dirname(os.path.abspath(__file__)), os.getcwd()): + if not root: + continue + candidate = os.path.join(root, file_name) + if os.path.exists(candidate): + return candidate + return file_name + + try: import keyboard except Exception as exc: @@ -61,13 +79,15 @@ class AutoClickerGUI(tk.Tk): def __init__(self): super().__init__() self.title("AutoClicker Lite") - self.geometry("360x500+400+260") + self.geometry("380x600+400+220") self.attributes("-topmost", True) self.configure(bg="#0f172a") - self.resizable(False, False) + # Fixed-size meant controls were clipped under display scaling. + self.resizable(True, True) + self.minsize(340, 420) try: - self.iconbitmap("favicon.ico") + self.iconbitmap(_resource_path("favicon.ico")) except: pass @@ -76,6 +96,7 @@ def __init__(self): self.click_mode_var = tk.StringVar(value="Left Click") self.delay_var = tk.StringVar(value="0.10") self.stop_hotkey_var = tk.StringVar(value="q") + self.capture_hotkey_var = tk.StringVar(value="f8") self.repeat_mode_var = tk.StringVar(value="Infinite") self.burst_count_var = tk.StringVar(value="25") self.status_var = tk.StringVar(value="Ready. Capture a point and press Start.") @@ -84,12 +105,19 @@ def __init__(self): self.is_running = False self.stop_event = threading.Event() self.session_clicks = 0 + self.capture_hotkey_handle = None self._build_ui() self.repeat_mode_var.trace_add("write", self._update_repeat_state) self._update_repeat_state() self._apply_dependency_state() + self.protocol("WM_DELETE_WINDOW", self._handle_close) + + def _handle_close(self): + self.stop_event.set() + self._disarm_hotkey_capture() + self.destroy() def _build_ui(self): tk.Label( @@ -116,7 +144,9 @@ def _build_ui(self): tk.Label(card, text="Target Y", bg="#f8fafc", fg="#0f172a", font=("Segoe UI", 9, "bold")).grid(row=1, column=0, sticky="w", pady=(0, 6)) ttk.Entry(card, textvariable=self.input_y_var, width=14).grid(row=1, column=1, sticky="ew", pady=(0, 6)) self.capture_button = ttk.Button(card, text="Use Current Cursor", command=self._capture_cursor) - self.capture_button.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(2, 10)) + self.capture_button.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(2, 4)) + self.capture_hotkey_button = ttk.Button(card, text="Arm Capture Hotkey", command=self._arm_hotkey_capture) + self.capture_hotkey_button.grid(row=9, column=0, columnspan=2, sticky="ew", pady=(0, 10)) tk.Label(card, text="Click type", bg="#f8fafc", fg="#0f172a", font=("Segoe UI", 9, "bold")).grid(row=3, column=0, sticky="w", pady=(0, 6)) ttk.Combobox( @@ -144,13 +174,16 @@ def _build_ui(self): self.burst_entry = ttk.Entry(card, textvariable=self.burst_count_var) self.burst_entry.grid(row=7, column=1, sticky="ew", pady=(0, 6)) + tk.Label(card, text="Capture hotkey", bg="#f8fafc", fg="#0f172a", font=("Segoe UI", 9, "bold")).grid(row=8, column=0, sticky="w", pady=(0, 6)) + ttk.Entry(card, textvariable=self.capture_hotkey_var).grid(row=8, column=1, sticky="ew", pady=(0, 6)) + tk.Label( card, textvariable=self.session_var, bg="#f8fafc", fg="#0f766e", font=("Segoe UI", 9, "bold"), - ).grid(row=8, column=0, columnspan=2, sticky="w", pady=(8, 0)) + ).grid(row=10, column=0, columnspan=2, sticky="w", pady=(8, 0)) button_row = tk.Frame(self, bg="#0f172a") button_row.pack(fill="x", padx=16, pady=18) @@ -211,6 +244,7 @@ def _update_repeat_state(self, *_args): self.burst_entry.configure(state="normal") def _capture_cursor(self): + """Capture where the cursor is now (the button click position, by definition).""" if not _ensure_dependencies("AutoClicker Lite", ["pyautogui"], parent=self): return @@ -224,6 +258,42 @@ def _capture_cursor(self): self.input_y_var.set(str(y_pos)) self.status_var.set(f"Captured cursor position {x_pos}, {y_pos}.") + def _arm_hotkey_capture(self): + """Arm a global hotkey so the target can be captured anywhere on screen. + + Clicking "Use Current Cursor" can only ever record the position of that button. + Arming a hotkey lets the user point at the real target first, then press a key. + """ + if not _ensure_dependencies("AutoClicker Lite", ["pyautogui", "keyboard"], parent=self): + return + if self.capture_hotkey_handle is not None: + self._disarm_hotkey_capture() + return + + hotkey = self.capture_hotkey_var.get().strip() or "f8" + try: + self.capture_hotkey_handle = keyboard.add_hotkey( + hotkey, lambda: self.after(0, self._capture_cursor) + ) + except Exception as exc: + messagebox.showerror("Capture hotkey", f"Could not register '{hotkey}'.\n{exc}", parent=self) + self.capture_hotkey_handle = None + return + + self.capture_hotkey_button.configure(text=f"Disarm '{hotkey}'") + self.status_var.set(f"Point at the target, then press '{hotkey}' to capture it.") + + def _disarm_hotkey_capture(self): + if self.capture_hotkey_handle is not None: + try: + keyboard.remove_hotkey(self.capture_hotkey_handle) + except Exception: + pass + self.capture_hotkey_handle = None + if hasattr(self, "capture_hotkey_button"): + self.capture_hotkey_button.configure(text="Arm Capture Hotkey") + self.status_var.set("Capture hotkey disarmed.") + def _set_running_state(self, running): self.is_running = running self.start_button.configure( diff --git a/packaging/build_release.py b/packaging/build_release.py index c132aff..0eab997 100644 --- a/packaging/build_release.py +++ b/packaging/build_release.py @@ -6,8 +6,19 @@ import sys from pathlib import Path -VERSION = "V10.1" ROOT = Path(__file__).resolve().parents[1] + + +def _app_version() -> str: + """Read APP_VERSION straight from the source so the three files cannot drift.""" + source = (ROOT / "AutoClicker.py").read_text(encoding="utf-8") + for line in source.splitlines(): + if line.startswith("APP_VERSION"): + return line.split("=", 1)[1].strip().strip('"').strip("'") + raise RuntimeError("APP_VERSION not found in AutoClicker.py") + + +VERSION = _app_version() BUILD_ROOT = ROOT / "Builds" / VERSION EXECUTABLE_DIR = BUILD_ROOT / "Executable" INSTALLER_DIR = BUILD_ROOT / "Installer" @@ -62,10 +73,10 @@ def build_main_executable() -> None: str(PYINSTALLER_ROOT / "autoclicker" / "build"), "--specpath", str(PYINSTALLER_ROOT / "autoclicker" / "spec"), - "--collect-submodules", - "gspread", - "--collect-submodules", - "oauth2client", + # The window icon is loaded at runtime via _resource_path("favicon.ico"); without + # --add-data the onefile bundle has no such file and iconbitmap() fails silently. + "--add-data", + add_data_argument(ICON_PATH, "."), "--collect-submodules", "pystray", "--collect-submodules", @@ -88,6 +99,8 @@ def build_lite_executable() -> None: str(PYINSTALLER_ROOT / "lite" / "build"), "--specpath", str(PYINSTALLER_ROOT / "lite" / "spec"), + "--add-data", + add_data_argument(ICON_PATH, "."), str(LITE_SCRIPT), ) @@ -124,7 +137,41 @@ def build_installer() -> None: ) +def preflight() -> None: + """Fail before anything is deleted. + + ensure_clean_dir() rmtree's the tracked Builds/ folders, so a missing + PyInstaller used to destroy the previous release and only then error out. + """ + import importlib.util + + if importlib.util.find_spec("PyInstaller") is None: + raise SystemExit( + "PyInstaller is not installed. Run: pip install -r requirements-build.txt" + ) + for required in (MAIN_SCRIPT, LITE_SCRIPT, INSTALLER_SCRIPT, ICON_PATH, *ROOT_PAYLOAD_FILES): + if not Path(required).exists(): + raise SystemExit(f"Missing build input: {required}") + + +def verify_outputs() -> None: + """Confirm the build actually produced what it claims.""" + expected = [ + EXECUTABLE_DIR / "AutoClicker.exe", + EXECUTABLE_DIR / "AutoClickerLite.exe", + INSTALLER_DIR / "AutoClickerInstaller.exe", + ] + missing = [str(path) for path in expected if not path.exists()] + if missing: + raise SystemExit("Build finished but these artifacts are missing:\n " + "\n ".join(missing)) + for path in expected: + print(f" {path.name}: {path.stat().st_size / 1_048_576:.1f} MB") + + def main() -> int: + print(f"Building AutoClicker {VERSION} (version read from AutoClicker.py)") + preflight() + ensure_clean_dir(EXECUTABLE_DIR) ensure_clean_dir(INSTALLER_DIR) ensure_clean_dir(PYINSTALLER_ROOT) @@ -133,6 +180,7 @@ def main() -> int: build_lite_executable() copy_payload_files() build_installer() + verify_outputs() print(f"Release artifacts written to: {BUILD_ROOT}") return 0 diff --git a/packaging/installer.py b/packaging/installer.py index 2820e32..85c72d8 100644 --- a/packaging/installer.py +++ b/packaging/installer.py @@ -7,7 +7,21 @@ from tkinter import filedialog, messagebox APP_NAME = "AutoClicker" -VERSION = "V10.1" + + +def _app_version(default="V11.0"): + """Prefer the version recorded in the source tree; fall back when frozen.""" + source_file = Path(__file__).resolve().parents[1] / "AutoClicker.py" + try: + for line in source_file.read_text(encoding="utf-8").splitlines(): + if line.startswith("APP_VERSION"): + return line.split("=", 1)[1].strip().strip('"').strip("'") + except Exception: + pass + return default + + +VERSION = _app_version() PAYLOAD_FILES = ( "AutoClicker.exe", "AutoClickerLite.exe", @@ -90,6 +104,43 @@ def create_shortcut(shortcut_path, target_path, icon_path, working_dir): shortcut.Save() +def assert_safe_install_dir(install_dir): + """Refuse to recursively delete anything that is not an AutoClicker install. + + `--install-dir` feeds straight into shutil.rmtree, so pointing it at C:\\ or at a + home directory would wipe it. A target must be non-existent, empty, or a directory + this installer previously wrote. + """ + install_dir = Path(install_dir).resolve() + + forbidden = {Path(p).resolve() for p in filter(None, ( + os.environ.get("SystemDrive", "C:") + os.sep, + os.environ.get("SystemRoot"), + os.environ.get("ProgramFiles"), + os.environ.get("USERPROFILE"), + os.environ.get("APPDATA"), + os.environ.get("LOCALAPPDATA"), + str(Path.home()), + ))} + if install_dir in forbidden or install_dir.parent == install_dir: + raise ValueError(f"Refusing to install into a protected location: {install_dir}") + + if not install_dir.exists(): + return install_dir + if not install_dir.is_dir(): + raise ValueError(f"Install target is not a directory: {install_dir}") + + existing = set(os.listdir(install_dir)) + if not existing: + return install_dir + if existing <= set(PAYLOAD_FILES) or (install_dir / "AutoClicker.exe").exists(): + return install_dir + raise ValueError( + f"Refusing to erase {install_dir}: it is not empty and does not look like an " + f"existing {APP_NAME} install." + ) + + def install_payload(install_dir, make_shortcuts): source_root = payload_root() missing_files = [name for name in PAYLOAD_FILES if not (source_root / name).exists()] @@ -98,6 +149,7 @@ def install_payload(install_dir, make_shortcuts): "Installer payload is incomplete. Missing: " + ", ".join(missing_files) ) + install_dir = assert_safe_install_dir(install_dir) if install_dir.exists(): shutil.rmtree(install_dir) install_dir.mkdir(parents=True, exist_ok=True) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..45aafd8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +[tool.pytest.ini_options] +# Without pythonpath the tests cannot `import AutoClicker`, and without testpaths a bare +# `pytest` collects nothing. Both used to fail silently. +testpaths = ["tests"] +pythonpath = ["."] +addopts = "-ra" + +[tool.ruff] +line-length = 140 +target-version = "py310" + +[tool.ruff.lint] +# The app is a single Tkinter file that leans on `from tkinter import *`; the star-import +# and bare-except rules are informational here rather than enforced. +ignore = ["E402", "E722", "F403", "F405"] diff --git a/requirements-build.txt b/requirements-build.txt new file mode 100644 index 0000000..7ea48ea --- /dev/null +++ b/requirements-build.txt @@ -0,0 +1,2 @@ +# Build-time only. Runtime dependencies live in requirements.txt. +pyinstaller>=6.0.0 diff --git a/requirements.txt b/requirements.txt index bd0f40c..04917a7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,15 @@ -pyautogui -keyboard -pystray -gspread -oauth2client -win10toast -colormap -pywin32 -Pillow -numpy +# Required: the app cannot send input without these. +pyautogui>=0.9.54 +keyboard>=0.13.5 + +# Optional: each unlocks a feature and degrades gracefully when absent. +# `python AutoClicker.py doctor` prints exactly which of these you are missing. +pystray>=0.19.5 # close-to-tray and the tray menu +Pillow>=10.0.0 # Photo Clicker previews and screen sampling +numpy>=1.24.0 # fast Colour Clicker region scanning +opencv-python>=4.8.0 # Photo Clicker confidence matching (pyautogui needs it for confidence=) +win10toast>=0.9; sys_platform == "win32" # Windows toast notifications + +# Removed in V11.0: gspread, oauth2client, colormap and pywin32 were installed but never +# referenced by any code path. Dropping them cut `import AutoClicker` from ~15s to ~1.4s +# and removed the last tie to the retired Google Sheets feedback integration. diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..c5de578 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,258 @@ +"""End-to-end tests for the headless command line. + +These run AutoClicker.py as a subprocess so they pin the real exit-code contract: +0 success, 1 check/validation failure, 2 usage error, 3 internal error. +""" + +import json +import os +import subprocess +import sys +import tempfile +import unittest + +import AutoClicker + +SCRIPT = os.path.abspath(AutoClicker.__file__) +REPO_ROOT = os.path.dirname(SCRIPT) + + +def run_cli(*args, state_dir=None): + env = dict(os.environ) + if state_dir: + env["APPDATA"] = state_dir + env["LOCALAPPDATA"] = state_dir + return subprocess.run( + [sys.executable, SCRIPT, *args], + capture_output=True, text=True, cwd=REPO_ROOT, env=env, timeout=120, + ) + + +class CliContractTests(unittest.TestCase): + def setUp(self): + self._temp = tempfile.TemporaryDirectory() + self.state_dir = self._temp.name + self.addCleanup(self._temp.cleanup) + + def write(self, name, payload): + path = os.path.join(self.state_dir, name) + with open(path, "w", encoding="utf-8") as handle: + if isinstance(payload, str): + handle.write(payload) + else: + json.dump(payload, handle) + return path + + def test_help_prints_usage_and_does_not_open_the_gui(self): + result = run_cli("--help", state_dir=self.state_dir) + self.assertEqual(result.returncode, 0) + self.assertIn("usage: AutoClicker", result.stdout) + + def test_version_reports_the_app_version(self): + result = run_cli("--version", state_dir=self.state_dir) + self.assertEqual(result.returncode, 0) + self.assertIn(AutoClicker.APP_VERSION, result.stdout) + + def test_unknown_flag_is_a_usage_error_not_a_gui_launch(self): + # V10.1 fell through to MAINWINDOW_REDESIGNED() for anything unrecognised, + # so a typo on a headless runner tried to open a window. + result = run_cli("--not-a-real-flag", state_dir=self.state_dir) + self.assertEqual(result.returncode, 2) + + def test_unknown_subcommand_is_a_usage_error(self): + self.assertEqual(run_cli("nonsense", state_dir=self.state_dir).returncode, 2) + + def test_missing_required_path_is_a_usage_error(self): + self.assertEqual(run_cli("validate-recording", state_dir=self.state_dir).returncode, 2) + + def test_health_reports_dependencies(self): + result = run_cli("health", state_dir=self.state_dir) + self.assertEqual(result.returncode, 0) + self.assertIn("AutoClicker Health Check", result.stdout) + + def test_health_json_uses_the_shared_envelope(self): + result = run_cli("health", "--json", state_dir=self.state_dir) + payload = json.loads(result.stdout) + for key in ("schema_version", "app_version", "command", "ok", "generated_at", "data", "errors"): + self.assertIn(key, payload) + self.assertEqual(payload["command"], "health") + self.assertTrue(payload["ok"]) + + def test_doctor_prints_an_actionable_install_line(self): + result = run_cli("doctor", state_dir=self.state_dir) + self.assertIn("Doctor", result.stdout) + + def test_schema_command_describes_every_action(self): + result = run_cli("schema", "action", "--json", state_dir=self.state_dir) + payload = json.loads(result.stdout) + self.assertEqual(set(payload["data"]["actions"]), set(AutoClicker.ACTION_REGISTRY)) + + def test_state_summary_still_works(self): + self.assertEqual(run_cli("state-summary", state_dir=self.state_dir).returncode, 0) + + def test_backup_dry_run_writes_nothing(self): + before = set(os.listdir(self.state_dir)) + result = run_cli("backup-state", "--dry-run", state_dir=self.state_dir) + self.assertEqual(result.returncode, 0) + self.assertIn("Dry run", result.stdout) + self.assertEqual(set(os.listdir(self.state_dir)), before) + + def test_valid_recording_exits_zero(self): + path = self.write("rec.json", [[10, 20], [30, 40]]) + result = run_cli("validate-recording", path, state_dir=self.state_dir) + self.assertEqual(result.returncode, 0) + self.assertIn("2 point(s)", result.stdout) + + def test_invalid_recording_exits_one(self): + path = self.write("bad.json", "not json at all") + self.assertEqual(run_cli("validate-recording", path, state_dir=self.state_dir).returncode, 1) + + def test_sequences_may_use_the_new_action_vocabulary(self): + path = self.write("seq.json", [[1, 2, "Left Click", 0.5], [3, 4, "Scroll Down", 0.25]]) + result = run_cli("validate-sequence", path, state_dir=self.state_dir) + self.assertEqual(result.returncode, 0) + self.assertIn("2 step(s)", result.stdout) + + def test_multiple_paths_are_all_validated(self): + good = self.write("rec.json", [[1, 2]]) + bad = self.write("bad.json", "nope") + result = run_cli("validate-recording", good, bad, state_dir=self.state_dir) + self.assertEqual(result.returncode, 1) + payload_result = run_cli("validate-recording", good, bad, "--json", state_dir=self.state_dir) + payload = json.loads(payload_result.stdout) + self.assertEqual(payload["data"]["count"], 2) + self.assertFalse(payload["ok"]) + + def test_legacy_flags_still_work(self): + recording = self.write("rec.json", [[1, 2]]) + cases = [ + (("--health-check",), 0), + (("--health-json",), 0), + (("--state-summary",), 0), + (("--state-json",), 0), + (("--validate-recording", recording), 0), + ] + for args, expected in cases: + with self.subTest(args=args): + self.assertEqual(run_cli(*args, state_dir=self.state_dir).returncode, expected) + + def test_legacy_gnu_equals_form_is_understood(self): + # "--validate-recording=x.json" is one argv token, which V10.1 did not match, + # so it silently launched the GUI instead of validating. + recording = self.write("rec.json", [[1, 2]]) + result = run_cli(f"--validate-recording={recording}", state_dir=self.state_dir) + self.assertEqual(result.returncode, 0) + + def test_stacked_legacy_flags_no_longer_hide_a_failure(self): + # V10.1 answered the first flag only and exited 0, so a failing validation + # combined with a logging flag reported success. + bad = self.write("bad.json", "nope") + result = run_cli("--validate-recording", bad, "--state-summary", state_dir=self.state_dir) + self.assertEqual(result.returncode, 1) + self.assertIn("Ignored extra legacy flag", result.stderr) + + def test_health_strict_fails_when_a_required_dependency_is_missing(self): + result = run_cli("health", "--strict", "--json", state_dir=self.state_dir) + payload = json.loads(result.stdout) + missing = payload["data"]["missing_required"] + self.assertEqual(result.returncode, 1 if missing else 0) + + def test_profiles_list_and_show(self): + os.makedirs(os.path.join(self.state_dir, "AutoClicker"), exist_ok=True) + profiles_path = os.path.join(self.state_dir, "AutoClicker", AutoClicker.PROFILE_FILE_NAME) + with open(profiles_path, "w", encoding="utf-8") as handle: + json.dump({"gaming": {"delay": "0.05", "click_mode": "Left Click", "stop_hotkey": "esc"}}, handle) + + listed = run_cli("profiles", "--json", state_dir=self.state_dir) + self.assertEqual(listed.returncode, 0) + self.assertEqual(json.loads(listed.stdout)["data"]["names"], ["gaming"]) + + shown = run_cli("profiles", "gaming", state_dir=self.state_dir) + self.assertEqual(shown.returncode, 0) + self.assertIn("delay", shown.stdout) + + missing = run_cli("profiles", "nope", state_dir=self.state_dir) + self.assertEqual(missing.returncode, 1) + + def test_readiness_checks_a_saved_profile_without_a_display(self): + os.makedirs(os.path.join(self.state_dir, "AutoClicker"), exist_ok=True) + profiles_path = os.path.join(self.state_dir, "AutoClicker", AutoClicker.PROFILE_FILE_NAME) + with open(profiles_path, "w", encoding="utf-8") as handle: + json.dump({ + "risky": { + "delay": "0", "click_mode": "Left Click", "stop_hotkey": "", + "repeat_mode": "Infinite", "max_actions": "0", "runtime_limit": "0", + }, + }, handle) + result = run_cli("readiness", "risky", "--json", state_dir=self.state_dir) + payload = json.loads(result.stdout) + self.assertFalse(payload["data"]["readiness"]["ready"]) + self.assertEqual(result.returncode, 1) + + def test_history_is_empty_on_a_fresh_state_dir(self): + result = run_cli("history", "--json", state_dir=self.state_dir) + self.assertEqual(result.returncode, 0) + self.assertEqual(json.loads(result.stdout)["data"]["summary"]["runs"], 0) + + +class LegacyTranslationTests(unittest.TestCase): + """Unit-level checks on argument translation (no subprocess).""" + + def test_no_arguments_stay_empty(self): + self.assertEqual(AutoClicker._translate_legacy_args([]), ([], [])) + + def test_modern_arguments_pass_through_untouched(self): + args = ["health", "--json"] + self.assertEqual(AutoClicker._translate_legacy_args(args), (args, [])) + + def test_legacy_flag_maps_to_a_subcommand(self): + translated, ignored = AutoClicker._translate_legacy_args(["--health-json"]) + self.assertEqual(translated, ["health", "--json"]) + self.assertEqual(ignored, []) + + def test_legacy_flag_with_a_value(self): + translated, _ = AutoClicker._translate_legacy_args(["--validate-recording", "a.json"]) + self.assertEqual(translated, ["validate-recording", "a.json"]) + + def test_extra_legacy_flags_are_reported_not_dropped(self): + translated, ignored = AutoClicker._translate_legacy_args( + ["--validate-recording", "a.json", "--state-summary"] + ) + self.assertEqual(translated, ["validate-recording", "a.json"]) + self.assertEqual(ignored, ["--state-summary"]) + + +class SchemaDescriptionTests(unittest.TestCase): + def test_every_schema_kind_is_describable(self): + for kind in ("recording", "sequence", "profile", "action"): + with self.subTest(kind=kind): + described = AutoClicker._describe_schema(kind) + self.assertEqual(described["kind"], kind) + self.assertTrue(described["description"]) + + def test_profile_schema_lists_the_real_field_sets(self): + described = AutoClicker._describe_schema("profile") + self.assertEqual(set(described["fields"]), AutoClicker.PROFILE_FIELDS) + + def test_readiness_projection_handles_an_empty_profile(self): + config = AutoClicker._readiness_config_from_profile({}) + self.assertEqual(config["x"], 0) + self.assertIsNone(config["repeat_limit"]) + + def test_readiness_projection_reads_burst_counts(self): + config = AutoClicker._readiness_config_from_profile( + {"repeat_mode": "Burst Count", "repeat_count": "25"} + ) + self.assertEqual(config["repeat_limit"], 25) + + def test_readiness_projection_survives_garbage_values(self): + config = AutoClicker._readiness_config_from_profile( + {"target_x": "abc", "delay": None, "repeat_mode": "Burst Count", "repeat_count": "xyz"} + ) + self.assertEqual(config["x"], 0) + self.assertEqual(config["delay"], 0.0) + self.assertIsNone(config["repeat_limit"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_engine.py b/tests/test_engine.py new file mode 100644 index 0000000..a70ce72 --- /dev/null +++ b/tests/test_engine.py @@ -0,0 +1,319 @@ +"""Tests for the V11 run-engine primitives. + +Everything here is headless: no display, no real mouse, no pyautogui calls. +""" + +import datetime +import unittest + +import AutoClicker + + +class InterruptibleSleepTests(unittest.TestCase): + def _fake_clock(self, sleeps): + state = {"now": 0.0} + + def clock(): + return state["now"] + + def sleeper(duration): + sleeps.append(duration) + state["now"] += duration + + return clock, sleeper + + def test_sleeps_the_full_duration_in_slices(self): + sleeps = [] + clock, sleeper = self._fake_clock(sleeps) + completed = AutoClicker._interruptible_sleep(0.1, None, 0.02, clock, sleeper) + + self.assertTrue(completed) + self.assertAlmostEqual(sum(sleeps), 0.1, places=6) + self.assertLessEqual(max(sleeps), 0.02) + + def test_returns_early_when_the_gate_trips(self): + sleeps = [] + clock, sleeper = self._fake_clock(sleeps) + calls = {"n": 0} + + def should_stop(): + calls["n"] += 1 + return calls["n"] > 2 + + completed = AutoClicker._interruptible_sleep(10.0, should_stop, 0.02, clock, sleeper) + + self.assertFalse(completed) + self.assertLess(sum(sleeps), 0.1) + + def test_never_passes_a_negative_duration_to_sleep(self): + # The old loop re-read the clock between the guard and the argument, so a + # descheduled thread could compute a negative sleep and raise ValueError. + sleeps = [] + state = {"now": 0.0} + + def clock(): + state["now"] += 0.05 # Clock jumps past the deadline mid-iteration. + return state["now"] + + def sleeper(duration): + sleeps.append(duration) + + AutoClicker._interruptible_sleep(0.01, None, 0.02, clock, sleeper) + self.assertTrue(all(duration >= 0 for duration in sleeps), sleeps) + + def test_zero_and_invalid_durations_do_not_sleep(self): + sleeps = [] + clock, sleeper = self._fake_clock(sleeps) + self.assertTrue(AutoClicker._interruptible_sleep(0, None, 0.02, clock, sleeper)) + self.assertTrue(AutoClicker._interruptible_sleep("nonsense", None, 0.02, clock, sleeper)) + self.assertEqual(sleeps, []) + + def test_zero_duration_still_reports_a_tripped_gate(self): + self.assertFalse(AutoClicker._interruptible_sleep(0, lambda: True)) + + +class ResolveActionTests(unittest.TestCase): + def test_every_registry_entry_resolves(self): + # action_text has no usable default on purpose: typing needs real text. + config = {"x": 10, "y": 20, "action_text": "hello"} + for action_name in AutoClicker.ACTION_REGISTRY: + with self.subTest(action=action_name): + descriptor = AutoClicker._resolve_action(action_name, config) + self.assertIn("kind", descriptor) + self.assertIn("call", descriptor) + self.assertIsInstance(descriptor["kwargs"], dict) + + def test_defaults_cover_every_declared_parameter(self): + declared = {name for spec in AutoClicker.ACTION_REGISTRY.values() for name in spec.get("uses", ())} + self.assertEqual(declared - set(AutoClicker.ACTION_DEFAULTS), set()) + + def test_legacy_click_types_keep_their_exact_behaviour(self): + for label, (button, clicks) in AutoClicker.DEFAULT_CLICK_TYPES.items(): + with self.subTest(label=label): + descriptor = AutoClicker._resolve_action(label, {"x": 3, "y": 4}) + self.assertEqual(descriptor["kind"], "click") + self.assertEqual(descriptor["kwargs"]["button"], button) + self.assertEqual(descriptor["kwargs"]["clicks"], clicks) + self.assertEqual((descriptor["kwargs"]["x"], descriptor["kwargs"]["y"]), (3, 4)) + + def test_scroll_direction_is_signed(self): + up = AutoClicker._resolve_action("Scroll Up", {"scroll_amount": 5}) + down = AutoClicker._resolve_action("Scroll Down", {"scroll_amount": 5}) + self.assertEqual(up["kwargs"]["clicks"], 5) + self.assertEqual(down["kwargs"]["clicks"], -5) + + def test_scroll_amount_sign_is_normalised(self): + descriptor = AutoClicker._resolve_action("Scroll Up", {"scroll_amount": -7}) + self.assertEqual(descriptor["kwargs"]["clicks"], 7) + + def test_drag_carries_both_endpoints(self): + descriptor = AutoClicker._resolve_action( + "Drag To", {"x": 1, "y": 2, "drag_to_x": 30, "drag_to_y": 40} + ) + self.assertEqual(descriptor["kwargs"]["x"], 1) + self.assertEqual(descriptor["kwargs"]["to_y"], 40) + + def test_unknown_action_is_rejected(self): + with self.assertRaises(ValueError): + AutoClicker._resolve_action("Telepathy", {}) + + def test_empty_key_name_is_rejected(self): + with self.assertRaises(ValueError): + AutoClicker._resolve_action("Key Press", {"action_key": " "}) + + def test_zero_scroll_is_rejected(self): + with self.assertRaises(ValueError): + AutoClicker._resolve_action("Scroll Up", {"scroll_amount": 0}) + + def test_empty_text_is_rejected(self): + with self.assertRaises(ValueError): + AutoClicker._resolve_action("Type Text", {"action_text": ""}) + + def test_keyboard_actions_do_not_move_the_pointer(self): + self.assertFalse(AutoClicker._action_moves_pointer("Key Press")) + self.assertFalse(AutoClicker._action_moves_pointer("Type Text")) + self.assertTrue(AutoClicker._action_moves_pointer("Left Click")) + self.assertTrue(AutoClicker._action_moves_pointer("Scroll Up")) + + +class RateTests(unittest.TestCase): + def test_delay_and_cps_round_trip(self): + self.assertAlmostEqual(AutoClicker._cps_to_delay(10), 0.1) + self.assertAlmostEqual(AutoClicker._delay_to_cps(0.1), 10.0) + self.assertAlmostEqual(AutoClicker._delay_to_cps(AutoClicker._cps_to_delay(7)), 7.0) + + def test_non_positive_and_invalid_values_return_none(self): + for value in (0, -1, "", "abc", None): + self.assertIsNone(AutoClicker._cps_to_delay(value)) + self.assertIsNone(AutoClicker._delay_to_cps(value)) + + def test_rate_stats_reports_average_and_peak(self): + stats = AutoClicker._rate_stats([(0.0, 0), (1.0, 10), (2.0, 30)]) + self.assertEqual(stats["average_cps"], 15.0) + self.assertEqual(stats["peak_cps"], 20.0) + self.assertEqual(stats["samples"], 3) + + def test_rate_stats_handles_too_few_samples(self): + for samples in ([], [(0.0, 0)], None): + stats = AutoClicker._rate_stats(samples) + self.assertEqual(stats["average_cps"], 0.0) + self.assertEqual(stats["peak_cps"], 0.0) + + def test_instant_rate_follows_the_recent_window(self): + # Fast for 10s, then a slow final second: instant should track the slow tail. + samples = [(float(second), second * 20) for second in range(11)] + samples.append((11.0, 201)) + stats = AutoClicker._rate_stats(samples, window_seconds=1.0) + self.assertLess(stats["instant_cps"], stats["average_cps"]) + + +class PacingTests(unittest.TestCase): + def test_precise_mode_honours_the_configured_delay(self): + self.assertAlmostEqual(AutoClicker._effective_action_period(0.1, "Precise", False), 0.1) + + def test_legacy_mode_exposes_the_hidden_library_pause(self): + # V10.1 promised 10/sec at delay 0.10 while actually delivering about 5/sec. + legacy = AutoClicker._effective_action_period(0.1, "Legacy V10.1", False) + self.assertAlmostEqual(legacy, 0.2) + self.assertAlmostEqual(AutoClicker._delay_to_cps(legacy), 5.0) + + def test_human_like_adds_movement_overhead(self): + plain = AutoClicker._effective_action_period(0.1, "Precise", False) + humanised = AutoClicker._effective_action_period(0.1, "Precise", True) + self.assertGreater(humanised, plain) + + def test_invalid_and_negative_delays_are_clamped(self): + self.assertAlmostEqual(AutoClicker._effective_action_period(-5, "Precise", False), 0.0) + self.assertAlmostEqual(AutoClicker._effective_action_period("x", "Precise", False), 0.0) + + +class HotkeyValidationTests(unittest.TestCase): + def test_multi_step_hotkeys_are_rejected(self): + result = AutoClicker._validate_hotkey("a, b") + self.assertFalse(result["valid"]) + self.assertIn("Multi-step", result["reason"]) + + def test_blank_hotkey_is_reported_as_unset(self): + self.assertFalse(AutoClicker._validate_hotkey("")["valid"]) + self.assertFalse(AutoClicker._validate_hotkey(None)["valid"]) + + def test_parser_failure_is_surfaced_not_swallowed(self): + def exploding_parser(_value): + raise ValueError("no such key") + + result = AutoClicker._validate_hotkey("f13", parser=exploding_parser) + self.assertFalse(result["valid"]) + self.assertIn("no such key", result["reason"]) + + def test_a_parseable_hotkey_is_accepted(self): + result = AutoClicker._validate_hotkey("ctrl+k", parser=lambda value: value) + self.assertTrue(result["valid"]) + self.assertEqual(result["hotkey"], "ctrl+k") + + +class ScheduledStartTests(unittest.TestCase): + def test_blank_value_is_not_scheduled(self): + self.assertFalse(AutoClicker._parse_scheduled_start("")["scheduled"]) + + def test_a_later_time_today_waits_that_long(self): + now = datetime.datetime(2026, 8, 3, 9, 0, 0) + result = AutoClicker._parse_scheduled_start("09:30", now=now) + self.assertTrue(result["scheduled"]) + self.assertAlmostEqual(result["delay_seconds"], 1800.0) + + def test_an_earlier_time_rolls_over_to_tomorrow(self): + now = datetime.datetime(2026, 8, 3, 20, 0, 0) + result = AutoClicker._parse_scheduled_start("09:00", now=now) + self.assertAlmostEqual(result["delay_seconds"], 13 * 3600.0) + self.assertTrue(result["start_at"].startswith("2026-08-04")) + + def test_seconds_precision_is_accepted(self): + now = datetime.datetime(2026, 8, 3, 9, 0, 0) + result = AutoClicker._parse_scheduled_start("09:00:30", now=now) + self.assertAlmostEqual(result["delay_seconds"], 30.0) + + def test_malformed_time_reports_an_error(self): + result = AutoClicker._parse_scheduled_start("half past nine") + self.assertIn("error", result) + self.assertFalse(result["scheduled"]) + + +class GeometryClampTests(unittest.TestCase): + def test_offscreen_geometry_is_pulled_back(self): + clamped = AutoClicker._clamp_geometry_to_screen("800x600+5000+4000", 1920, 1080) + self.assertEqual(clamped, "800x600+1840+1000") + + def test_onscreen_geometry_is_untouched(self): + self.assertEqual( + AutoClicker._clamp_geometry_to_screen("800x600+100+100", 1920, 1080), + "800x600+100+100", + ) + + def test_oversized_window_is_capped_to_the_screen(self): + clamped = AutoClicker._clamp_geometry_to_screen("5000x4000+0+0", 1920, 1080) + self.assertTrue(clamped.startswith("1920x1080")) + + def test_unparseable_geometry_passes_through(self): + self.assertEqual(AutoClicker._clamp_geometry_to_screen("garbage", 1920, 1080), "garbage") + + +class SystemThemeTests(unittest.TestCase): + def test_light_and_dark_are_derived_from_the_reader(self): + self.assertEqual(AutoClicker._detect_system_theme(lambda: 1), "Light") + self.assertEqual(AutoClicker._detect_system_theme(lambda: 0), "Dark") + + def test_a_failing_reader_falls_back_to_light(self): + def boom(): + raise OSError("no registry") + + self.assertEqual(AutoClicker._detect_system_theme(boom), "Light") + + +class ReadinessTests(unittest.TestCase): + def _config(self, **overrides): + config = { + "x": 100, "y": 100, "delay": 0.1, "runtime_limit": 0, "max_actions": 10, + "repeat_limit": None, "stop_hotkey": "esc", "dry_run": True, + "pyautogui_failsafe": True, "click_mode": "Left Click", "pacing_mode": "Precise", + } + config.update(overrides) + return config + + def _item(self, readiness, label): + return next(item for item in readiness["items"] if item["label"] == label) + + def test_an_unpollable_stop_hotkey_is_flagged_not_reported_green(self): + readiness = AutoClicker._build_readiness_checklist(self._config(stop_hotkey="ctrl, k")) + self.assertEqual(self._item(readiness, "Stop hotkey")["state"], "review") + self.assertFalse(readiness["ready"]) + + def test_legacy_pacing_warns_that_the_real_rate_differs(self): + readiness = AutoClicker._build_readiness_checklist( + self._config(pacing_mode="Legacy V10.1") + ) + self.assertEqual(self._item(readiness, "Pace")["state"], "review") + + def test_precise_pacing_reports_the_promised_rate(self): + readiness = AutoClicker._build_readiness_checklist(self._config()) + pace = self._item(readiness, "Pace") + self.assertEqual(pace["state"], "ok") + self.assertIn("10.0 action(s)/sec", pace["detail"]) + + def test_a_misconfigured_action_is_flagged(self): + readiness = AutoClicker._build_readiness_checklist( + self._config(click_mode="Key Press", action_key="") + ) + self.assertEqual(self._item(readiness, "Action")["state"], "review") + + def test_round_robin_targets_are_reported(self): + readiness = AutoClicker._build_readiness_checklist( + self._config(targets=[(1, 2), (3, 4), (5, 6)]) + ) + self.assertIn("3 recorded point(s)", self._item(readiness, "Targets")["detail"]) + + def test_a_clean_config_is_ready(self): + self.assertTrue(AutoClicker._build_readiness_checklist(self._config())["ready"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_gui_engine.py b/tests/test_gui_engine.py new file mode 100644 index 0000000..79a31f3 --- /dev/null +++ b/tests/test_gui_engine.py @@ -0,0 +1,396 @@ +"""Behavioural tests for the run engine inside the Control Center class. + +The engine lives inside `MAINWINDOW_REDESIGNED()`, so it can only be reached by building +the real window. These tests do that against a fake pyautogui that records calls instead +of moving the mouse, and skip cleanly on any machine without a display. +""" + +import os +import tempfile +import time +import tkinter as tk +import types +import unittest + +import AutoClicker + + +class FailSafeException(Exception): + """Stand-in for pyautogui.FailSafeException.""" + + +def make_fake_pyautogui(calls): + return types.SimpleNamespace( + FAILSAFE=True, + PAUSE=0.1, + size=lambda: (1920, 1080), + position=lambda: (7, 9), + click=lambda **kw: calls.append(("click", kw)), + moveTo=lambda *a, **kw: calls.append(("moveTo", a, kw)), + press=lambda keys: calls.append(("press", keys)), + keyDown=lambda key: calls.append(("keyDown", key)), + keyUp=lambda key: calls.append(("keyUp", key)), + write=lambda message: calls.append(("write", message)), + scroll=lambda clicks, **kw: calls.append(("scroll", clicks, kw)), + mouseDown=lambda **kw: calls.append(("mouseDown", kw)), + mouseUp=lambda **kw: calls.append(("mouseUp", kw)), + FailSafeException=FailSafeException, + ) + + +def make_fake_keyboard(): + """Minimal stand-in for the `keyboard` package.""" + return types.SimpleNamespace( + is_pressed=lambda key: False, + add_hotkey=lambda *a, **k: object(), + remove_hotkey=lambda handle: None, + parse_hotkey=lambda hotkey: hotkey, + ) + + +def silence_dialogs(): + """Replace every blocking dialog with a no-op and return an undo callable. + + A modal `messagebox.showerror` has nobody to dismiss it on a CI runner: the job + hangs until the platform's timeout. No test may be able to open one. + """ + import tkinter.messagebox as messagebox + + names = ("showerror", "showinfo", "showwarning", "askyesno", "askyesnocancel", "askokcancel") + saved = {name: getattr(messagebox, name, None) for name in names} + replacements = { + "showerror": lambda *a, **k: None, + "showinfo": lambda *a, **k: None, + "showwarning": lambda *a, **k: None, + "askyesno": lambda *a, **k: True, + "askyesnocancel": lambda *a, **k: True, + "askokcancel": lambda *a, **k: True, + } + for name, replacement in replacements.items(): + setattr(messagebox, name, replacement) + # AutoClicker did `from tkinter import messagebox`, so it shares this module object, + # but rebind explicitly in case that ever changes. + setattr(AutoClicker.messagebox, name, replacement) + + def restore(): + for name, original in saved.items(): + if original is not None: + setattr(messagebox, name, original) + setattr(AutoClicker.messagebox, name, original) + + return restore + + +_SHARED = {} + + +def get_shared_gui(): + """Build the Control Center exactly once for the whole test module. + + Tk does not survive repeated create/destroy cycles cleanly on Windows: a second + root after a destroyed one raises `invalid command name "tcl_findLibrary"`. One + window is built lazily, shared by every test, and left alive until the process ends. + """ + if "gui" in _SHARED: + return _SHARED["gui"] + if _SHARED.get("unavailable"): + raise unittest.SkipTest(_SHARED["unavailable"]) + + calls = [] + _SHARED["calls"] = calls + _SHARED["fake"] = make_fake_pyautogui(calls) + _SHARED["real_pyautogui"] = AutoClicker.pyautogui + _SHARED["real_keyboard"] = AutoClicker.keyboard + AutoClicker.pyautogui = _SHARED["fake"] + AutoClicker.keyboard = make_fake_keyboard() + + # We are supplying working stand-ins, so the dependency gate must not fire. Without + # this, CI (which installs no third-party packages by design) reaches + # _show_dependency_error and opens a modal dialog that never closes. + _SHARED["import_errors"] = dict(AutoClicker.IMPORT_ERRORS) + for package in ("pyautogui", "keyboard"): + AutoClicker.IMPORT_ERRORS.pop(package, None) + _SHARED["restore_dialogs"] = silence_dialogs() + + temp = tempfile.TemporaryDirectory() + _SHARED["temp"] = temp + _SHARED["env"] = {key: os.environ.get(key) for key in ("APPDATA", "LOCALAPPDATA")} + os.environ["APPDATA"] = os.environ["LOCALAPPDATA"] = temp.name + + def give_up(reason): + AutoClicker.pyautogui = _SHARED["real_pyautogui"] + AutoClicker.keyboard = _SHARED["real_keyboard"] + AutoClicker.IMPORT_ERRORS.clear() + AutoClicker.IMPORT_ERRORS.update(_SHARED["import_errors"]) + _SHARED["restore_dialogs"]() + _SHARED["unavailable"] = reason + raise unittest.SkipTest(reason) + + holder = {} + real_mainloop = tk.Tk.mainloop + tk.Tk.mainloop = lambda window, *a, **k: holder.setdefault("gui", window) + try: + AutoClicker.MAINWINDOW_REDESIGNED() + except Exception as exc: + give_up(f"no usable display: {exc}") + finally: + tk.Tk.mainloop = real_mainloop + + if holder.get("gui") is None: + give_up("Control Center did not build") + + _SHARED["gui"] = holder["gui"] + return _SHARED["gui"] + + +class GuiTestCase(unittest.TestCase): + """Base class sharing one Control Center; each test resets the state it relies on.""" + + @classmethod + def setUpClass(cls): + cls.gui = get_shared_gui() + cls.calls = _SHARED["calls"] + cls.fake = _SHARED["fake"] + + def setUp(self): + gui = self.gui + gui.stopclick() + self.pump(0.2) + self.calls.clear() + self.fake.PAUSE = 0.1 + self.fake.FAILSAFE = True + gui.pause_event.clear() + for event in (gui.stop_event, gui.sequence_stop_event, gui.playback_stop_event): + event.clear() + gui.recording_data = [] + gui.round_robin_var.set(False) + gui.pacing_mode_var.set("Precise") + gui.repeat_mode_var.set("Burst Count") + gui.repeat_count_var.set("3") + gui.stop_hotkey_var.set("esc") + gui.countdown_var.set("0") + gui.runtime_limit_var.set("0") + gui.max_actions_var.set("0") + gui.scheduled_start_var.set("") + gui.click_mode_var.set("Left Click") + gui.minimize_on_start_var.set(False) + gui.play_sound_var.set(False) + gui.dry_run_var.set(False) + gui.human_like_var.set(False) + gui.target_x_var.set("100") + gui.target_y_var.set("200") + gui.jitter_x_var.set("0") + gui.jitter_y_var.set("0") + gui.delay_var.set("0.01") + self.addCleanup(self._stop_any_run) + + def _stop_any_run(self): + try: + self.gui.pause_event.clear() + self.gui.stopclick() + self.pump(0.4) + except Exception: + pass + + def pump(self, seconds): + """Drive the Tk event loop for `seconds` so worker messages get delivered.""" + end = time.time() + seconds + while time.time() < end: + try: + self.gui.update() + except tk.TclError: + return + time.sleep(0.01) + + def clicks(self): + return [call for call in self.calls if call[0] == "click"] + + +class RunEngineTests(GuiTestCase): + # -- action dispatch ---------------------------------------------------------------- + + def test_each_action_kind_reaches_the_right_call(self): + expected = { + "Left Click": "click", "Key Press": "press", "Key Hold": "keyDown", + "Type Text": "write", "Scroll Up": "scroll", "Click And Hold": "mouseDown", + "Drag To": "mouseDown", "Move Only": "moveTo", + } + self.gui.action_text_var.set("hi") + for action, want in expected.items(): + with self.subTest(action=action): + self.calls.clear() + config = dict(self.gui._build_run_config(), click_mode=action, + dry_run=False, action_text="hi", stop_hotkey="") + self.gui._emit_action(config, (100, 200)) + self.assertIn(want, [call[0] for call in self.calls]) + + def test_held_actions_always_release(self): + for action, down, up in (("Click And Hold", "mouseDown", "mouseUp"), + ("Drag To", "mouseDown", "mouseUp"), + ("Key Hold", "keyDown", "keyUp")): + with self.subTest(action=action): + self.calls.clear() + config = dict(self.gui._build_run_config(), click_mode=action, + dry_run=False, hold_duration=0.01, stop_hotkey="") + self.gui._emit_action(config, (10, 10)) + names = [call[0] for call in self.calls] + self.assertIn(down, names) + self.assertIn(up, names) + + def test_dry_run_emits_nothing(self): + config = dict(self.gui._build_run_config(), dry_run=True, stop_hotkey="") + self.gui._emit_action(config, (100, 200)) + self.assertEqual(self.calls, []) + + # -- run lifecycle ------------------------------------------------------------------ + + def test_burst_sends_exactly_the_requested_count(self): + self.gui.repeat_mode_var.set("Burst Count") + self.gui.repeat_count_var.set("5") + self.gui.startclick() + self.pump(2.0) + self.assertEqual(len(self.clicks()), 5) + + def test_globals_are_restored_after_a_run(self): + self.gui.repeat_mode_var.set("Burst Count") + self.gui.repeat_count_var.set("3") + self.gui.startclick() + self.pump(2.0) + self.assertEqual(self.fake.PAUSE, 0.1) + self.assertTrue(self.fake.FAILSAFE) + + def test_precise_pacing_zeroes_the_library_pause_during_the_run(self): + seen = [] + real_click = self.fake.click + self.fake.click = lambda **kw: (seen.append(self.fake.PAUSE), real_click(**kw)) + self.gui.repeat_mode_var.set("Burst Count") + self.gui.repeat_count_var.set("3") + self.gui.pacing_mode_var.set("Precise") + self.gui.startclick() + self.pump(2.0) + self.assertTrue(seen) + self.assertTrue(all(pause == 0 for pause in seen), seen) + + def test_legacy_pacing_leaves_the_library_pause_alone(self): + seen = [] + real_click = self.fake.click + self.fake.click = lambda **kw: (seen.append(self.fake.PAUSE), real_click(**kw)) + self.gui.repeat_mode_var.set("Burst Count") + self.gui.repeat_count_var.set("3") + self.gui.pacing_mode_var.set("Legacy V10.1") + self.gui.startclick() + self.pump(2.0) + self.assertTrue(seen) + self.assertTrue(all(pause == 0.1 for pause in seen), seen) + + def test_pause_holds_the_run_and_resume_continues_it(self): + self.gui.repeat_mode_var.set("Infinite") + self.gui.stop_hotkey_var.set("f24") + self.gui.delay_var.set("0.02") + self.gui.startclick() + self.pump(0.4) + self.gui.toggle_pause() + self.pump(0.3) + at_pause = len(self.clicks()) + self.pump(0.5) + after_wait = len(self.clicks()) + self.assertLessEqual(after_wait - at_pause, 1, "run kept clicking while paused") + self.assertEqual(self.gui.pause_button.cget("text"), "Resume") + + self.gui.toggle_pause() + self.pump(0.4) + self.assertGreater(len(self.clicks()), after_wait, "run did not resume") + + def test_stop_ends_the_run_and_restores_the_buttons(self): + self.gui.repeat_mode_var.set("Infinite") + self.gui.stop_hotkey_var.set("f24") + self.gui.startclick() + self.pump(0.3) + self.gui.stopclick() + self.pump(0.6) + self.assertFalse(self.gui.worker_thread and self.gui.worker_thread.is_alive()) + self.assertEqual(str(self.gui.start_button.cget("state")), "normal") + self.assertEqual(str(self.gui.stop_button.cget("state")), "disabled") + + def test_a_stale_finish_cannot_tear_down_a_newer_run(self): + # The pump runs every 100ms; a run started inside that window used to be orphaned + # by the previous run's "finished" message, leaving Stop dead while clicks flowed. + self.gui.repeat_mode_var.set("Burst Count") + self.gui.repeat_count_var.set("200") + self.gui.delay_var.set("0.05") + self.gui.startclick() + self.pump(0.2) + self.gui._finish_run(1, 0.1, (0, 0), generation=self.gui.active_run_generation - 1) + self.assertTrue(self.gui.worker_thread and self.gui.worker_thread.is_alive()) + self.assertEqual(str(self.gui.stop_button.cget("state")), "normal") + + def test_countdown_is_not_charged_against_the_runtime_cap(self): + # A 0.5s countdown with a 0.4s cap used to break on the first iteration: 0 actions. + self.gui.repeat_mode_var.set("Infinite") + self.gui.stop_hotkey_var.set("f24") + self.gui.countdown_var.set("0.5") + self.gui.runtime_limit_var.set("0.4") + self.gui.startclick() + self.pump(1.8) + self.assertGreater(len(self.clicks()), 0) + + def test_an_unpollable_stop_hotkey_is_rejected_before_the_run(self): + self.gui.stop_hotkey_var.set("a, b") + with self.assertRaises(ValueError): + self.gui._build_run_config() + + def test_round_robin_cycles_every_recorded_point(self): + self.gui.recording_data = [(10, 10), (20, 20), (30, 30)] + self.gui.round_robin_var.set(True) + self.gui.repeat_mode_var.set("Burst Count") + self.gui.repeat_count_var.set("6") + self.gui.startclick() + self.pump(2.0) + points = {(call[1]["x"], call[1]["y"]) for call in self.clicks()} + self.assertEqual(points, {(10, 10), (20, 20), (30, 30)}) + + def test_stop_tokens_are_independent_per_subsystem(self): + # One shared Event let a finishing worker un-stop a running one. + self.gui.stop_event.set() + self.gui.playback_stop_event.set() + self.gui.playback_stop_event.clear() + self.assertTrue(self.gui.stop_event.is_set()) + self.gui.stop_event.clear() + + def test_finished_runs_are_written_to_the_persistent_log(self): + self.gui.repeat_mode_var.set("Burst Count") + self.gui.repeat_count_var.set("4") + self.gui.startclick() + self.pump(2.0) + records = AutoClicker._read_run_log( + AutoClicker._state_file_location(AutoClicker.RUN_LOG_FILE_NAME) + ) + self.assertTrue(records) + self.assertGreater(AutoClicker._summarize_run_history(records)["actions"], 0) + + +class ThemeTests(GuiTestCase): + def tearDown(self): + self.gui.theme_var.set("Light") + self.gui._apply_theme() + + def test_repeated_theme_switches_never_corrupt_the_palette(self): + # Themes used to be re-derived from live colours, and Light hero_bg equals Dark + # main_bg, so Dark -> Light left the page dark and the next switch made text + # unreadable. Roles are now captured once from the authored colour. + expected = {"Light": "#dbe7f2", "Dark": "#0f172a", "Ocean": "#d8edf2", "Midnight": "#08070f"} + for name in ("Light", "Dark", "Ocean", "Midnight", "Light", "Dark", "Light", "Ocean", "Light"): + with self.subTest(theme=name): + self.gui.theme_var.set(name) + self.gui._apply_theme() + self.gui.update() + self.assertEqual(self.gui.cget("bg"), expected[name]) + + def test_system_theme_resolves_to_a_real_palette(self): + self.gui.theme_var.set("System") + palette = self.gui._theme_palette() + self.assertIn(palette["main_bg"], ("#dbe7f2", "#0f172a")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 214510b..3a562f4 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -37,7 +37,7 @@ def test_headless_health_report_has_core_sections(self): report = AutoClicker._build_headless_health_report() self.assertIn("AutoClicker Health Check", report) - self.assertIn("App version: V10.1", report) + self.assertIn(f"App version: {AutoClicker.APP_VERSION}", report) self.assertIn("Dependencies", report) self.assertIn("State Files", report) @@ -46,7 +46,7 @@ def test_headless_health_data_is_machine_readable(self): with mock.patch.dict(os.environ, {"APPDATA": temp_dir}, clear=False): data = AutoClicker._collect_headless_health_data() - self.assertEqual(data["app_version"], "V10.1") + self.assertEqual(data["app_version"], AutoClicker.APP_VERSION) self.assertIn("dependencies", data) self.assertIn("profiles", data["state_files"]) self.assertEqual( @@ -67,7 +67,7 @@ def test_session_report_payload_keeps_recent_entries(self): {"workspace": "workspace.json"}, ) - self.assertEqual(payload["app_version"], "V10.1") + self.assertEqual(payload["app_version"], AutoClicker.APP_VERSION) self.assertEqual(payload["profile_data"], profile_data) self.assertEqual(len(payload["activity_history"]), 80) self.assertEqual(payload["activity_history"][0], "activity 20") diff --git a/tests/test_packaging.py b/tests/test_packaging.py new file mode 100644 index 0000000..8e0cf9f --- /dev/null +++ b/tests/test_packaging.py @@ -0,0 +1,241 @@ +"""Guards on packaging metadata and repository hygiene.""" + +import os +import re +import subprocess +import unittest + +import AutoClicker + +REPO_ROOT = os.path.dirname(os.path.abspath(AutoClicker.__file__)) + + +def read(*parts): + with open(os.path.join(REPO_ROOT, *parts), "r", encoding="utf-8") as handle: + return handle.read() + + +class VersionConsistencyTests(unittest.TestCase): + def test_build_script_derives_the_version_from_the_app(self): + # The version used to be hard-coded in three files and drifted between them. + import importlib.util + + spec = importlib.util.spec_from_file_location( + "build_release", os.path.join(REPO_ROOT, "packaging", "build_release.py") + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + self.assertEqual(module.VERSION, AutoClicker.APP_VERSION) + + def test_installer_derives_the_version_from_the_app(self): + import importlib.util + + spec = importlib.util.spec_from_file_location( + "acinstaller", os.path.join(REPO_ROOT, "packaging", "installer.py") + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + self.assertEqual(module.VERSION, AutoClicker.APP_VERSION) + + def test_readme_mentions_the_current_version(self): + self.assertIn(AutoClicker.APP_VERSION, read("README.md")) + + +class SecretHygieneTests(unittest.TestCase): + def test_no_credential_files_are_tracked(self): + result = subprocess.run( + ["git", "ls-files"], cwd=REPO_ROOT, capture_output=True, text=True, timeout=60 + ) + if result.returncode != 0: + self.skipTest("not a git checkout") + offenders = [ + path for path in result.stdout.splitlines() + if re.search(r"(^|/)creds\.json$|\.pem$|\.key$", path) + ] + self.assertEqual(offenders, [], f"Credential files are tracked: {offenders}") + + def test_gitignore_blocks_credential_files(self): + gitignore = read(".gitignore") + self.assertIn("creds.json", gitignore) + + def test_no_private_key_material_in_source_files(self): + for name in ("AutoClicker.py", "lite-version.py", "requirements.txt", "README.md"): + with self.subTest(file=name): + self.assertNotIn("PRIVATE KEY", read(name)) + + +class RequirementsTests(unittest.TestCase): + def test_retired_dependencies_are_gone(self): + requirements = read("requirements.txt") + source = read("AutoClicker.py") + for retired in ("gspread", "oauth2client", "colormap", "pywin32"): + with self.subTest(package=retired): + # Comments explain the removal, so only check real requirement lines. + lines = [ + line.split("#")[0].strip() + for line in requirements.splitlines() + if line.strip() and not line.strip().startswith("#") + ] + self.assertFalse( + any(line.startswith(retired) for line in lines), + f"{retired} is still required but never imported", + ) + self.assertNotIn(f"import {retired}", source) + + def test_every_catalogued_dependency_is_declared(self): + requirements = read("requirements.txt").lower() + for name in AutoClicker.DEPENDENCY_CATALOGUE: + with self.subTest(package=name): + self.assertIn(name.lower(), requirements) + + def test_required_dependencies_are_marked_required(self): + catalogue = AutoClicker.DEPENDENCY_CATALOGUE + self.assertTrue(catalogue["pyautogui"]["required"]) + self.assertTrue(catalogue["keyboard"]["required"]) + self.assertFalse(catalogue["pystray"]["required"]) + + def test_opencv_is_declared_because_photo_clicker_passes_confidence(self): + # pyautogui raises unless OpenCV is installed when confidence= is used. + self.assertIn("confidence=", read("AutoClicker.py")) + self.assertIn("opencv-python", read("requirements.txt")) + + +class DeadCodeTests(unittest.TestCase): + def test_no_duplicate_top_level_definitions(self): + import ast + import collections + + tree = ast.parse(read("AutoClicker.py")) + counts = collections.Counter( + node.name for node in tree.body + if isinstance(node, (ast.FunctionDef, ast.ClassDef)) + ) + duplicates = {name: count for name, count in counts.items() if count > 1} + self.assertEqual(duplicates, {}, f"Shadowed top-level definitions: {duplicates}") + + def test_no_duplicate_methods_within_a_class(self): + import ast + import collections + + tree = ast.parse(read("AutoClicker.py")) + for node in ast.walk(tree): + if not isinstance(node, ast.ClassDef): + continue + counts = collections.Counter( + child.name for child in node.body + if isinstance(child, (ast.FunctionDef, ast.ClassDef)) + ) + duplicates = {name: count for name, count in counts.items() if count > 1} + self.assertEqual(duplicates, {}, f"{node.name} has shadowed methods: {duplicates}") + + def test_retired_dead_functions_are_gone(self): + source = read("AutoClicker.py") + self.assertNotIn("def MAINWINDOW_NEWSTYLE", source) + self.assertNotIn("class Coordinates(", source) + + def test_no_function_hides_its_body_behind_a_main_guard(self): + # `if __name__ == '__main__'` nested inside OldStyleGUI() made the menu item a + # silent no-op whenever the module was imported instead of run directly. + import ast + + def is_main_guard(test): + return ( + isinstance(test, ast.Compare) + and isinstance(test.left, ast.Name) + and test.left.id == "__name__" + and any(isinstance(c, ast.Constant) and c.value == "__main__" for c in test.comparators) + ) + + tree = ast.parse(read("AutoClicker.py")) + offenders = [ + f"{node.name}:{child.lineno}" + for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) + for child in ast.walk(node) + if isinstance(child, ast.If) and is_main_guard(child.test) + ] + self.assertEqual(offenders, [], f"Nested __main__ guards found: {offenders}") + + + + +class LegacyWindowDefectTests(unittest.TestCase): + """Guards on defects that lived in the classic (Old Style GUI) window.""" + + def test_no_global_declaration_for_a_closure_local(self): + # `global things` targeted a name that only exists as a closure local, so the + # classic List Coordinates delete always raised NameError. Checked via AST so a + # comment describing the old bug does not trip the test. + import ast + + tree = ast.parse(read("AutoClicker.py")) + module_level = { + target.id + for node in tree.body if isinstance(node, ast.Assign) + for target in node.targets if isinstance(target, ast.Name) + } + offenders = [ + (node.lineno, name) + for node in ast.walk(tree) if isinstance(node, ast.Global) + for name in node.names if name not in module_level + ] + self.assertEqual(offenders, [], f"`global` on names with no module-level binding: {offenders}") + + def test_no_eval_or_exec_anywhere(self): + # Listbox rows are built from user-entered X/Y text, and eval() executed them. + import ast + + tree = ast.parse(read("AutoClicker.py")) + offenders = [ + (node.lineno, node.func.id) + for node in ast.walk(tree) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + and node.func.id in ("eval", "exec") + ] + self.assertEqual(offenders, [], f"eval/exec calls at {offenders}") + + def test_no_exit_calls_inside_worker_threads(self): + # `exit(0)` raises SystemExit, which on a worker thread kills only that thread + # with no status update, leaving the UI showing a run that has already died. + import ast + + tree = ast.parse(read("AutoClicker.py")) + offenders = [ + node.lineno + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id in ("exit", "quit") + ] + self.assertEqual(offenders, [], f"bare exit()/quit() calls at lines {offenders}") + + def test_os_exit_is_not_used_for_the_emergency_hotkey(self): + # os._exit skips the workspace save, discarding unsaved recordings and layout. + source = read("AutoClicker.py") + self.assertNotIn('add_hotkey("ctrl+shift+k", lambda: os._exit(0))', source) + + +class ValidationBoundsTests(unittest.TestCase): + def test_opacity_validation_matches_the_runtime_clamp(self): + low, high = AutoClicker.WINDOW_OPACITY_RANGE + inside = AutoClicker._validate_profile_data("p", {"window_opacity": str(low)}) + self.assertEqual(inside["warnings"], []) + outside = AutoClicker._validate_profile_data("p", {"window_opacity": str(low - 0.2)}) + self.assertTrue(outside["warnings"]) + + def test_ui_scale_validation_matches_the_runtime_clamp(self): + low, high = AutoClicker.UI_SCALE_RANGE + inside = AutoClicker._validate_profile_data("p", {"ui_scale": str(high)}) + self.assertEqual(inside["warnings"], []) + outside = AutoClicker._validate_profile_data("p", {"ui_scale": str(high + 0.5)}) + self.assertTrue(outside["warnings"]) + + def test_new_action_types_pass_profile_validation(self): + for action in ("Scroll Down", "Key Press", "Drag To"): + with self.subTest(action=action): + result = AutoClicker._validate_profile_data("p", {"click_mode": action}) + self.assertTrue(result["valid"], result["errors"]) + + +if __name__ == "__main__": + unittest.main()