|
| 1 | +import threading |
| 2 | +import time |
| 3 | +import sys |
| 4 | +import pydirectinput |
| 5 | +import keyboard # for hotkey start/stop |
| 6 | +import ttkbootstrap as tb |
| 7 | +from ttkbootstrap.constants import * |
| 8 | +from tkinter import Toplevel, PhotoImage |
| 9 | +from tkinter import Frame, LEFT, BOTH, YES, X, Y, RIGHT, TOP, BOTTOM, HORIZONTAL, VERTICAL |
| 10 | + |
| 11 | +# ----------------------------- |
| 12 | +# Configuration |
| 13 | +# ----------------------------- |
| 14 | +TOOL_NAME = "SimpleKeyClicker" |
| 15 | +POSSIBLE_KEYS = """ |
| 16 | +Possible Keys/Mouse Actions: |
| 17 | +- Single keys: a, b, c, ..., z |
| 18 | +- Digits: 0-9 |
| 19 | +- Special keys: TAB, SPACE, ENTER, ESC, SHIFT, CTRL, ALT, BACKSPACE |
| 20 | +- Mouse clicks: click (left click), rclick (right click), mclick (middle click) |
| 21 | +- Other keys: up, down, left, right, home, end, pageup, pagedown |
| 22 | +- Characters: !, @, #, $, %, ^, &, *, (, ), -, _, =, +, [, ], {, }, ;, :, ', ", \\, |, ,, <, ., >, /, ? |
| 23 | +""" |
| 24 | + |
| 25 | +ICON_PATH = "logo.ico" |
| 26 | +LOGO_PATH = "logo.png" # For the UI banner and error windows |
| 27 | + |
| 28 | +class KeyClickerApp: |
| 29 | + def __init__(self, root): |
| 30 | + self.root = root |
| 31 | + self.root.title(TOOL_NAME) |
| 32 | + self.root.geometry("500x250") |
| 33 | + self.root.resizable(False, True) |
| 34 | + self.root.minsize(500, 250) |
| 35 | + # Set window icon if available |
| 36 | + try: |
| 37 | + self.root.iconbitmap(ICON_PATH) |
| 38 | + except: |
| 39 | + pass |
| 40 | + |
| 41 | + # Style and theme |
| 42 | + self.style = tb.Style("sandstone") # use any ttkbootstrap theme you prefer |
| 43 | + |
| 44 | + # Main frame |
| 45 | + self.main_frame = tb.Frame(self.root, padding=10) |
| 46 | + self.main_frame.pack(fill=BOTH, expand=YES) |
| 47 | + |
| 48 | + # ----------------------------- |
| 49 | + # Top Frame (Row 1: Logo & Buttons) |
| 50 | + # ----------------------------- |
| 51 | + self.top_frame = tb.Frame(self.main_frame) |
| 52 | + self.top_frame.pack(fill=X, pady=(0,5)) |
| 53 | + |
| 54 | + # Create a horizontal container for the logo and text/buttons |
| 55 | + logo_btn_frame = tb.Frame(self.top_frame) |
| 56 | + logo_btn_frame.pack(fill=X, pady=5) |
| 57 | + |
| 58 | + # Logo |
| 59 | + try: |
| 60 | + self.logo_image = PhotoImage(file=LOGO_PATH) |
| 61 | + logo_label = tb.Label(logo_btn_frame, image=self.logo_image) |
| 62 | + logo_label.pack(side=LEFT, padx=(5, 20)) |
| 63 | + except: |
| 64 | + pass |
| 65 | + |
| 66 | + # Info button |
| 67 | + self.info_button = tb.Button(logo_btn_frame, text="Info (Keys)", bootstyle=INFO, command=self.show_info) |
| 68 | + self.info_button.pack(side=LEFT, padx=5) |
| 69 | + |
| 70 | + # Start/Stop Buttons |
| 71 | + self.start_button = tb.Button(logo_btn_frame, text="Start", padding=(40, 6), bootstyle=PRIMARY, command=self.start_action) |
| 72 | + self.start_button.pack(side=LEFT, padx=5) |
| 73 | + |
| 74 | + self.stop_button = tb.Button(logo_btn_frame, text="Stop", padding=(40, 6), bootstyle=DANGER, command=self.stop_action) |
| 75 | + self.stop_button.pack(side=LEFT, padx=5) |
| 76 | + |
| 77 | + # Hint label |
| 78 | + tb.Label(self.top_frame, text="Hint: Press Ctrl+F2 to Start, Ctrl+F3 to Stop", |
| 79 | + font=("Helvetica", 10, "italic")).pack(pady=(5,0)) |
| 80 | + |
| 81 | + # ----------------------------- |
| 82 | + # Bottom Frame (Row 2: Scrollable Action Rows) |
| 83 | + # ----------------------------- |
| 84 | + self.bottom_frame = tb.Frame(self.main_frame) |
| 85 | + self.bottom_frame.pack(fill=BOTH, expand=YES, pady=(10, 0)) |
| 86 | + |
| 87 | + # Add Row button (at the top of bottom frame) |
| 88 | + add_row_frame = tb.Frame(self.bottom_frame) |
| 89 | + add_row_frame.pack(fill=X) |
| 90 | + tb.Button(add_row_frame, text="Add Row", bootstyle=SUCCESS, command=self._add_row).pack(side=LEFT, padx=5, pady=5) |
| 91 | + |
| 92 | + # Scrollable area |
| 93 | + self.canvas = tb.Canvas(self.bottom_frame, highlightthickness=0) |
| 94 | + self.canvas.pack(side=LEFT, fill=BOTH, expand=YES) |
| 95 | + |
| 96 | + self.scrollbar = tb.Scrollbar(self.bottom_frame, orient=VERTICAL, command=self.canvas.yview) |
| 97 | + self.scrollbar.pack(side=RIGHT, fill=Y) |
| 98 | + |
| 99 | + self.canvas.configure(yscrollcommand=self.scrollbar.set) |
| 100 | + |
| 101 | + # A frame inside the canvas |
| 102 | + self.rows_container = tb.Frame(self.canvas) |
| 103 | + self.canvas.create_window((0,0), window=self.rows_container, anchor="nw") |
| 104 | + |
| 105 | + self.rows_container.bind("<Configure>", lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all"))) |
| 106 | + |
| 107 | + # Store rows |
| 108 | + self.rows = [] |
| 109 | + |
| 110 | + # Add the first mandatory row |
| 111 | + self._add_row(mandatory=True) |
| 112 | + |
| 113 | + # State variables |
| 114 | + self.running = False |
| 115 | + self.thread = None |
| 116 | + |
| 117 | + # Setup hotkeys |
| 118 | + keyboard.add_hotkey('ctrl+f2', self.start_action) |
| 119 | + keyboard.add_hotkey('ctrl+f3', self.stop_action) |
| 120 | + |
| 121 | + def _add_row(self, mandatory=False): |
| 122 | + """Add a new row for key/sleep input and center it.""" |
| 123 | + row_frame = tb.Frame(self.rows_container) |
| 124 | + row_frame.pack(fill=X, pady=2) |
| 125 | + |
| 126 | + sub_frame = tb.Frame(row_frame) |
| 127 | + sub_frame.pack(anchor='center') |
| 128 | + |
| 129 | + key_var = tb.StringVar(value="") |
| 130 | + sleep_var = tb.StringVar(value="0.5") # default sleep time |
| 131 | + |
| 132 | + tb.Label(sub_frame, text="Key/Button:", width=12).pack(side=LEFT) |
| 133 | + tb.Entry(sub_frame, textvariable=key_var, width=20).pack(side=LEFT, padx=5) |
| 134 | + |
| 135 | + tb.Label(sub_frame, text="Delay (s):", width=10).pack(side=LEFT) |
| 136 | + tb.Entry(sub_frame, textvariable=sleep_var, width=10).pack(side=LEFT, padx=5) |
| 137 | + |
| 138 | + remove_btn = None |
| 139 | + if not mandatory: |
| 140 | + remove_btn = tb.Button(sub_frame, text="Remove", bootstyle=DANGER, command=lambda f=row_frame: self._remove_row(f)) |
| 141 | + remove_btn.pack(side=LEFT, padx=5) |
| 142 | + |
| 143 | + self.rows.append({ |
| 144 | + 'frame': row_frame, |
| 145 | + 'key_var': key_var, |
| 146 | + 'sleep_var': sleep_var, |
| 147 | + 'remove_btn': remove_btn, |
| 148 | + 'mandatory': mandatory |
| 149 | + }) |
| 150 | + |
| 151 | + def _remove_row(self, frame): |
| 152 | + """Remove a row from the UI and the list.""" |
| 153 | + for r in self.rows: |
| 154 | + if r['frame'] == frame: |
| 155 | + r['frame'].destroy() |
| 156 | + self.rows.remove(r) |
| 157 | + break |
| 158 | + |
| 159 | + def show_info(self): |
| 160 | + info_win = Toplevel(self.root) |
| 161 | + info_win.title("Info - Possible Keys") |
| 162 | + try: |
| 163 | + info_win.iconbitmap(ICON_PATH) |
| 164 | + except: |
| 165 | + pass |
| 166 | + info_win.grab_set() |
| 167 | + tb.Label(info_win, text=POSSIBLE_KEYS, font=("Helvetica", 13), padding=20, justify=LEFT).pack() |
| 168 | + tb.Button(info_win, text="Close", bootstyle=PRIMARY, command=info_win.destroy).pack(pady=10) |
| 169 | + |
| 170 | + def show_custom_error(self, title, message): |
| 171 | + """Show a custom error window with the logo instead of the default icon.""" |
| 172 | + error_win = Toplevel(self.root) |
| 173 | + error_win.title(title) |
| 174 | + try: |
| 175 | + error_win.iconbitmap(ICON_PATH) |
| 176 | + except: |
| 177 | + pass |
| 178 | + error_win.grab_set() |
| 179 | + |
| 180 | + frm = tb.Frame(error_win, padding=10) |
| 181 | + frm.pack() |
| 182 | + |
| 183 | + # Load the logo image |
| 184 | + try: |
| 185 | + logo = PhotoImage(file=LOGO_PATH) |
| 186 | + logo_label = tb.Label(frm, image=logo) |
| 187 | + logo_label.image = logo # keep a reference so it's not garbage-collected |
| 188 | + logo_label.pack() |
| 189 | + except: |
| 190 | + pass |
| 191 | + |
| 192 | + tb.Label(frm, text=message, padding=10, justify=LEFT, foreground="red", font=("Helvetica", 12)).pack() |
| 193 | + tb.Button(frm, text="OK", bootstyle=PRIMARY, command=error_win.destroy).pack(pady=10) |
| 194 | + |
| 195 | + # Center the error window relative to the main window |
| 196 | + error_win.update_idletasks() |
| 197 | + x = self.root.winfo_x() + (self.root.winfo_width() // 2) - (error_win.winfo_width() // 2) |
| 198 | + y = self.root.winfo_y() + (self.root.winfo_height() // 2) - (error_win.winfo_height() // 2) |
| 199 | + error_win.geometry(f"+{x}+{y}") |
| 200 | + |
| 201 | + def start_action(self): |
| 202 | + if self.running: |
| 203 | + return |
| 204 | + # Validate input |
| 205 | + for r in self.rows: |
| 206 | + if r['key_var'].get().strip() == "": |
| 207 | + self.show_custom_error("Error", "Please specify a key/button in all rows.") |
| 208 | + return |
| 209 | + try: |
| 210 | + float(r['sleep_var'].get()) |
| 211 | + except ValueError: |
| 212 | + self.show_custom_error("Error", f"Invalid delay value: {r['sleep_var'].get()}") |
| 213 | + return |
| 214 | + |
| 215 | + self.running = True |
| 216 | + self.thread = threading.Thread(target=self._run_loop, daemon=True) |
| 217 | + self.thread.start() |
| 218 | + |
| 219 | + def stop_action(self): |
| 220 | + self.running = False |
| 221 | + |
| 222 | + def _run_loop(self): |
| 223 | + # Loop indefinitely until stopped |
| 224 | + while self.running: |
| 225 | + for r in self.rows: |
| 226 | + if not self.running: |
| 227 | + break |
| 228 | + key = r['key_var'].get().strip() |
| 229 | + delay = float(r['sleep_var'].get()) |
| 230 | + self._perform_action(key) |
| 231 | + time.sleep(delay) |
| 232 | + |
| 233 | + def _perform_action(self, key): |
| 234 | + # Determine if it's a mouse action or a key press |
| 235 | + k = key.lower() |
| 236 | + if k == "click": |
| 237 | + pydirectinput.click() |
| 238 | + elif k == "rclick": |
| 239 | + pydirectinput.rightClick() |
| 240 | + elif k == "mclick": |
| 241 | + pydirectinput.middleClick() |
| 242 | + else: |
| 243 | + pydirectinput.press(key) |
| 244 | + |
| 245 | +# ----------------------------- |
| 246 | +# Main |
| 247 | +# ----------------------------- |
| 248 | +if __name__ == "__main__": |
| 249 | + root = tb.Window(themename="cosmo") |
| 250 | + app = KeyClickerApp(root) |
| 251 | + root.mainloop() |
0 commit comments