Gamepad tester and button mapper
Plug in a USB controller and press any button so the browser can see it. Then press Learn buttons and follow the prompts. Each button lights up on the drawing, and the map is saved in this browser and can be copied out as JSON. Built for N64 USB adapters, and it works with any pad. Nothing leaves your machine.
Controller
Stick: learn the stick to see its range.
Raw input
Every button (B) and axis (A) exactly as the browser numbers them. Use this when something on the drawing does not light up.
Button map (JSON)
A game reads this to turn the controller's numbers into named buttons. "t":"button" is a button index. "t":"axis" means the button reports through an axis, at the value shown.
Assign the controls
Choose what each button does when the controller is your mouse. Your choice is saved in this browser and goes inside the program when you download it. Press a button on the controller and its row lights up, once the buttons are learned above.
Stick feel
The pointer creeps near the centre for fine control and speeds up towards the edge. A stronger curve gives more room for fine work. Edge boost speeds the pointer up further when you hold the stick at its rim, so long trips stay quick.
Always, whatever you choose: Z and Start together open Claude, L and R together pause the mouse so a game can have the controller back, and L, R and Start together quit.
Use your controller as a mouse
A web page cannot move your pointer or install anything, so this part is a small program you download and start once. One Python file works on macOS, Windows and Linux. The stick moves the pointer with the feel you set above, and each button does what you assigned it. It runs only on your computer and sends nothing anywhere.
The program uses the controls you assigned above. Desktop programs number a controller's buttons differently from the browser, so the program cannot reuse the map learned above. On its first run it asks for the stick and each button once more, which takes about half a minute.
- Install Python 3.9 or newer from python.org, if you do not have it.
- It saves to your Downloads folder.
- Open a terminal, paste this line and press Enter:
python3 ~/Downloads/gamepad_mouse.py
On Windows, in Command Prompt:py %USERPROFILE%\Downloads\gamepad_mouse.py - The first run asks before installing the two libraries it needs into a private folder of its own (about 100 MB), then learns your controller.
- On a Mac, allow Terminal under System Settings, Privacy & Security, Accessibility, then quit Terminal, reopen it and run the line again. On Linux, use an X11 session: Wayland does not let programs move the pointer. Windows needs nothing extra.
- To stop, press L, R and Start together, or Ctrl+C in the terminal. To start again later, run the same line.
Read the program before you run it
#!/usr/bin/env python3
r"""Gamepad mouse: use a USB game controller as a mouse on macOS, Windows or Linux.
From highlandwillow.com/tools/gamepad-tester. Built for N64 USB adapters, works with most pads.
Free to use, copy and change. Runs entirely on your computer and sends nothing anywhere.
SETUP
1. Install Python 3.9 or newer from python.org, if you do not have it.
2. Run this file:
macOS / Linux python3 ~/Downloads/gamepad_mouse.py
Windows py %USERPROFILE%\Downloads\gamepad_mouse.py
The first run asks before installing the two libraries it needs (pygame-ce reads the
controller, pynput moves the pointer) into a private folder of its own, about 100 MB. Nothing
else on your computer changes. Then it learns your controller: push the stick, then press
each button it names. A button you leave alone for 10 seconds is skipped.
The libraries and your controller map live in ~/.gamepad_mouse (Windows: %LOCALAPPDATA%\GamepadMouse).
PERMISSIONS
macOS System Settings > Privacy & Security > Accessibility: switch on the app you run this
from (Terminal, for example), then quit and reopen that app. Without it the pointer
does not move. On a Mac, a controller's Home button opens the Games app; if the
tester shows one of your buttons doing that, leave it out when learning.
Windows Nothing to allow. Pointer control does not work over windows running as administrator
unless this does too.
Linux Works on an X11 session. Wayland sessions block programs from moving the pointer.
CONTROLS (defaults; choose your own on the tester page, or edit ACTIONS_FROM_PAGE below)
stick move the pointer: slow near the centre, faster to the edge, and faster still if
held at the edge (curve, dead zone, speed and edge boost are set on the page)
Z held slow, precise pointer
A left button (hold = drag) B right click
C up/down scroll C left/right scroll sideways
D-pad up/dn Page Up / Page Down D-pad l/r previous / next tab
L / R Back / Forward Start Enter
Always: Z + Start opens Claude (the app if installed, else claude.ai)
L + R pauses or resumes, so a game can have the controller back
L + R + Start together, or Ctrl+C in this window, quits
ALWAYS ON
python3 gamepad_mouse.py --install-autostart start at login and wait for the controller
python3 gamepad_mouse.py --uninstall-autostart stop doing that
(Windows: py instead of python3.) It copies itself into the private folder, so you can delete
the download afterwards. Learn the controller once in a terminal first. On a Mac the
background copy runs as Python rather than Terminal, so macOS asks once more for Accessibility.
Unplugging is fine: it waits, and carries on when the controller is plugged back in.
Log: log.txt in the private folder.
OPTIONS
--learn learn the controller again --show print raw buttons and axes
--background no prompts; wait for a controller (what autostart runs)
"""
import json
import os
import shutil
import subprocess
import sys
import time
WIN = sys.platform == "win32"
APP_DIR = os.environ.get("GAMEPAD_MOUSE_HOME") or (
os.path.join(os.environ.get("LOCALAPPDATA", os.path.expanduser("~")), "GamepadMouse") if WIN
else os.path.join(os.path.expanduser("~"), ".gamepad_mouse"))
os.environ.setdefault("PYGAME_HIDE_SUPPORT_PROMPT", "1")
# Keep reading the controller while another window has focus, which is the whole point.
os.environ.setdefault("SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS", "1")
# No dummy video driver: on macOS it stops SDL seeing the controller at all (tested 2026-11-10).
try:
import pygame
from pynput.mouse import Button, Controller as Mouse
from pynput.keyboard import Key, Controller as Keyboard
except ImportError as e:
# First run: put the two libraries in a private virtual environment and run again inside it,
# so nothing is installed into the system Python.
venv_dir = os.path.join(APP_DIR, "venv")
vpy = os.path.join(venv_dir, "Scripts", "python.exe") if WIN else os.path.join(venv_dir, "bin", "python3")
manual = ("Install them yourself instead with:\n "
+ ("py" if WIN else "python3") + " -m pip install pygame-ce pynput")
if os.environ.get("GAMEPAD_MOUSE_BOOTSTRAPPED"):
sys.exit(f"Could not load {e.name} even after installing it. " + manual)
if not os.path.exists(vpy):
print(f"First run. This needs two libraries, pygame-ce and pynput (about 100 MB).\n"
f"They go into {venv_dir} and nothing else on your computer changes.")
if input("Install them now? [Y/n] ").strip().lower() in ("n", "no"):
sys.exit(manual)
import venv
try:
venv.create(venv_dir, with_pip=True)
except Exception as err:
hint = "\nOn Debian or Ubuntu, first run: sudo apt install python3-venv" if sys.platform.startswith("linux") else ""
sys.exit(f"Could not make the private folder ({err}).{hint}\n" + manual)
rc = subprocess.call([vpy, "-m", "pip", "install", "--disable-pip-version-check", "-q",
"pygame-ce", "pynput"])
if rc:
sys.exit("Installing the libraries failed (see above). " + manual)
env = dict(os.environ, GAMEPAD_MOUSE_BOOTSTRAPPED="1")
sys.exit(subprocess.call([vpy, os.path.abspath(__file__)] + sys.argv[1:], env=env))
MAC = sys.platform == "darwin"
MAPS_PATH = os.path.join(APP_DIR, "maps.json")
STEPS = [
("a", "A"), ("b", "B"), ("z", "Z (trigger underneath)"), ("start", "Start"),
("l", "L"), ("r", "R"),
("cu", "C up"), ("cd", "C down"), ("cl", "C left"), ("cr", "C right"),
("du", "D-pad up"), ("dd", "D-pad down"), ("dl", "D-pad left"), ("dr", "D-pad right"),
]
# Tuning
TICK_HZ = 120
DEADZONE = 0.12 # share of the stick's full reach that does nothing
MAX_SPEED = 1800.0 # pixels per second at full tilt, before edge boost
SLOW_FACTOR = 0.25 # while Z is held
CURVE = 2.6 # above 1 gives finer control near the centre; 1 is straight
EDGE_BOOST = True # holding the stick at its rim speeds the pointer up
BOOST_MAX = 2.5 # top speed multiplier at the rim
BOOST_DELAY = 0.3 # seconds at the rim before the boost starts
BOOST_RAMP = 0.8 # seconds from no boost to full boost
SCROLL_STEPS = 14.0 # scroll steps per second while a C button is held
KEY_REPEAT_DELAY = 0.35
KEY_REPEAT_RATE = 0.045
DOUBLE_CLICK_S = 0.4
LEARN_TIMEOUT = 10.0
# What each button does. Every name below is one of ACTIONS. The tester page at
# highlandwillow.com/tools/gamepad-tester lets you choose these and puts your choice into the
# ACTIONS_FROM_PAGE line when you download; you can also edit that line by hand.
ACTIONS = ("none", "left_click", "right_click", "middle_click", "precision",
"scroll_up", "scroll_down", "scroll_left", "scroll_right",
"back", "forward", "prev_tab", "next_tab", "new_tab", "close_tab", "reopen_tab",
"page_up", "page_down", "home", "end",
"arrow_up", "arrow_down", "arrow_left", "arrow_right",
"enter", "escape", "space", "tab", "zoom_in", "zoom_out",
"copy", "paste", "undo", "overview", "app_switch", "open_claude")
DEFAULT_ACTIONS = {
"a": "left_click", "b": "right_click", "z": "precision", "start": "enter",
"l": "back", "r": "forward",
"cu": "scroll_up", "cd": "scroll_down", "cl": "scroll_left", "cr": "scroll_right",
"du": "page_up", "dd": "page_down", "dl": "prev_tab", "dr": "next_tab",
}
ACTIONS_FROM_PAGE = None
# ---------------------------------------------------------------- controller
class Unplugged(Exception):
pass
def open_pad(wait_s=3.0, forever=False):
pygame.init()
pygame.display.init()
pygame.joystick.init()
t = time.time()
said = False
while forever or time.time() - t < wait_s:
pygame.event.get()
if pygame.joystick.get_count():
break
if forever and not said:
print(time.strftime("%H:%M:%S"), "waiting for a controller", flush=True)
said = True
time.sleep(0.25 if forever else 0.02)
if not pygame.joystick.get_count():
sys.exit("No controller found. Check it is plugged in, then run this again.")
pad = pygame.joystick.Joystick(0)
if not pad.get_init():
pad.init()
for _ in range(10):
pygame.event.pump()
time.sleep(0.01)
return pad
def snapshot(pad):
# Draining the queue keeps it from filling during long runs, and is how an unplug is seen.
for e in pygame.event.get():
if e.type == pygame.JOYDEVICEREMOVED:
raise Unplugged()
return ([bool(pad.get_button(i)) for i in range(pad.get_numbuttons())],
[pad.get_axis(i) for i in range(pad.get_numaxes())],
[pad.get_hat(i) for i in range(pad.get_numhats())])
def is_down(b, snap):
btn, ax, hats = snap
if b is None:
return False
if b["t"] == "button":
return b["i"] < len(btn) and btn[b["i"]]
if b["t"] == "axis":
return b["i"] < len(ax) and (ax[b["i"]] - b["rest"]) * b["sign"] > 0.5
if b["t"] == "hat":
return b["i"] < len(hats) and hats[b["i"]][b["c"]] == b["v"]
return False
def same(a, b):
return all(a.get(k) == b.get(k) for k in ("t", "i", "sign", "c", "v"))
def detect(snap, base, skip=()):
btn, ax, hats = snap
bbtn, bax, bhats = base
for i, d in enumerate(btn):
if d and not bbtn[i]:
return {"t": "button", "i": i}
for i, h in enumerate(hats):
for c in (0, 1):
if h[c] != bhats[i][c] and h[c] != 0:
return {"t": "hat", "i": i, "c": c, "v": h[c]}
best, best_d = None, 0.5
for i, v in enumerate(ax):
if i in skip:
continue
d = abs(v - bax[i])
if d > best_d:
best, best_d = i, d
if best is not None:
return {"t": "axis", "i": best, "sign": 1 if ax[best] > bax[best] else -1,
"rest": round(bax[best], 3), "full": round(abs(ax[best] - bax[best]), 3)}
return None
def at_rest(snap, base, skip=()):
btn, ax, hats = snap
if any(d and not base[0][i] for i, d in enumerate(btn)):
return False
if hats != base[2]:
return False
return all(abs(v - base[1][i]) < 0.2 for i, v in enumerate(ax) if i not in skip)
def wait_for(pad, base, timeout=LEARN_TIMEOUT, hold=0.0, skip=()):
"""The first new input held for `hold` seconds, or None after `timeout`."""
t0 = time.time()
cand, since = None, 0.0
while time.time() - t0 < timeout:
snap = snapshot(pad)
b = detect(snap, base, skip)
if b is None:
cand = None
elif cand is None or not same(cand, b):
cand, since = b, time.time()
if cand is not None and time.time() - since >= hold:
if cand["t"] == "axis":
cand["full"] = round(max(cand.get("full", 0), abs(snap[1][cand["i"]] - base[1][cand["i"]])), 3)
return cand
time.sleep(1 / TICK_HZ)
return None
def wait_release(pad, base, skip=()):
while not at_rest(snapshot(pad), base, skip):
time.sleep(1 / TICK_HZ)
time.sleep(0.15)
# ---------------------------------------------------------------- maps
def load_maps():
try:
with open(MAPS_PATH) as f:
return json.load(f)
except (OSError, ValueError):
return {}
def save_map(m):
os.makedirs(APP_DIR, exist_ok=True)
maps = load_maps()
maps[m["guid"]] = m
with open(MAPS_PATH, "w") as f:
json.dump(maps, f, indent=2)
def desc(b):
if b["t"] == "button":
return f"button {b['i']}"
if b["t"] == "hat":
return f"hat {b['i']} {'xy'[b['c']]}={b['v']}"
return f"axis {b['i']} {'+' if b['sign'] > 0 else '-'}"
# ---------------------------------------------------------------- learn
def learn(pad):
print(f"Controller: {pad.get_name()} ({pad.get_numbuttons()} buttons, "
f"{pad.get_numaxes()} axes, {pad.get_numhats()} hats)")
print("Learning your controller. Hands off the stick for a second...")
time.sleep(1.0)
base = snapshot(pad)
m = {"guid": pad.get_guid(), "name": pad.get_name(),
"learned": time.strftime("%Y-%m-%d"), "buttons": {}, "stick": {}}
# The stick goes first so its axes can be ignored while buttons are learned. Learned last,
# a nudge on the stick during a button prompt was taken for that button.
for key, label in (("x", "RIGHT"), ("y", "DOWN")):
print(f" Push the stick fully {label} and hold it ... ", end="", flush=True)
taken = tuple(v["i"] for v in m["stick"].values())
b = wait_for(pad, base, hold=0.4, skip=taken)
if b is None or b["t"] != "axis":
print("not seen; the pointer will not move on this axis")
continue
m["stick"][key] = b
print(desc(b))
wait_release(pad, base)
stick_axes = tuple(v["i"] for v in m["stick"].values())
print(" Now the buttons. The stick is ignored from here on.")
for key, label in STEPS:
while True:
print(f" Press {label} ... ", end="", flush=True)
b = wait_for(pad, base, skip=stick_axes)
if b is None:
print(f"skipped (nothing in {LEARN_TIMEOUT:.0f} s)")
break
clash = [k for k, v in m["buttons"].items() if same(v, b)]
if clash:
print(f"that is already {clash[0].upper()}. Let go and try again.")
wait_release(pad, base, stick_axes)
continue
m["buttons"][key] = b
print(desc(b))
wait_release(pad, base, stick_axes)
break
save_map(m)
print(f"\nSaved to {MAPS_PATH}.\n")
return m
# ---------------------------------------------------------------- show
def show(pad):
print(f"Controller: {pad.get_name()} id {pad.get_guid()}")
print("Press things. Ctrl+C to stop.")
prev = snapshot(pad)
try:
while True:
s = snapshot(pad)
for i, (a, b) in enumerate(zip(prev[0], s[0])):
if a != b:
print(f"button {i} {'down' if b else 'up'}")
for i, (a, b) in enumerate(zip(prev[1], s[1])):
if abs(a - b) > 0.1:
print(f"axis {i} {b:+.2f}")
for i, (a, b) in enumerate(zip(prev[2], s[2])):
if a != b:
print(f"hat {i} {b}")
prev = s
time.sleep(1 / TICK_HZ)
except KeyboardInterrupt:
pass
# ---------------------------------------------------------------- run
def mac_allowed():
"""True, False, or None when it cannot be checked."""
if not MAC:
return None
try:
import Quartz
except ImportError:
return None
pre = getattr(Quartz, "CGPreflightPostEventAccess", None)
if pre is None:
return None
if pre():
return True
req = getattr(Quartz, "CGRequestPostEventAccess", None)
if req is not None:
req()
return False
def open_claude():
import webbrowser
if MAC and subprocess.call(["open", "-a", "Claude"], stderr=subprocess.DEVNULL) == 0:
return
if WIN:
try:
os.startfile("claude://")
return
except OSError:
pass
webbrowser.open("https://claude.ai")
def stick_vector(x, y, curve, deadzone):
"""Stick position (each -1..1, as a share of full reach) to pointer velocity (each -1..1).
The curve is applied to the distance from the centre, not to each axis, so a diagonal push
feels the same as a straight one. Inside the dead zone nothing moves; from its edge the
output rises from zero as ((r - dz) / (1 - dz)) ** curve."""
r = (x * x + y * y) ** 0.5
if r <= deadzone:
return 0.0, 0.0, 0.0
rr = min(r, 1.0)
out = ((rr - deadzone) / (1 - deadzone)) ** curve
return x / r * out, y / r * out, rr
def resolve_actions():
"""Page download > saved choice > defaults. A download from the tester page carries the
visitor's choices in ACTIONS_FROM_PAGE; they are saved so the always-on copy keeps them."""
path = os.path.join(APP_DIR, "actions.json")
acts = dict(DEFAULT_ACTIONS)
feel = {"speed": MAX_SPEED, "curve": CURVE, "deadzone": DEADZONE, "boost": EDGE_BOOST}
saved = None
try:
with open(path) as f:
saved = json.load(f)
except (OSError, ValueError):
pass
chosen = ACTIONS_FROM_PAGE or saved
if chosen:
acts.update({k: v for k, v in chosen.items() if k in DEFAULT_ACTIONS and v in ACTIONS})
num = lambda k, lo, hi: (isinstance(chosen.get(k), (int, float)) and not isinstance(chosen.get(k), bool)
and lo <= chosen[k] <= hi)
if num("_speed", 200, 6000):
feel["speed"] = float(chosen["_speed"])
if num("_curve", 1.0, 4.0):
feel["curve"] = float(chosen["_curve"])
if num("_deadzone", 0.0, 0.4):
feel["deadzone"] = float(chosen["_deadzone"])
if isinstance(chosen.get("_boost"), bool):
feel["boost"] = chosen["_boost"]
if ACTIONS_FROM_PAGE and ACTIONS_FROM_PAGE != saved:
os.makedirs(APP_DIR, exist_ok=True)
with open(path, "w") as f:
json.dump(ACTIONS_FROM_PAGE, f, indent=2)
return acts, feel
def run(pad, m):
"""Returns "quit" for the quit chord or Ctrl+C, "unplugged" if the controller goes away."""
if mac_allowed() is False:
sys.exit("macOS has not allowed this app to control the pointer yet.\n"
"Open System Settings > Privacy & Security > Accessibility, switch on the app you\n"
"ran this from (Python, if this is the background copy), then run this again.")
B, S = m["buttons"], m["stick"]
acts, feel = resolve_actions()
mouse, kb = Mouse(), Keyboard()
mod = Key.cmd if MAC else Key.ctrl
combos = {
"back": [Key.cmd, "["] if MAC else [Key.alt, Key.left],
"forward": [Key.cmd, "]"] if MAC else [Key.alt, Key.right],
"prev_tab": [Key.ctrl, Key.shift, Key.tab], "next_tab": [Key.ctrl, Key.tab],
"new_tab": [mod, "t"], "close_tab": [mod, "w"], "reopen_tab": [mod, Key.shift, "t"],
"zoom_in": [mod, "="], "zoom_out": [mod, "-"],
"copy": [mod, "c"], "paste": [mod, "v"], "undo": [mod, "z"],
"overview": [Key.ctrl, Key.up] if MAC else [Key.cmd, Key.tab],
"app_switch": [Key.cmd, Key.tab] if MAC else [Key.alt, Key.tab],
"home": [Key.home], "end": [Key.end], "enter": [Key.enter], "escape": [Key.esc],
"space": [Key.space], "tab": [Key.tab],
}
repeat_keys = {"page_up": Key.page_up, "page_down": Key.page_down, "arrow_up": Key.up,
"arrow_down": Key.down, "arrow_left": Key.left, "arrow_right": Key.right}
buttons_for = {"left_click": Button.left, "right_click": Button.right, "middle_click": Button.middle}
scrolls = {"scroll_up": (0, 1), "scroll_down": (0, -1), "scroll_left": (-1, 0), "scroll_right": (1, 0)}
CHORD_KEYS = ("l", "r", "start") # their one-shot actions fire on release, so chords stay free
def combo(keys):
for k in keys:
kb.press(k)
for k in reversed(keys):
kb.release(k)
def axis(b, snap):
"""One stick axis as a share of its learned full reach, -1..1."""
if not b:
return 0.0
v = (snap[1][b["i"]] - b["rest"]) * b["sign"] / max(b.get("full", 1.0), 0.3)
return max(-1.0, min(1.0, v))
def press_button(btn, down, clicks):
# macOS needs the click count on each event or a double click is never seen. pynput only
# sets it inside click(), so set its counter here; press() adds one before sending.
if MAC:
mouse._click = clicks - 1 if down else clicks
(mouse.press if down else mouse.release)(btn)
if MAC and not down:
mouse._click = None
def fire(act):
if act == "open_claude":
open_claude()
elif act in combos:
combo(combos[act])
names = {k: acts.get(k, "none") for k in B}
print(f"Running with {m.get('name', 'controller')}. L + R pauses. "
"L + R + Start together, or Ctrl+C here, to quit.", flush=True)
print("Controls: " + ", ".join(f"{k}={v}" for k, v in names.items()), flush=True)
print(f"Stick: speed {feel['speed']:.0f} px/s, curve {feel['curve']:g}, dead zone {feel['deadzone']:g}, "
f"edge boost {'on' if feel['boost'] else 'off'}", flush=True)
edge_t = 0.0
held = {k: False for k in B}
chorded = set()
repeat_at = {}
mouse_down = {} # button key -> pynput Button held
last_click, clicks = 0.0, 0
sub = [0.0, 0.0]
scroll_acc = [0.0, 0.0]
paused = False
lr_chord = False
result = "quit"
last = time.time()
try:
while True:
now = time.time()
dt, last = min(now - last, 0.1), now
snap = snapshot(pad)
down = {k: is_down(b, snap) for k, b in B.items()}
pressed = {k for k in down if down[k] and not held[k]}
released = {k for k in down if held[k] and not down[k]}
if down.get("l") and down.get("r") and down.get("start"):
print("Quit chord pressed.", flush=True)
break
if down.get("l") and down.get("r"):
lr_chord = True
chorded.update(("l", "r"))
if lr_chord and not down.get("l") and not down.get("r"):
lr_chord = False
paused = not paused
print(time.strftime("%H:%M:%S"), "paused" if paused else "resumed", flush=True)
if down.get("z") and "start" in pressed:
chorded.add("start")
open_claude()
if paused or lr_chord:
held = down
chorded.difference_update(released)
time.sleep(1 / TICK_HZ)
continue
slow = any(down.get(k) and names.get(k) == "precision" for k in B)
vx, vy, r = stick_vector(axis(S.get("x"), snap), axis(S.get("y"), snap),
feel["curve"], feel["deadzone"])
# Edge boost: after BOOST_DELAY at the rim, ramp up to BOOST_MAX over BOOST_RAMP, so
# a strong centre curve does not make long trips across the screen slow.
edge_t = edge_t + dt if r >= 0.95 else 0.0
boost = 1.0
if feel["boost"] and not slow:
boost += (BOOST_MAX - 1.0) * min(1.0, max(0.0, edge_t - BOOST_DELAY) / BOOST_RAMP)
if vx or vy:
speed = feel["speed"] * boost * (SLOW_FACTOR if slow else 1.0)
sub[0] += vx * speed * dt
sub[1] += vy * speed * dt
dx, dy = int(sub[0]), int(sub[1])
if dx or dy:
mouse.move(dx, dy)
sub[0] -= dx
sub[1] -= dy
sx = sy = 0
for k, act in names.items():
if act in buttons_for:
if k in pressed:
if act == "left_click":
clicks = clicks + 1 if now - last_click < DOUBLE_CLICK_S else 1
last_click = now
press_button(buttons_for[act], True, clicks)
else:
mouse.press(buttons_for[act])
mouse_down[k] = buttons_for[act]
if k in released and k in mouse_down:
if act == "left_click":
press_button(mouse_down.pop(k), False, clicks)
else:
mouse.release(mouse_down.pop(k))
elif act in scrolls and down.get(k):
sx += scrolls[act][0]
sy += scrolls[act][1]
elif act in repeat_keys:
key = repeat_keys[act]
if k in pressed:
kb.press(key)
repeat_at[k] = now + KEY_REPEAT_DELAY
elif down.get(k) and now >= repeat_at.get(k, float("inf")):
kb.press(key)
repeat_at[k] = now + KEY_REPEAT_RATE
if k in released:
kb.release(key)
repeat_at.pop(k, None)
elif act in combos or act == "open_claude":
if k in CHORD_KEYS:
if k in released and k not in chorded:
fire(act)
elif k in pressed:
fire(act)
if sx or sy:
scroll_acc[0] += sy * SCROLL_STEPS * dt
scroll_acc[1] += sx * SCROLL_STEPS * dt
iy, ix = int(scroll_acc[0]), int(scroll_acc[1])
if iy or ix:
mouse.scroll(ix, iy)
scroll_acc[0] -= iy
scroll_acc[1] -= ix
else:
scroll_acc = [0.0, 0.0]
chorded.difference_update(released)
held = down
time.sleep(max(0.0, 1 / TICK_HZ - (time.time() - now)))
except KeyboardInterrupt:
pass
except Unplugged:
result = "unplugged"
print(time.strftime("%H:%M:%S"), "controller unplugged", flush=True)
finally:
for k, btn in list(mouse_down.items()):
mouse.release(btn)
for k, act in names.items():
if act in repeat_keys and held.get(k):
kb.release(repeat_keys[act])
if result == "quit":
print("Stopped.", flush=True)
return result
# ---------------------------------------------------------------- always on
LABEL = "com.highlandwillow.gamepad-mouse"
def _autostart_paths():
if MAC:
return os.path.expanduser(f"~/Library/LaunchAgents/{LABEL}.plist")
if WIN:
return os.path.join(os.environ["APPDATA"], "Microsoft", "Windows", "Start Menu",
"Programs", "Startup", "GamepadMouse.cmd")
return os.path.expanduser("~/.config/autostart/gamepad-mouse.desktop")
def install_autostart():
if not load_maps():
sys.exit("Learn the controller first: run this file once without options.")
os.makedirs(APP_DIR, exist_ok=True)
me = os.path.abspath(__file__)
copy = os.path.join(APP_DIR, "gamepad_mouse.py")
if os.path.abspath(copy) != me:
shutil.copyfile(me, copy)
py = sys.executable
log = os.path.join(APP_DIR, "log.txt")
path = _autostart_paths()
os.makedirs(os.path.dirname(path), exist_ok=True)
if MAC:
import plistlib
with open(path, "wb") as f:
plistlib.dump({"Label": LABEL, "ProgramArguments": [py, copy, "--background"],
"RunAtLoad": True, "KeepAlive": {"SuccessfulExit": False},
"ThrottleInterval": 30, "StandardOutPath": log, "StandardErrorPath": log,
"EnvironmentVariables": {"GAMEPAD_MOUSE_HOME": APP_DIR}}, f)
dom = f"gui/{os.getuid()}"
subprocess.call(["launchctl", "bootout", dom, path], stderr=subprocess.DEVNULL)
rc = subprocess.call(["launchctl", "bootstrap", dom, path])
print(f"Installed {path} and started it (launchctl {rc}).")
print("macOS will ask to let Python control the computer: allow it under Privacy & Security >\n"
"Accessibility. It retries every 30 seconds until allowed.")
elif WIN:
pyw = os.path.join(os.path.dirname(py), "pythonw.exe")
pyw = pyw if os.path.exists(pyw) else py
with open(path, "w") as f:
f.write(f'@start "" "{pyw}" "{copy}" --background\r\n')
subprocess.Popen([pyw, copy, "--background"], creationflags=0x00000008) # DETACHED_PROCESS
print(f"Installed {path} and started it.")
else:
with open(path, "w") as f:
f.write("[Desktop Entry]\nType=Application\nName=Gamepad mouse\n"
f'Exec="{py}" "{copy}" --background\nX-GNOME-Autostart-enabled=true\n')
subprocess.Popen([py, copy, "--background"], start_new_session=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
print(f"Installed {path} and started it.")
print("From now on it starts at login and waits for the controller. L + R pauses it.")
def uninstall_autostart():
path = _autostart_paths()
if MAC and os.path.exists(path):
subprocess.call(["launchctl", "bootout", f"gui/{os.getuid()}", path], stderr=subprocess.DEVNULL)
if os.path.exists(path):
os.remove(path)
print(f"Removed {path}. It will not start at login any more.")
else:
print("It was not set to start at login.")
if not MAC:
print("If a copy is running now, press L + R + Start to stop it.")
def main():
arg = sys.argv[1] if len(sys.argv) > 1 else ""
if arg in ("-h", "--help"):
print(__doc__)
return
if arg == "--install-autostart":
install_autostart()
return
if arg == "--uninstall-autostart":
uninstall_autostart()
return
background = arg == "--background"
if background:
os.makedirs(APP_DIR, exist_ok=True)
if sys.stdout is None or not sys.stdout.isatty():
sys.stdout = sys.stderr = open(os.path.join(APP_DIR, "log.txt"), "a", buffering=1)
print(time.strftime("%Y-%m-%d %H:%M:%S"), "background start", flush=True)
pad = open_pad(forever=True)
if arg == "--show":
show(pad)
return
while True:
m = load_maps().get(pad.get_guid())
if arg == "--learn" or m is None:
if background:
print("No map for", pad.get_name(), "- run gamepad_mouse.py once in a terminal to learn it.",
flush=True)
while pygame.joystick.get_count():
if any(e.type == pygame.JOYDEVICEREMOVED for e in pygame.event.get()):
break
time.sleep(0.5)
else:
if m is None:
print("First time with this controller.")
m = learn(pad)
arg = ""
if m is not None and run(pad, m) == "quit":
return
pad = open_pad(forever=True)
if __name__ == "__main__":
main()
Pointer practice
A quick test of your controller mouse, or any mouse. Click the ten targets as fast as you can. Clicks that miss are counted.
Press Start, then click each target.
Good to know
- Browsers only show a controller after a button is pressed while the page is open. That is a privacy rule, not a fault.
- The numbers are the browser's numbers. Chrome and Edge often renumber a known pad into a "standard" layout, so the same adapter can report different numbers in Firefox, in Safari, or in a desktop program that reads it directly. Learn the map in the place you will use it.
- Some adapters send the D-pad as a single "hat" axis that rests near 1.29 and steps through eight directions. The tester recognises that and lights diagonals too.
- An N64 stick rarely reaches 1.0. Roll it round its edge after learning and the furthest values appear under the drawing, so a game can scale the stick to its real reach.
- Each button can only be learned once. If the tester says a button is already taken, the press was probably caught by the wrong prompt. Let go and press the one asked for.
- On a Mac, macOS treats one button on many adapters as the controller's Home button, and pressing it opens the Games app. If a button does that, leave it alone when the program asks for it and it is skipped after 10 seconds.