tapir-autoclicker 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tapir_autoclicker-0.2.0/LICENSE +21 -0
- tapir_autoclicker-0.2.0/PKG-INFO +198 -0
- tapir_autoclicker-0.2.0/README.md +177 -0
- tapir_autoclicker-0.2.0/pyproject.toml +35 -0
- tapir_autoclicker-0.2.0/setup.cfg +4 -0
- tapir_autoclicker-0.2.0/tapir/__init__.py +0 -0
- tapir_autoclicker-0.2.0/tapir/__main__.py +5 -0
- tapir_autoclicker-0.2.0/tapir/config.py +57 -0
- tapir_autoclicker-0.2.0/tapir/engine.py +135 -0
- tapir_autoclicker-0.2.0/tapir/hotkey.py +214 -0
- tapir_autoclicker-0.2.0/tapir/i18n.py +111 -0
- tapir_autoclicker-0.2.0/tapir/ipc.py +65 -0
- tapir_autoclicker-0.2.0/tapir/ui/__init__.py +0 -0
- tapir_autoclicker-0.2.0/tapir/ui/app.py +42 -0
- tapir_autoclicker-0.2.0/tapir/ui/tray.py +58 -0
- tapir_autoclicker-0.2.0/tapir/ui/window.py +406 -0
- tapir_autoclicker-0.2.0/tapir_autoclicker.egg-info/PKG-INFO +198 -0
- tapir_autoclicker-0.2.0/tapir_autoclicker.egg-info/SOURCES.txt +25 -0
- tapir_autoclicker-0.2.0/tapir_autoclicker.egg-info/dependency_links.txt +1 -0
- tapir_autoclicker-0.2.0/tapir_autoclicker.egg-info/entry_points.txt +2 -0
- tapir_autoclicker-0.2.0/tapir_autoclicker.egg-info/requires.txt +3 -0
- tapir_autoclicker-0.2.0/tapir_autoclicker.egg-info/top_level.txt +1 -0
- tapir_autoclicker-0.2.0/tests/test_config.py +29 -0
- tapir_autoclicker-0.2.0/tests/test_engine.py +134 -0
- tapir_autoclicker-0.2.0/tests/test_hotkey.py +85 -0
- tapir_autoclicker-0.2.0/tests/test_i18n.py +74 -0
- tapir_autoclicker-0.2.0/tests/test_ipc.py +29 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pierre Sylla
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tapir-autoclicker
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Small Wayland-friendly autoclicker with a Qt UI
|
|
5
|
+
Author: Pierre Sylla
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/PierreSylla/tapir-autoclicker
|
|
8
|
+
Project-URL: Issues, https://github.com/PierreSylla/tapir-autoclicker/issues
|
|
9
|
+
Keywords: autoclicker,wayland,hyprland,uinput
|
|
10
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
11
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Utilities
|
|
14
|
+
Requires-Python: >=3.12
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: PySide6>=6.5
|
|
18
|
+
Requires-Dist: evdev>=1.6
|
|
19
|
+
Requires-Dist: tomli-w>=1.0
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# Tapir
|
|
23
|
+
|
|
24
|
+
A small autoclicker for Linux Wayland desktops, with a compact Qt window.
|
|
25
|
+
|
|
26
|
+
Clicks go through a virtual `uinput` mouse, so they work under any compositor. No X11 tools (xdotool, pynput, PyAutoGUI) are involved.
|
|
27
|
+
|
|
28
|
+
## Features
|
|
29
|
+
|
|
30
|
+
- Interval in hours, minutes, seconds and milliseconds (10 ms minimum), with an optional random variation of ± N ms
|
|
31
|
+
- Left, right or middle button; single or double click
|
|
32
|
+
- Repeat N times or until stopped, with a click counter
|
|
33
|
+
- Optional countdown (0–10 s) when starting from the window or the tray, to give you time to place the cursor
|
|
34
|
+
- Clicks at the current cursor position and never moves the cursor
|
|
35
|
+
- Global start/stop hotkey: a bind managed in Hyprland, or an evdev fallback elsewhere
|
|
36
|
+
- Command line and Unix socket: `tapir toggle | start | stop`
|
|
37
|
+
- Tray icon coloured by state (grey stopped, amber countdown, green running)
|
|
38
|
+
- English or French interface, following the system language until you pick one
|
|
39
|
+
- Settings saved on every change
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
### Arch Linux / CachyOS package
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
cd packaging
|
|
47
|
+
makepkg -sif
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The PKGBUILD builds the **last commit** of this repository: uncommitted changes, including new files that are not committed yet, are not included. `-f` rebuilds the package even when one with the same version was built before; without it, makepkg reinstalls the old file. It installs the `tapir` command, the desktop entry and the udev rule, and pacman reloads udev for you.
|
|
51
|
+
|
|
52
|
+
### With pipx or pip
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
pipx install tapir-autoclicker
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
evdev ships no prebuilt wheel, so pip compiles it: you need a C compiler, the Python headers and the kernel headers (Arch: `base-devel`; Debian and Ubuntu: `build-essential python3-dev`). The udev rule and the desktop entry are not installed this way: see Permissions.
|
|
59
|
+
|
|
60
|
+
### From source
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
sudo pacman -S pyside6 python-evdev python-tomli-w
|
|
64
|
+
python -m tapir
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Python 3.12 or newer. On other distributions, install PySide6, evdev and tomli-w from your package manager or with pip. From a source checkout, replace `tapir` with `python -m tapir` in the commands below.
|
|
68
|
+
|
|
69
|
+
## Permissions
|
|
70
|
+
|
|
71
|
+
Tapir creates its virtual mouse through `/dev/uinput` and needs write access to it. Many systems already give it to the logged-in user (Steam's `60-steam-input.rules` does, for example). To check:
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
test -w /dev/uinput && echo ok
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
If it is not writable, install the udev rule shipped with Tapir. The Arch package does it for you; from source, run:
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
scripts/setup-permissions.sh
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The script asks for confirmation, then runs:
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
sudo install -Dm644 packaging/70-tapir-uinput.rules /etc/udev/rules.d/70-tapir-uinput.rules
|
|
87
|
+
sudo udevadm control --reload-rules
|
|
88
|
+
sudo udevadm trigger --sysname-match=uinput
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Without a source checkout (pip install), create the rule directly:
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
echo 'KERNEL=="uinput", SUBSYSTEM=="misc", TAG+="uaccess", OPTIONS+="static_node=uinput"' | sudo tee /etc/udev/rules.d/70-tapir-uinput.rules
|
|
95
|
+
sudo udevadm control --reload-rules
|
|
96
|
+
sudo udevadm trigger --sysname-match=uinput
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The rule uses systemd's `uaccess` tag: only the user logged in at the seat gets access, not a whole group. Its file name has to sort before `73-seat-late.rules`, where udev applies `uaccess`.
|
|
100
|
+
|
|
101
|
+
When access is missing, Tapir shows these instructions instead of starting.
|
|
102
|
+
|
|
103
|
+
On Hyprland, Tapir never reads your keyboard. The evdev fallback for other compositors needs read access to `/dev/input/event*`, usually through the `input` group. Being in that group lets every program you run read every keystroke, passwords included, so Tapir never adds you to it. Prefer your compositor's own shortcuts (see below).
|
|
104
|
+
|
|
105
|
+
## Usage
|
|
106
|
+
|
|
107
|
+
Set the interval, button, click type and repeat mode, then press **Start**. The status line shows *Stopped*, *Starting in N s* or *Running*, and the counter shows the clicks of the current session. In "Repeat N times" mode the session stops on its own.
|
|
108
|
+
|
|
109
|
+
The list at the top left switches the interface between English and French; the change applies immediately and is saved.
|
|
110
|
+
|
|
111
|
+
The settings are locked while a session runs. When a tray is available, closing the window (the **✕** button or your compositor's close shortcut) hides it to the tray; **Quit** is in the tray menu. Without a tray, closing the window quits.
|
|
112
|
+
|
|
113
|
+
## Global hotkey
|
|
114
|
+
|
|
115
|
+
### Hyprland
|
|
116
|
+
|
|
117
|
+
Click **Set…** next to *Hotkey* and press the combination: a key with optional Super, Ctrl, Alt and Shift, or a mouse side button (Mouse 4 / Mouse 5). Esc cancels, Backspace removes the hotkey.
|
|
118
|
+
|
|
119
|
+
Tapir adds the bind to Hyprland itself with `hyprctl eval` (`hl.bind`, tagged with the description `tapir`). The hotkey works when Tapir's window is not focused, and the key press does not reach other applications.
|
|
120
|
+
|
|
121
|
+
- A combination that your Hyprland config already uses is refused.
|
|
122
|
+
- The bind is added again when Hyprland reloads its config, and removed when Tapir quits.
|
|
123
|
+
- Hotkey starts are immediate: the countdown only applies to the window and tray.
|
|
124
|
+
|
|
125
|
+
Tested with Hyprland 0.57 and a Lua config, where `hyprctl keyword` is not available.
|
|
126
|
+
|
|
127
|
+
### Static bind
|
|
128
|
+
|
|
129
|
+
If you prefer a permanent bind in your own config, leave Tapir's hotkey empty and add one of these.
|
|
130
|
+
|
|
131
|
+
Lua config (`binds.lua`):
|
|
132
|
+
|
|
133
|
+
```lua
|
|
134
|
+
hl.bind("SUPER + F8", hl.dsp.exec_cmd("tapir toggle"))
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
hyprlang config (`hyprland.conf`):
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
bind = SUPER, F8, exec, tapir toggle
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Do not set the same combination in both places, or each press toggles twice.
|
|
144
|
+
|
|
145
|
+
### Other compositors
|
|
146
|
+
|
|
147
|
+
Bind `tapir toggle` in your compositor's shortcut settings.
|
|
148
|
+
|
|
149
|
+
Alternatively, when Tapir runs outside Hyprland and can read `/dev/input/event*` (see Permissions), **Set…** works the same way and Tapir listens to your input devices itself. It reads them without grabbing them, so the key press also reaches the focused application. Devices plugged in later are picked up within a couple of seconds.
|
|
150
|
+
|
|
151
|
+
## Command line
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
tapir open the window, or show the one already running
|
|
155
|
+
tapir toggle start or stop clicking
|
|
156
|
+
tapir start start clicking now (no countdown)
|
|
157
|
+
tapir stop stop clicking
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Commands go to the running instance through `$XDG_RUNTIME_DIR/tapir.sock`, which only your user can reach. They exit with code 1 when no instance is running. Only one instance runs at a time.
|
|
161
|
+
|
|
162
|
+
## Configuration
|
|
163
|
+
|
|
164
|
+
Settings are saved to `~/.config/tapir/config.toml` (or `$XDG_CONFIG_HOME/tapir/config.toml`) on every change and loaded at startup. Invalid values fall back to their defaults.
|
|
165
|
+
|
|
166
|
+
## Known limitations
|
|
167
|
+
|
|
168
|
+
- Tapir clicks wherever the cursor is; it cannot move the cursor or target a window.
|
|
169
|
+
- The tray is detected at startup. If Tapir starts before your bar, there is no tray icon and closing the window quits.
|
|
170
|
+
- On Wayland, `tapir` or **Show** may not raise a window that is already visible: compositors limit focus stealing (Hyprland: `misc:focus_on_activate`).
|
|
171
|
+
- The Hyprland conflict check compares key codes and key names. A bind written with a key name that Qt spells differently (`Prior` and `PgUp`, `Escape` and `Esc`) is not detected.
|
|
172
|
+
- The managed Hyprland bind has only been tested with a Lua config.
|
|
173
|
+
- When the 10 ms minimum clips the random variation, the average interval is slightly longer than the one set.
|
|
174
|
+
- evdev fallback: the hotkey also reaches the focused application, and a modifier held while its device is unplugged counts as held until it is pressed again.
|
|
175
|
+
- Two instances launched within the same few milliseconds can both start.
|
|
176
|
+
|
|
177
|
+
## Development
|
|
178
|
+
|
|
179
|
+
```
|
|
180
|
+
python -m pytest
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`tapir/engine.py` holds the click worker and the virtual mouse (no Qt), `hotkey.py` the hotkey model, Hyprland binds and evdev listener, `ipc.py` the socket and command line, `config.py` the settings file, `i18n.py` the French translations, and `ui/` the window and tray. A test checks that every text passed to `tr()` has a French translation with the same placeholders.
|
|
184
|
+
|
|
185
|
+
### Releasing
|
|
186
|
+
|
|
187
|
+
Set the new version in `pyproject.toml` and `pkgver` in `packaging/PKGBUILD`, commit, then push a matching tag:
|
|
188
|
+
|
|
189
|
+
```
|
|
190
|
+
git tag v0.2.0
|
|
191
|
+
git push origin v0.2.0
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
The *Publish* workflow runs the tests, checks that the tag matches the version in `pyproject.toml`, builds the sdist and wheel and uploads them to PyPI through trusted publishing (no API token is stored anywhere).
|
|
195
|
+
|
|
196
|
+
## License
|
|
197
|
+
|
|
198
|
+
MIT, see [LICENSE](https://github.com/PierreSylla/tapir-autoclicker/blob/main/LICENSE).
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# Tapir
|
|
2
|
+
|
|
3
|
+
A small autoclicker for Linux Wayland desktops, with a compact Qt window.
|
|
4
|
+
|
|
5
|
+
Clicks go through a virtual `uinput` mouse, so they work under any compositor. No X11 tools (xdotool, pynput, PyAutoGUI) are involved.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Interval in hours, minutes, seconds and milliseconds (10 ms minimum), with an optional random variation of ± N ms
|
|
10
|
+
- Left, right or middle button; single or double click
|
|
11
|
+
- Repeat N times or until stopped, with a click counter
|
|
12
|
+
- Optional countdown (0–10 s) when starting from the window or the tray, to give you time to place the cursor
|
|
13
|
+
- Clicks at the current cursor position and never moves the cursor
|
|
14
|
+
- Global start/stop hotkey: a bind managed in Hyprland, or an evdev fallback elsewhere
|
|
15
|
+
- Command line and Unix socket: `tapir toggle | start | stop`
|
|
16
|
+
- Tray icon coloured by state (grey stopped, amber countdown, green running)
|
|
17
|
+
- English or French interface, following the system language until you pick one
|
|
18
|
+
- Settings saved on every change
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
### Arch Linux / CachyOS package
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
cd packaging
|
|
26
|
+
makepkg -sif
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The PKGBUILD builds the **last commit** of this repository: uncommitted changes, including new files that are not committed yet, are not included. `-f` rebuilds the package even when one with the same version was built before; without it, makepkg reinstalls the old file. It installs the `tapir` command, the desktop entry and the udev rule, and pacman reloads udev for you.
|
|
30
|
+
|
|
31
|
+
### With pipx or pip
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
pipx install tapir-autoclicker
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
evdev ships no prebuilt wheel, so pip compiles it: you need a C compiler, the Python headers and the kernel headers (Arch: `base-devel`; Debian and Ubuntu: `build-essential python3-dev`). The udev rule and the desktop entry are not installed this way: see Permissions.
|
|
38
|
+
|
|
39
|
+
### From source
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
sudo pacman -S pyside6 python-evdev python-tomli-w
|
|
43
|
+
python -m tapir
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Python 3.12 or newer. On other distributions, install PySide6, evdev and tomli-w from your package manager or with pip. From a source checkout, replace `tapir` with `python -m tapir` in the commands below.
|
|
47
|
+
|
|
48
|
+
## Permissions
|
|
49
|
+
|
|
50
|
+
Tapir creates its virtual mouse through `/dev/uinput` and needs write access to it. Many systems already give it to the logged-in user (Steam's `60-steam-input.rules` does, for example). To check:
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
test -w /dev/uinput && echo ok
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
If it is not writable, install the udev rule shipped with Tapir. The Arch package does it for you; from source, run:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
scripts/setup-permissions.sh
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The script asks for confirmation, then runs:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
sudo install -Dm644 packaging/70-tapir-uinput.rules /etc/udev/rules.d/70-tapir-uinput.rules
|
|
66
|
+
sudo udevadm control --reload-rules
|
|
67
|
+
sudo udevadm trigger --sysname-match=uinput
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Without a source checkout (pip install), create the rule directly:
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
echo 'KERNEL=="uinput", SUBSYSTEM=="misc", TAG+="uaccess", OPTIONS+="static_node=uinput"' | sudo tee /etc/udev/rules.d/70-tapir-uinput.rules
|
|
74
|
+
sudo udevadm control --reload-rules
|
|
75
|
+
sudo udevadm trigger --sysname-match=uinput
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The rule uses systemd's `uaccess` tag: only the user logged in at the seat gets access, not a whole group. Its file name has to sort before `73-seat-late.rules`, where udev applies `uaccess`.
|
|
79
|
+
|
|
80
|
+
When access is missing, Tapir shows these instructions instead of starting.
|
|
81
|
+
|
|
82
|
+
On Hyprland, Tapir never reads your keyboard. The evdev fallback for other compositors needs read access to `/dev/input/event*`, usually through the `input` group. Being in that group lets every program you run read every keystroke, passwords included, so Tapir never adds you to it. Prefer your compositor's own shortcuts (see below).
|
|
83
|
+
|
|
84
|
+
## Usage
|
|
85
|
+
|
|
86
|
+
Set the interval, button, click type and repeat mode, then press **Start**. The status line shows *Stopped*, *Starting in N s* or *Running*, and the counter shows the clicks of the current session. In "Repeat N times" mode the session stops on its own.
|
|
87
|
+
|
|
88
|
+
The list at the top left switches the interface between English and French; the change applies immediately and is saved.
|
|
89
|
+
|
|
90
|
+
The settings are locked while a session runs. When a tray is available, closing the window (the **✕** button or your compositor's close shortcut) hides it to the tray; **Quit** is in the tray menu. Without a tray, closing the window quits.
|
|
91
|
+
|
|
92
|
+
## Global hotkey
|
|
93
|
+
|
|
94
|
+
### Hyprland
|
|
95
|
+
|
|
96
|
+
Click **Set…** next to *Hotkey* and press the combination: a key with optional Super, Ctrl, Alt and Shift, or a mouse side button (Mouse 4 / Mouse 5). Esc cancels, Backspace removes the hotkey.
|
|
97
|
+
|
|
98
|
+
Tapir adds the bind to Hyprland itself with `hyprctl eval` (`hl.bind`, tagged with the description `tapir`). The hotkey works when Tapir's window is not focused, and the key press does not reach other applications.
|
|
99
|
+
|
|
100
|
+
- A combination that your Hyprland config already uses is refused.
|
|
101
|
+
- The bind is added again when Hyprland reloads its config, and removed when Tapir quits.
|
|
102
|
+
- Hotkey starts are immediate: the countdown only applies to the window and tray.
|
|
103
|
+
|
|
104
|
+
Tested with Hyprland 0.57 and a Lua config, where `hyprctl keyword` is not available.
|
|
105
|
+
|
|
106
|
+
### Static bind
|
|
107
|
+
|
|
108
|
+
If you prefer a permanent bind in your own config, leave Tapir's hotkey empty and add one of these.
|
|
109
|
+
|
|
110
|
+
Lua config (`binds.lua`):
|
|
111
|
+
|
|
112
|
+
```lua
|
|
113
|
+
hl.bind("SUPER + F8", hl.dsp.exec_cmd("tapir toggle"))
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
hyprlang config (`hyprland.conf`):
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
bind = SUPER, F8, exec, tapir toggle
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Do not set the same combination in both places, or each press toggles twice.
|
|
123
|
+
|
|
124
|
+
### Other compositors
|
|
125
|
+
|
|
126
|
+
Bind `tapir toggle` in your compositor's shortcut settings.
|
|
127
|
+
|
|
128
|
+
Alternatively, when Tapir runs outside Hyprland and can read `/dev/input/event*` (see Permissions), **Set…** works the same way and Tapir listens to your input devices itself. It reads them without grabbing them, so the key press also reaches the focused application. Devices plugged in later are picked up within a couple of seconds.
|
|
129
|
+
|
|
130
|
+
## Command line
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
tapir open the window, or show the one already running
|
|
134
|
+
tapir toggle start or stop clicking
|
|
135
|
+
tapir start start clicking now (no countdown)
|
|
136
|
+
tapir stop stop clicking
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Commands go to the running instance through `$XDG_RUNTIME_DIR/tapir.sock`, which only your user can reach. They exit with code 1 when no instance is running. Only one instance runs at a time.
|
|
140
|
+
|
|
141
|
+
## Configuration
|
|
142
|
+
|
|
143
|
+
Settings are saved to `~/.config/tapir/config.toml` (or `$XDG_CONFIG_HOME/tapir/config.toml`) on every change and loaded at startup. Invalid values fall back to their defaults.
|
|
144
|
+
|
|
145
|
+
## Known limitations
|
|
146
|
+
|
|
147
|
+
- Tapir clicks wherever the cursor is; it cannot move the cursor or target a window.
|
|
148
|
+
- The tray is detected at startup. If Tapir starts before your bar, there is no tray icon and closing the window quits.
|
|
149
|
+
- On Wayland, `tapir` or **Show** may not raise a window that is already visible: compositors limit focus stealing (Hyprland: `misc:focus_on_activate`).
|
|
150
|
+
- The Hyprland conflict check compares key codes and key names. A bind written with a key name that Qt spells differently (`Prior` and `PgUp`, `Escape` and `Esc`) is not detected.
|
|
151
|
+
- The managed Hyprland bind has only been tested with a Lua config.
|
|
152
|
+
- When the 10 ms minimum clips the random variation, the average interval is slightly longer than the one set.
|
|
153
|
+
- evdev fallback: the hotkey also reaches the focused application, and a modifier held while its device is unplugged counts as held until it is pressed again.
|
|
154
|
+
- Two instances launched within the same few milliseconds can both start.
|
|
155
|
+
|
|
156
|
+
## Development
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
python -m pytest
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
`tapir/engine.py` holds the click worker and the virtual mouse (no Qt), `hotkey.py` the hotkey model, Hyprland binds and evdev listener, `ipc.py` the socket and command line, `config.py` the settings file, `i18n.py` the French translations, and `ui/` the window and tray. A test checks that every text passed to `tr()` has a French translation with the same placeholders.
|
|
163
|
+
|
|
164
|
+
### Releasing
|
|
165
|
+
|
|
166
|
+
Set the new version in `pyproject.toml` and `pkgver` in `packaging/PKGBUILD`, commit, then push a matching tag:
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
git tag v0.2.0
|
|
170
|
+
git push origin v0.2.0
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
The *Publish* workflow runs the tests, checks that the tag matches the version in `pyproject.toml`, builds the sdist and wheel and uploads them to PyPI through trusted publishing (no API token is stored anywhere).
|
|
174
|
+
|
|
175
|
+
## License
|
|
176
|
+
|
|
177
|
+
MIT, see [LICENSE](https://github.com/PierreSylla/tapir-autoclicker/blob/main/LICENSE).
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tapir-autoclicker"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Small Wayland-friendly autoclicker with a Qt UI"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "Pierre Sylla" }]
|
|
13
|
+
keywords = ["autoclicker", "wayland", "hyprland", "uinput"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Intended Audience :: End Users/Desktop",
|
|
16
|
+
"Operating System :: POSIX :: Linux",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: Utilities",
|
|
19
|
+
]
|
|
20
|
+
requires-python = ">=3.12"
|
|
21
|
+
dependencies = ["PySide6>=6.5", "evdev>=1.6", "tomli-w>=1.0"]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/PierreSylla/tapir-autoclicker"
|
|
25
|
+
Issues = "https://github.com/PierreSylla/tapir-autoclicker/issues"
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
tapir = "tapir.ipc:main"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
include = ["tapir*"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests"]
|
|
35
|
+
pythonpath = ["."]
|
|
File without changes
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import tomllib
|
|
4
|
+
from dataclasses import asdict, dataclass, fields
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import tomli_w
|
|
8
|
+
|
|
9
|
+
from tapir.engine import BUTTONS
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
CONFIG_PATH = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "tapir" / "config.toml"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Config:
|
|
18
|
+
hours: int = 0
|
|
19
|
+
minutes: int = 0
|
|
20
|
+
seconds: int = 0
|
|
21
|
+
milliseconds: int = 100
|
|
22
|
+
jitter_ms: int = 0
|
|
23
|
+
button: str = "left"
|
|
24
|
+
double_click: bool = False
|
|
25
|
+
repeat_forever: bool = True
|
|
26
|
+
repeat_count: int = 10
|
|
27
|
+
start_delay_s: int = 3
|
|
28
|
+
hotkey: str = ""
|
|
29
|
+
hotkey_label: str = ""
|
|
30
|
+
language: str = "" # empty: follow the system
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load(path: Path = CONFIG_PATH) -> Config:
|
|
34
|
+
try:
|
|
35
|
+
with path.open("rb") as f:
|
|
36
|
+
data = tomllib.load(f)
|
|
37
|
+
except FileNotFoundError:
|
|
38
|
+
return Config()
|
|
39
|
+
except (OSError, ValueError) as e:
|
|
40
|
+
log.warning("Ignoring unreadable config %s: %s", path, e)
|
|
41
|
+
return Config()
|
|
42
|
+
defaults = Config()
|
|
43
|
+
valid = {
|
|
44
|
+
f.name: data[f.name]
|
|
45
|
+
for f in fields(Config)
|
|
46
|
+
if type(data.get(f.name)) is type(getattr(defaults, f.name))
|
|
47
|
+
}
|
|
48
|
+
if valid.get("button") not in (None, *BUTTONS):
|
|
49
|
+
del valid["button"]
|
|
50
|
+
return Config(**valid)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def save(config: Config, path: Path = CONFIG_PATH) -> None:
|
|
54
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
tmp = path.with_suffix(".tmp")
|
|
56
|
+
tmp.write_text(tomli_w.dumps(asdict(config)), encoding="utf-8")
|
|
57
|
+
tmp.replace(path)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import random
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
|
|
8
|
+
from evdev import UInput, ecodes
|
|
9
|
+
|
|
10
|
+
MIN_INTERVAL_MS = 10
|
|
11
|
+
DOUBLE_CLICK_GAP_S = 0.04
|
|
12
|
+
UINPUT_NAME = "tapir-virtual-mouse"
|
|
13
|
+
BUTTON_CODES = {"left": ecodes.BTN_LEFT, "right": ecodes.BTN_RIGHT, "middle": ecodes.BTN_MIDDLE}
|
|
14
|
+
BUTTONS = tuple(BUTTON_CODES)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class State(StrEnum):
|
|
18
|
+
STOPPED = "stopped"
|
|
19
|
+
PENDING = "pending"
|
|
20
|
+
RUNNING = "running"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ClickSettings:
|
|
25
|
+
interval_ms: int
|
|
26
|
+
button: str = "left"
|
|
27
|
+
double: bool = False
|
|
28
|
+
repeat: int | None = None # None = until stopped
|
|
29
|
+
jitter_ms: int = 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def total_ms(hours: int = 0, minutes: int = 0, seconds: int = 0, milliseconds: int = 0) -> int:
|
|
33
|
+
return ((hours * 60 + minutes) * 60 + seconds) * 1000 + milliseconds
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def gap_range_ms(interval_ms: int, jitter_ms: int = 0) -> tuple[int, int]:
|
|
37
|
+
base = max(interval_ms, MIN_INTERVAL_MS)
|
|
38
|
+
return max(base - jitter_ms, MIN_INTERVAL_MS), base + jitter_ms
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def gap_ms(interval_ms: int, jitter_ms: int = 0) -> int:
|
|
42
|
+
return random.randint(*gap_range_ms(interval_ms, jitter_ms))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class UInputEmitter:
|
|
46
|
+
"""Virtual mouse; clicks land wherever the compositor's cursor is."""
|
|
47
|
+
|
|
48
|
+
def __init__(self):
|
|
49
|
+
# REL axes are never used but make libinput classify the device as a pointer.
|
|
50
|
+
self._ui = UInput(
|
|
51
|
+
{ecodes.EV_KEY: list(BUTTON_CODES.values()), ecodes.EV_REL: [ecodes.REL_X, ecodes.REL_Y, ecodes.REL_WHEEL]},
|
|
52
|
+
name=UINPUT_NAME,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def press(self, button: str) -> None:
|
|
56
|
+
self._ui.write(ecodes.EV_KEY, BUTTON_CODES[button], 1)
|
|
57
|
+
self._ui.syn()
|
|
58
|
+
|
|
59
|
+
def release(self, button: str) -> None:
|
|
60
|
+
self._ui.write(ecodes.EV_KEY, BUTTON_CODES[button], 0)
|
|
61
|
+
self._ui.syn()
|
|
62
|
+
|
|
63
|
+
def close(self) -> None:
|
|
64
|
+
# The kernel drops releases for buttons that are not down, so releasing everything is safe.
|
|
65
|
+
for code in BUTTON_CODES.values():
|
|
66
|
+
self._ui.write(ecodes.EV_KEY, code, 0)
|
|
67
|
+
self._ui.syn()
|
|
68
|
+
self._ui.close()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Clicker:
|
|
72
|
+
"""Click worker thread; callbacks run on the worker thread."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, emitter, on_state=None, on_count=None):
|
|
75
|
+
self._emitter = emitter
|
|
76
|
+
self._on_state = on_state or (lambda state, countdown=0: None)
|
|
77
|
+
self._on_count = on_count or (lambda count: None)
|
|
78
|
+
self._thread: threading.Thread | None = None
|
|
79
|
+
self._stop = threading.Event()
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def active(self) -> bool:
|
|
83
|
+
return self._thread is not None and self._thread.is_alive()
|
|
84
|
+
|
|
85
|
+
def start(self, settings: ClickSettings, start_delay_s: float = 0) -> None:
|
|
86
|
+
if self.active:
|
|
87
|
+
return
|
|
88
|
+
self._stop = threading.Event()
|
|
89
|
+
self._thread = threading.Thread(
|
|
90
|
+
target=self._run, args=(settings, start_delay_s, self._stop), name="tapir-clicker", daemon=True
|
|
91
|
+
)
|
|
92
|
+
self._thread.start()
|
|
93
|
+
|
|
94
|
+
def stop(self) -> None:
|
|
95
|
+
self._stop.set()
|
|
96
|
+
if self._thread is not None:
|
|
97
|
+
self._thread.join()
|
|
98
|
+
|
|
99
|
+
def _run(self, settings: ClickSettings, start_delay_s: float, stop: threading.Event) -> None:
|
|
100
|
+
try:
|
|
101
|
+
if self._countdown(start_delay_s, stop):
|
|
102
|
+
return
|
|
103
|
+
self._on_state(State.RUNNING)
|
|
104
|
+
self._click_loop(settings, stop)
|
|
105
|
+
finally:
|
|
106
|
+
self._on_state(State.STOPPED)
|
|
107
|
+
|
|
108
|
+
def _countdown(self, seconds: float, stop: threading.Event) -> bool:
|
|
109
|
+
end = time.monotonic() + seconds
|
|
110
|
+
while (left := end - time.monotonic()) > 0:
|
|
111
|
+
whole = math.ceil(left)
|
|
112
|
+
self._on_state(State.PENDING, whole)
|
|
113
|
+
if stop.wait(left - whole + 1):
|
|
114
|
+
return True
|
|
115
|
+
return stop.is_set()
|
|
116
|
+
|
|
117
|
+
def _click_loop(self, settings: ClickSettings, stop: threading.Event) -> None:
|
|
118
|
+
count = 0
|
|
119
|
+
next_at = time.monotonic()
|
|
120
|
+
while settings.repeat is None or count < settings.repeat:
|
|
121
|
+
if stop.wait(max(0.0, next_at - time.monotonic())):
|
|
122
|
+
return
|
|
123
|
+
self._click(settings, stop)
|
|
124
|
+
count += 1
|
|
125
|
+
self._on_count(count)
|
|
126
|
+
# After a stall (suspend, overload) resume from now instead of firing a burst of late clicks.
|
|
127
|
+
gap = gap_ms(settings.interval_ms, settings.jitter_ms) / 1000
|
|
128
|
+
next_at = max(next_at + gap, time.monotonic())
|
|
129
|
+
|
|
130
|
+
def _click(self, settings: ClickSettings, stop: threading.Event) -> None:
|
|
131
|
+
self._emitter.press(settings.button)
|
|
132
|
+
self._emitter.release(settings.button)
|
|
133
|
+
if settings.double and not stop.wait(DOUBLE_CLICK_GAP_S):
|
|
134
|
+
self._emitter.press(settings.button)
|
|
135
|
+
self._emitter.release(settings.button)
|