AutoMonkey 0.4.1__py3-none-any.whl
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.
- automonkey/__init__.py +37 -0
- automonkey/ai/__init__.py +54 -0
- automonkey/ai/__main__.py +76 -0
- automonkey/ai/mcp_launch.py +62 -0
- automonkey/ai/mcp_server.py +114 -0
- automonkey/ai/ocr.py +149 -0
- automonkey/ai/screen.py +202 -0
- automonkey/ai/script.py +190 -0
- automonkey/ai/windows.py +185 -0
- automonkey/app_funcs.py +201 -0
- automonkey/atspi_funcs.py +1093 -0
- automonkey/automonkey.py +300 -0
- automonkey/constants.py +120 -0
- automonkey/exceptions.py +16 -0
- automonkey/img_funcs.py +301 -0
- automonkey/key_funcs.py +279 -0
- automonkey/mouse_funcs.py +37 -0
- automonkey/mouse_tracker.py +133 -0
- automonkey/pywinauto_funcs.py +343 -0
- automonkey/utils.py +169 -0
- automonkey-0.4.1.dist-info/METADATA +517 -0
- automonkey-0.4.1.dist-info/RECORD +27 -0
- automonkey-0.4.1.dist-info/WHEEL +5 -0
- automonkey-0.4.1.dist-info/entry_points.txt +3 -0
- automonkey-0.4.1.dist-info/licenses/AUTHORS.rst +13 -0
- automonkey-0.4.1.dist-info/licenses/LICENSE +33 -0
- automonkey-0.4.1.dist-info/top_level.txt +1 -0
automonkey/img_funcs.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
from os import remove
|
|
2
|
+
from os.path import isfile
|
|
3
|
+
|
|
4
|
+
from time import sleep
|
|
5
|
+
|
|
6
|
+
from pyautogui import center
|
|
7
|
+
from pyautogui import locateOnScreen
|
|
8
|
+
|
|
9
|
+
from pyscreeze import Box
|
|
10
|
+
from pytesseract import image_to_string
|
|
11
|
+
|
|
12
|
+
from clipboard import copy as copy1
|
|
13
|
+
from pyperclip import copy as copy2
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from PIL import Image
|
|
17
|
+
except ImportError:
|
|
18
|
+
import Image
|
|
19
|
+
|
|
20
|
+
import cv2
|
|
21
|
+
from cv2 import imread
|
|
22
|
+
from numpy import where
|
|
23
|
+
|
|
24
|
+
from pyautogui import screenshot
|
|
25
|
+
|
|
26
|
+
from .constants import IMG_EXT
|
|
27
|
+
|
|
28
|
+
# TODO: Add mousedown, mouseup, drag, and drop functions
|
|
29
|
+
|
|
30
|
+
def copy(text: str):
|
|
31
|
+
"""Copy text to clipboard using two functions
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
text (str): text to be copied to clipboard
|
|
35
|
+
"""
|
|
36
|
+
copy1(text)
|
|
37
|
+
copy2(text)
|
|
38
|
+
|
|
39
|
+
def _add_ext(filename: str) -> str:
|
|
40
|
+
"""Adds extension to an image filename if missing
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
filename (str): image filename (with or without extension)
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
str: image filename with extension if was missing
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
for ext in IMG_EXT:
|
|
50
|
+
if isfile(f"{filename}{ext}"):
|
|
51
|
+
return f"{filename}{ext}"
|
|
52
|
+
return filename
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_on_screen(what: str) -> bool:
|
|
56
|
+
"""Checks whether an image is found on screen
|
|
57
|
+
Args:
|
|
58
|
+
what (str): image to locate on screen
|
|
59
|
+
Returns:
|
|
60
|
+
bool: Returns True if image is found and False if not.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
found = False
|
|
64
|
+
what = _add_ext(what)
|
|
65
|
+
|
|
66
|
+
if locateOnScreen(what, confidence=0.9) is not None:
|
|
67
|
+
found = True
|
|
68
|
+
|
|
69
|
+
return found
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_center(image: str) -> tuple:
|
|
73
|
+
"""Find the center of an image on screen
|
|
74
|
+
Args:
|
|
75
|
+
image ([str]): image to be located
|
|
76
|
+
Returns:
|
|
77
|
+
[point]: Returns the center of the located image as a point or None
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
image = _add_ext(image)
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
if not isinstance(image, Box):
|
|
84
|
+
return center(locateOnScreen(image, confidence=0.9))
|
|
85
|
+
return center(image)
|
|
86
|
+
except TypeError:
|
|
87
|
+
return None
|
|
88
|
+
except NameError:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def vertical_point(point, offset) -> tuple:
|
|
93
|
+
"""Returns a PyAutoGUI point that is offset vertically
|
|
94
|
+
Args:
|
|
95
|
+
point (PyAutoGUI point): [A Tuple with an X and a Y]
|
|
96
|
+
offset (int): The offset. Can be positive or negative.
|
|
97
|
+
Positive = Above. Negative = Below
|
|
98
|
+
Returns:
|
|
99
|
+
[PyAutoGUI point]: The PyAutoGUI point offset vertically.
|
|
100
|
+
"""
|
|
101
|
+
return point[0], point[1] - offset
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def horizontal_point(point, offset) -> tuple:
|
|
105
|
+
"""Returns a PyAutoGUI point that is offset horizontally
|
|
106
|
+
Args:
|
|
107
|
+
point (PyAutoGUI point): [A Tuple with an X and a Y]
|
|
108
|
+
offset (int): The offset. Can be positive or negative.
|
|
109
|
+
Positive = Right. Negative = Left
|
|
110
|
+
Returns:
|
|
111
|
+
[PyAutoGUI point]: The PyAutoGUI point offset horizontally.
|
|
112
|
+
"""
|
|
113
|
+
return point[0] + offset, point[1]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def diagonal_point(point, x_point, y_point) -> tuple:
|
|
117
|
+
"""Returns a PyAutoGUI point that is offset diagonally
|
|
118
|
+
Args:
|
|
119
|
+
point (PyAutoGUI point): [A Tuple with an X and a Y]
|
|
120
|
+
x_point (int): The horizontal offset. Can be positive or negative.
|
|
121
|
+
Positive = Right. Negative = Left
|
|
122
|
+
y_point (int): The vertical offset. Can be positive or negative.
|
|
123
|
+
Positive = Above. Negative = Below
|
|
124
|
+
Returns:
|
|
125
|
+
[PyAutoGUI point]: The PyAutoGUI point offset diagonally.
|
|
126
|
+
"""
|
|
127
|
+
return point[0] + x_point, point[1] - y_point
|
|
128
|
+
|
|
129
|
+
def get_img_height(image_file: str) -> int:
|
|
130
|
+
"""Function that returns the height of an image.
|
|
131
|
+
Args:
|
|
132
|
+
image_file (path): path to an image file, including filename.
|
|
133
|
+
Returns:
|
|
134
|
+
int: Height of the image
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
image_file = _add_ext(image_file)
|
|
138
|
+
|
|
139
|
+
img = Image.open(image_file)
|
|
140
|
+
_, height = img.size
|
|
141
|
+
|
|
142
|
+
return height
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def get_img_width(image_file: str) -> int:
|
|
146
|
+
"""Function that returns the width of an image.
|
|
147
|
+
Args:
|
|
148
|
+
image_file (path): path to an image file, including filename.
|
|
149
|
+
Returns:
|
|
150
|
+
int: Width of the image
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
image_file = _add_ext(image_file)
|
|
154
|
+
|
|
155
|
+
img = Image.open(image_file)
|
|
156
|
+
width, _ = img.size
|
|
157
|
+
|
|
158
|
+
return width
|
|
159
|
+
|
|
160
|
+
def __transform_region_1(*args) -> tuple:
|
|
161
|
+
assert isinstance(args[0], tuple), "The argument must be a tuple of 4 integers: Left, Top, Width, Height"
|
|
162
|
+
if len(args[0]) == 4:
|
|
163
|
+
region = (args[0][0], args[0][1], args[0][2] - args[0][0], args[0][3] - args[0][1])
|
|
164
|
+
elif len(args[0]) == 2:
|
|
165
|
+
region = __transform_region_4(args)
|
|
166
|
+
else:
|
|
167
|
+
region = args[0]
|
|
168
|
+
return region
|
|
169
|
+
|
|
170
|
+
# TODO: To have a function that can be called with different number and different types of arguments look into function overloading
|
|
171
|
+
# https://stackoverflow.com/questions/6434482/python-function-overloading
|
|
172
|
+
# This could be needed for:
|
|
173
|
+
# 1. get_text_from_region
|
|
174
|
+
# 2. copy_from_to
|
|
175
|
+
|
|
176
|
+
def __transform_region_2(*args) -> tuple:
|
|
177
|
+
assert isinstance(args[0], tuple), "The first argument must be a tuple of 2 integers: X, Y coordinates of the top left corner"
|
|
178
|
+
assert isinstance(args[1], tuple), "The second argument must be a tuple of 2 integers: X, Y coordinates of the bottom right corner"
|
|
179
|
+
return (args[0][0], args[0][1], args[1][0] - args[0][0], args[1][1] - args[0][1])
|
|
180
|
+
|
|
181
|
+
def __transform_region_3(*args) -> tuple:
|
|
182
|
+
assert isinstance(args[0], int), "The first argument must be an integer: X coordinate of the top left corner"
|
|
183
|
+
assert isinstance(args[1], int), "The second argument must be an integer: Y coordinate of the top left corner"
|
|
184
|
+
assert isinstance(args[2], int), "The third argument must be an integer: Width of the region"
|
|
185
|
+
assert isinstance(args[3], int), "The fourth argument must be an integer: Height of the region"
|
|
186
|
+
if args[2] > args[0] and args[3] > args[1]:
|
|
187
|
+
region = (args[0], args[1], args[2] - args[0], args[3] - args[1])
|
|
188
|
+
else:
|
|
189
|
+
region = (args[0], args[1], args[2], args[3])
|
|
190
|
+
return region
|
|
191
|
+
|
|
192
|
+
def __transform_region_4(*args) -> tuple:
|
|
193
|
+
if args[0][1][1] > args[0][0][0] and args[0][1][1] > args[0][0][1]:
|
|
194
|
+
region = (args[0][0][0], args[0][0][1], args[0][1][0] - args[0][0][0], args[0][1][1] - args[0][0][1])
|
|
195
|
+
else:
|
|
196
|
+
region = (args[0][0][0], args[0][0][1], args[0][1][0], args[0][1][1])
|
|
197
|
+
return region
|
|
198
|
+
|
|
199
|
+
def get_text_from_region(*args) -> str:
|
|
200
|
+
"""Makes a screenshot of a screen region and performs OCR on it
|
|
201
|
+
Args:
|
|
202
|
+
Top left corner X, top left corner Y, bottom right corner X, bottom right corner Y
|
|
203
|
+
or
|
|
204
|
+
Top point (Tuple): (x, y) and Bottom point (Tuple): (x, y)
|
|
205
|
+
or
|
|
206
|
+
region (Tuple or PyAutoGUI region): (Left, Top, Width, Height)
|
|
207
|
+
or
|
|
208
|
+
Left (int), Top (int), Width (int), Height (int)
|
|
209
|
+
Returns:
|
|
210
|
+
str: The text from the region
|
|
211
|
+
"""
|
|
212
|
+
if len(args) == 1:
|
|
213
|
+
region = __transform_region_1(args)
|
|
214
|
+
elif len(args) == 2:
|
|
215
|
+
region = __transform_region_2(args)
|
|
216
|
+
elif len(args) == 4:
|
|
217
|
+
region = __transform_region_3(args)
|
|
218
|
+
|
|
219
|
+
snap = screenshot(region=region)
|
|
220
|
+
snap.save("temp.jpg")
|
|
221
|
+
sleep(1)
|
|
222
|
+
img = imread('temp.jpg')
|
|
223
|
+
|
|
224
|
+
custom_config = r'--oem 3 --psm 6'
|
|
225
|
+
text = image_to_string(img, config=custom_config)
|
|
226
|
+
remove('temp.jpg')
|
|
227
|
+
|
|
228
|
+
copy(text) # in order to make the text available in the clipboard
|
|
229
|
+
return text
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def count_img(needle: str, haystack: str = None) -> int:
|
|
233
|
+
"""Counts how many times an image appears in a bigger image
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
needle (str): image filename to be counted
|
|
237
|
+
haystack (str): filename of image in which to search
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
int: Count of occurrences of needle in the haystack
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
needle = _add_ext(needle)
|
|
244
|
+
if haystack:
|
|
245
|
+
haystack = _add_ext(haystack)
|
|
246
|
+
hay = imread(haystack)
|
|
247
|
+
else:
|
|
248
|
+
haystack = screenshot("temp.jpg")
|
|
249
|
+
hay = imread("temp.jpg")
|
|
250
|
+
remove("temp.jpg")
|
|
251
|
+
|
|
252
|
+
need = imread(needle)
|
|
253
|
+
|
|
254
|
+
res = cv2.cv2.matchTemplate(hay, need, cv2.cv2.TM_CCOEFF_NORMED)
|
|
255
|
+
|
|
256
|
+
threshold = .9 # 9 is more precise. 8 gives some false positives
|
|
257
|
+
loc = where(res >= threshold)
|
|
258
|
+
|
|
259
|
+
copy(len(loc[0])) # in order to make the text available in the clipboard
|
|
260
|
+
return len(loc[0])
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _offset_clicks(point: tuple, img: str, offset_value: str, click_type: str):
|
|
264
|
+
"""Offset Clicks
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
point (tuple): PyAutoGUI Point as a (x, y) tuple
|
|
268
|
+
img (str): image path that was the source of the point
|
|
269
|
+
offset_value (str): Offset type:
|
|
270
|
+
- above
|
|
271
|
+
- bellow
|
|
272
|
+
- right
|
|
273
|
+
- left
|
|
274
|
+
- upper-left
|
|
275
|
+
- upper-right
|
|
276
|
+
- lower-left
|
|
277
|
+
- lower-right
|
|
278
|
+
click_type (str): click, rightClick, doubleClick, etc
|
|
279
|
+
"""
|
|
280
|
+
__offset_clicks_1(point, img, offset_value, click_type)
|
|
281
|
+
__offset_clicks_2(point, img, offset_value, click_type)
|
|
282
|
+
|
|
283
|
+
def __offset_clicks_1(point: tuple, img: str, offset_value: str, click_type: str):
|
|
284
|
+
if offset_value == "above":
|
|
285
|
+
globals()[click_type](vertical_point(point, get_img_height(img)))
|
|
286
|
+
if offset_value == "bellow":
|
|
287
|
+
globals()[click_type](vertical_point(point, 0 - get_img_height(img)))
|
|
288
|
+
if offset_value == "right":
|
|
289
|
+
globals()[click_type](horizontal_point(point, get_img_width(img)))
|
|
290
|
+
if offset_value == "left":
|
|
291
|
+
globals()[click_type](horizontal_point(point, 0 - get_img_width(img)))
|
|
292
|
+
|
|
293
|
+
def __offset_clicks_2(point: tuple, img: str, offset_value: str, click_type: str):
|
|
294
|
+
if offset_value == "upper-left":
|
|
295
|
+
globals()[click_type](diagonal_point(point, 0 - get_img_width(img), get_img_height(img)))
|
|
296
|
+
if offset_value == "upper-right":
|
|
297
|
+
globals()[click_type](diagonal_point(point, get_img_width(img), get_img_height(img)))
|
|
298
|
+
if offset_value == "lower-left":
|
|
299
|
+
globals()[click_type](diagonal_point(point, 0 - get_img_width(img), 0 - get_img_height(img)))
|
|
300
|
+
if offset_value == "lower-right":
|
|
301
|
+
globals()[click_type](diagonal_point(point, get_img_width(img), 0 - get_img_height(img)))
|
automonkey/key_funcs.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Keyboard-layout-aware typing, key presses and shortcuts.
|
|
2
|
+
|
|
3
|
+
pyautogui maps characters to keys with the layout of the *Python* thread (fixed at
|
|
4
|
+
import time) and sends bare virtual-key codes, which key remappers then rewrite. On
|
|
5
|
+
Windows that mangles text whenever the target window uses another layout (German
|
|
6
|
+
QWERTZ: ';' arrives as 'ö') or a remapper swaps keys (PowerToys y<->z: 'KEYBOARD'
|
|
7
|
+
arrives as 'KEZBOARD').
|
|
8
|
+
|
|
9
|
+
On Windows:
|
|
10
|
+
* text is injected as Unicode characters (KEYEVENTF_UNICODE): independent of the
|
|
11
|
+
layout and of key remappers, and it covers non-ASCII text (ă, ß, €) as well;
|
|
12
|
+
* keys and shortcuts are resolved with the layout of the *foreground* window, and
|
|
13
|
+
PowerToys Keyboard Manager single-key remaps are undone, so the app receives the
|
|
14
|
+
key that was asked for (ctrl+z stays undo when y and z are swapped);
|
|
15
|
+
* registry "Scancode Map" overrides rewrite hardware scancodes inside the keyboard
|
|
16
|
+
driver; injected input never passes through them, so they need no correction.
|
|
17
|
+
|
|
18
|
+
Set AUTOMONKEY_TYPING=keys (or pass mode="keys") to type text as real key presses
|
|
19
|
+
instead, for targets that ignore Unicode injection (some games, remote desktops, VMs).
|
|
20
|
+
Elsewhere this falls back to pyautogui, pasting text it cannot type.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
import time
|
|
28
|
+
|
|
29
|
+
import pyautogui
|
|
30
|
+
|
|
31
|
+
_TEXT_KEYS = {"\n": "enter", "\t": "tab"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _mode(mode: str | None) -> str:
|
|
35
|
+
return (mode or os.environ.get("AUTOMONKEY_TYPING") or "unicode").lower()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _flatten(keys) -> list[str]:
|
|
39
|
+
if len(keys) == 1 and isinstance(keys[0], (list, tuple)):
|
|
40
|
+
keys = keys[0]
|
|
41
|
+
return [str(k) for k in keys]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _name(key: str) -> str:
|
|
45
|
+
return key if len(key) == 1 else key.lower()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if sys.platform == "win32":
|
|
49
|
+
import ctypes
|
|
50
|
+
from ctypes import wintypes
|
|
51
|
+
|
|
52
|
+
_u32 = ctypes.WinDLL("user32", use_last_error=True)
|
|
53
|
+
_k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
54
|
+
|
|
55
|
+
INPUT_KEYBOARD = 1
|
|
56
|
+
KEYEVENTF_EXTENDEDKEY = 0x1
|
|
57
|
+
KEYEVENTF_KEYUP = 0x2
|
|
58
|
+
KEYEVENTF_UNICODE = 0x4
|
|
59
|
+
MAPVK_VK_TO_VSC_EX = 4
|
|
60
|
+
VK_SHIFT, VK_CONTROL, VK_MENU = 0x10, 0x11, 0x12
|
|
61
|
+
|
|
62
|
+
class _MOUSEINPUT(ctypes.Structure):
|
|
63
|
+
_fields_ = [("dx", wintypes.LONG), ("dy", wintypes.LONG), ("mouseData", wintypes.DWORD),
|
|
64
|
+
("dwFlags", wintypes.DWORD), ("time", wintypes.DWORD), ("dwExtraInfo", ctypes.c_size_t)]
|
|
65
|
+
|
|
66
|
+
class _KEYBDINPUT(ctypes.Structure):
|
|
67
|
+
_fields_ = [("wVk", wintypes.WORD), ("wScan", wintypes.WORD), ("dwFlags", wintypes.DWORD),
|
|
68
|
+
("time", wintypes.DWORD), ("dwExtraInfo", ctypes.c_size_t)]
|
|
69
|
+
|
|
70
|
+
class _INPUTUNION(ctypes.Union): # sized by its largest member, as SendInput expects
|
|
71
|
+
_fields_ = [("mi", _MOUSEINPUT), ("ki", _KEYBDINPUT)]
|
|
72
|
+
|
|
73
|
+
class _INPUT(ctypes.Structure):
|
|
74
|
+
_fields_ = [("type", wintypes.DWORD), ("u", _INPUTUNION)]
|
|
75
|
+
|
|
76
|
+
class _PROCESSENTRY32W(ctypes.Structure):
|
|
77
|
+
_fields_ = [("dwSize", wintypes.DWORD), ("cntUsage", wintypes.DWORD), ("th32ProcessID", wintypes.DWORD),
|
|
78
|
+
("th32DefaultHeapID", ctypes.c_size_t), ("th32ModuleID", wintypes.DWORD),
|
|
79
|
+
("cntThreads", wintypes.DWORD), ("th32ParentProcessID", wintypes.DWORD),
|
|
80
|
+
("pcPriClassBase", wintypes.LONG), ("dwFlags", wintypes.DWORD),
|
|
81
|
+
("szExeFile", wintypes.WCHAR * 260)]
|
|
82
|
+
|
|
83
|
+
_u32.GetForegroundWindow.restype = wintypes.HWND
|
|
84
|
+
_u32.GetWindowThreadProcessId.argtypes = (wintypes.HWND, ctypes.POINTER(wintypes.DWORD))
|
|
85
|
+
_u32.GetWindowThreadProcessId.restype = wintypes.DWORD
|
|
86
|
+
_u32.GetKeyboardLayout.argtypes = (wintypes.DWORD,)
|
|
87
|
+
_u32.GetKeyboardLayout.restype = wintypes.HKL
|
|
88
|
+
_u32.VkKeyScanExW.argtypes = (wintypes.WCHAR, wintypes.HKL)
|
|
89
|
+
_u32.VkKeyScanExW.restype = ctypes.c_short
|
|
90
|
+
_u32.MapVirtualKeyExW.argtypes = (wintypes.UINT, wintypes.UINT, wintypes.HKL)
|
|
91
|
+
_u32.MapVirtualKeyExW.restype = wintypes.UINT
|
|
92
|
+
_u32.ToUnicodeEx.argtypes = (wintypes.UINT, wintypes.UINT, ctypes.POINTER(ctypes.c_ubyte),
|
|
93
|
+
wintypes.LPWSTR, ctypes.c_int, wintypes.UINT, wintypes.HKL)
|
|
94
|
+
_u32.SendInput.argtypes = (wintypes.UINT, ctypes.POINTER(_INPUT), ctypes.c_int)
|
|
95
|
+
_u32.SendInput.restype = wintypes.UINT
|
|
96
|
+
_k32.CreateToolhelp32Snapshot.argtypes = (wintypes.DWORD, wintypes.DWORD)
|
|
97
|
+
_k32.CreateToolhelp32Snapshot.restype = wintypes.HANDLE
|
|
98
|
+
_k32.Process32FirstW.argtypes = (wintypes.HANDLE, ctypes.POINTER(_PROCESSENTRY32W))
|
|
99
|
+
_k32.Process32NextW.argtypes = (wintypes.HANDLE, ctypes.POINTER(_PROCESSENTRY32W))
|
|
100
|
+
_k32.CloseHandle.argtypes = (wintypes.HANDLE,)
|
|
101
|
+
|
|
102
|
+
# Named keys ('enter', 'ctrl', 'f5', ...) have fixed virtual-key codes.
|
|
103
|
+
_NAMED = {k: v for k, v in pyautogui.platformModule.keyboardMapping.items() if len(k) > 1 and v}
|
|
104
|
+
|
|
105
|
+
def foreground_layout() -> int:
|
|
106
|
+
"""Keyboard layout (HKL) of the window that will receive the input."""
|
|
107
|
+
hwnd = _u32.GetForegroundWindow()
|
|
108
|
+
tid = _u32.GetWindowThreadProcessId(hwnd, None) if hwnd else 0
|
|
109
|
+
return _u32.GetKeyboardLayout(tid)
|
|
110
|
+
|
|
111
|
+
def _running(exe: str) -> bool:
|
|
112
|
+
snap = _k32.CreateToolhelp32Snapshot(0x2, 0) # TH32CS_SNAPPROCESS
|
|
113
|
+
if snap in (None, wintypes.HANDLE(-1).value):
|
|
114
|
+
return False
|
|
115
|
+
try:
|
|
116
|
+
entry = _PROCESSENTRY32W(dwSize=ctypes.sizeof(_PROCESSENTRY32W))
|
|
117
|
+
ok = _k32.Process32FirstW(snap, ctypes.byref(entry))
|
|
118
|
+
while ok:
|
|
119
|
+
if entry.szExeFile.lower() == exe:
|
|
120
|
+
return True
|
|
121
|
+
ok = _k32.Process32NextW(snap, ctypes.byref(entry))
|
|
122
|
+
return False
|
|
123
|
+
finally:
|
|
124
|
+
_k32.CloseHandle(snap)
|
|
125
|
+
|
|
126
|
+
def powertoys_remaps(root: str | None = None) -> dict[int, int]:
|
|
127
|
+
"""Single-key remaps PowerToys Keyboard Manager applies, {original_vk: new_vk}.
|
|
128
|
+
Its hook rewrites injected keys too, so these have to be undone. Empty when the
|
|
129
|
+
engine isn't running."""
|
|
130
|
+
root = root or os.path.join(os.environ.get("LOCALAPPDATA", ""), "Microsoft", "PowerToys", "Keyboard Manager")
|
|
131
|
+
try:
|
|
132
|
+
with open(os.path.join(root, "settings.json"), encoding="utf-8-sig") as f:
|
|
133
|
+
config = json.load(f)["properties"]["activeConfiguration"]["value"]
|
|
134
|
+
with open(os.path.join(root, f"{config}.json"), encoding="utf-8-sig") as f:
|
|
135
|
+
entries = json.load(f)["remapKeys"]["inProcess"]
|
|
136
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
137
|
+
return {}
|
|
138
|
+
remaps = {}
|
|
139
|
+
for e in entries:
|
|
140
|
+
new = str(e.get("newRemapKeys", ""))
|
|
141
|
+
if new.isdigit(): # "17;67" would be a key -> shortcut remap; leave those alone
|
|
142
|
+
remaps[int(e["originalKeys"])] = int(new)
|
|
143
|
+
return remaps
|
|
144
|
+
|
|
145
|
+
_remap_cache = (0.0, {})
|
|
146
|
+
|
|
147
|
+
def _remaps() -> dict[int, int]:
|
|
148
|
+
global _remap_cache
|
|
149
|
+
stamp, cached = _remap_cache
|
|
150
|
+
if time.monotonic() - stamp > 5:
|
|
151
|
+
cached = powertoys_remaps() if _running("powertoys.keyboardmanagerengine.exe") else {}
|
|
152
|
+
_remap_cache = (time.monotonic(), cached)
|
|
153
|
+
return cached
|
|
154
|
+
|
|
155
|
+
def _source_vk(vk: int, remaps: dict[int, int]) -> int:
|
|
156
|
+
"""The key to inject so that, after remapping, the app receives `vk`."""
|
|
157
|
+
for orig, new in remaps.items():
|
|
158
|
+
if new == vk:
|
|
159
|
+
return orig
|
|
160
|
+
return vk
|
|
161
|
+
|
|
162
|
+
def _key(vk: int, up: bool, hkl, remaps) -> _INPUT:
|
|
163
|
+
vk = _source_vk(vk, remaps)
|
|
164
|
+
scan = _u32.MapVirtualKeyExW(vk, MAPVK_VK_TO_VSC_EX, hkl)
|
|
165
|
+
flags = (KEYEVENTF_KEYUP if up else 0) | (KEYEVENTF_EXTENDEDKEY if scan >> 8 == 0xE0 else 0)
|
|
166
|
+
return _INPUT(type=INPUT_KEYBOARD, u=_INPUTUNION(ki=_KEYBDINPUT(wVk=vk, wScan=scan & 0xFF, dwFlags=flags)))
|
|
167
|
+
|
|
168
|
+
def _chord(vks: list[int], hkl, remaps) -> list[_INPUT]:
|
|
169
|
+
return [_key(vk, False, hkl, remaps) for vk in vks] + [_key(vk, True, hkl, remaps) for vk in reversed(vks)]
|
|
170
|
+
|
|
171
|
+
def _unicode(ch: str) -> list[_INPUT]:
|
|
172
|
+
data = ch.encode("utf-16-le") # characters beyond the BMP are two surrogate units
|
|
173
|
+
out = []
|
|
174
|
+
for i in range(0, len(data), 2):
|
|
175
|
+
unit = int.from_bytes(data[i:i + 2], "little")
|
|
176
|
+
for flags in (KEYEVENTF_UNICODE, KEYEVENTF_UNICODE | KEYEVENTF_KEYUP):
|
|
177
|
+
out.append(_INPUT(type=INPUT_KEYBOARD, u=_INPUTUNION(ki=_KEYBDINPUT(wScan=unit, dwFlags=flags))))
|
|
178
|
+
return out
|
|
179
|
+
|
|
180
|
+
def _send(inputs: list[_INPUT]):
|
|
181
|
+
arr = (_INPUT * len(inputs))(*inputs)
|
|
182
|
+
if _u32.SendInput(len(inputs), arr, ctypes.sizeof(_INPUT)) != len(inputs):
|
|
183
|
+
raise OSError(f"SendInput failed: {ctypes.WinError(ctypes.get_last_error())}")
|
|
184
|
+
|
|
185
|
+
def char_keys(ch: str, hkl) -> list[int] | None:
|
|
186
|
+
"""[modifier VKs..., VK] that type `ch` on layout `hkl`, or None if the layout
|
|
187
|
+
has no plain key for it (missing, or only reachable through a dead key)."""
|
|
188
|
+
if ord(ch) > 0xFFFF: # no layout has a key for a surrogate pair
|
|
189
|
+
return None
|
|
190
|
+
res = _u32.VkKeyScanExW(ch, hkl)
|
|
191
|
+
if res == -1:
|
|
192
|
+
return None
|
|
193
|
+
vk, state = res & 0xFF, (res >> 8) & 0xFF
|
|
194
|
+
if state & ~0x7: # Hankaku / layout-specific shift states we can't press
|
|
195
|
+
return None
|
|
196
|
+
mods = [m for bit, m in ((1, VK_SHIFT), (2, VK_CONTROL), (4, VK_MENU)) if state & bit]
|
|
197
|
+
keystate = (ctypes.c_ubyte * 256)()
|
|
198
|
+
for m in mods:
|
|
199
|
+
keystate[m] = 0x80
|
|
200
|
+
buf = ctypes.create_unicode_buffer(8)
|
|
201
|
+
# flag 0x4: don't touch the thread's dead-key state (Windows 10 1607+)
|
|
202
|
+
if _u32.ToUnicodeEx(vk, _u32.MapVirtualKeyExW(vk, 0, hkl), keystate, buf, 8, 0x4, hkl) < 0:
|
|
203
|
+
return None
|
|
204
|
+
return mods + [vk]
|
|
205
|
+
|
|
206
|
+
def _shortcut_vks(keys: list[str], hkl) -> list[int]:
|
|
207
|
+
vks = []
|
|
208
|
+
for key in keys:
|
|
209
|
+
if key in _NAMED:
|
|
210
|
+
add = [_NAMED[key]]
|
|
211
|
+
elif len(key) == 1 and key.isascii() and key.isalnum():
|
|
212
|
+
add = [ord(key.upper())] # apps match letter/digit shortcuts by VK on every layout
|
|
213
|
+
else: # punctuation: whatever key (and shift) types it on this layout
|
|
214
|
+
add = char_keys(key, hkl) if len(key) == 1 else None
|
|
215
|
+
if not add:
|
|
216
|
+
raise ValueError(f"key {key!r} is unknown or not on the active keyboard layout")
|
|
217
|
+
vks += [vk for vk in add if vk not in vks]
|
|
218
|
+
return vks
|
|
219
|
+
|
|
220
|
+
def write(text: str, interval: float = 0.01, mode: str | None = None):
|
|
221
|
+
"""Type `text` exactly, whatever the keyboard layout or key remaps."""
|
|
222
|
+
hkl = foreground_layout()
|
|
223
|
+
remaps = _remaps()
|
|
224
|
+
as_keys = _mode(mode) == "keys"
|
|
225
|
+
for ch in text.replace("\r\n", "\n").replace("\r", "\n"):
|
|
226
|
+
pyautogui.failSafeCheck()
|
|
227
|
+
vks = [_NAMED[_TEXT_KEYS[ch]]] if ch in _TEXT_KEYS else char_keys(ch, hkl) if as_keys else None
|
|
228
|
+
_send(_chord(vks, hkl, remaps) if vks else _unicode(ch))
|
|
229
|
+
time.sleep(interval)
|
|
230
|
+
time.sleep(pyautogui.PAUSE)
|
|
231
|
+
|
|
232
|
+
def press(keys, presses: int = 1, interval: float = 0.0):
|
|
233
|
+
"""Press and release each key in turn: a named key ('enter', 'f5') or a character."""
|
|
234
|
+
hkl = foreground_layout()
|
|
235
|
+
remaps = _remaps()
|
|
236
|
+
for _ in range(presses):
|
|
237
|
+
for key in [keys] if isinstance(keys, str) else keys:
|
|
238
|
+
pyautogui.failSafeCheck()
|
|
239
|
+
key = _name(key)
|
|
240
|
+
if key in _NAMED:
|
|
241
|
+
_send(_chord([_NAMED[key]], hkl, remaps))
|
|
242
|
+
elif len(key) == 1:
|
|
243
|
+
vks = char_keys(key, hkl)
|
|
244
|
+
_send(_chord(vks, hkl, remaps) if vks else _unicode(key))
|
|
245
|
+
else:
|
|
246
|
+
raise ValueError(f"unknown key {key!r}")
|
|
247
|
+
time.sleep(interval)
|
|
248
|
+
time.sleep(pyautogui.PAUSE)
|
|
249
|
+
|
|
250
|
+
def hotkey(*keys, interval: float = 0.0):
|
|
251
|
+
"""Hold the keys down in order, then release them in reverse: hotkey('ctrl', 'z')."""
|
|
252
|
+
pyautogui.failSafeCheck()
|
|
253
|
+
hkl = foreground_layout()
|
|
254
|
+
remaps = _remaps()
|
|
255
|
+
vks = _shortcut_vks([_name(k) for k in _flatten(keys)], hkl)
|
|
256
|
+
if interval:
|
|
257
|
+
for inp in _chord(vks, hkl, remaps):
|
|
258
|
+
_send([inp])
|
|
259
|
+
time.sleep(interval)
|
|
260
|
+
else:
|
|
261
|
+
_send(_chord(vks, hkl, remaps))
|
|
262
|
+
time.sleep(pyautogui.PAUSE)
|
|
263
|
+
|
|
264
|
+
else:
|
|
265
|
+
def write(text: str, interval: float = 0.01, mode: str | None = None):
|
|
266
|
+
"""Type `text`; pyautogui can't type non-ASCII (e.g. ă, ș), so that is pasted."""
|
|
267
|
+
if text.isascii():
|
|
268
|
+
pyautogui.write(text, interval=interval)
|
|
269
|
+
else:
|
|
270
|
+
import pyperclip
|
|
271
|
+
pyperclip.copy(text)
|
|
272
|
+
pyautogui.hotkey("ctrl", "v")
|
|
273
|
+
|
|
274
|
+
def press(keys, presses: int = 1, interval: float = 0.0):
|
|
275
|
+
pyautogui.press([_name(k) for k in ([keys] if isinstance(keys, str) else keys)],
|
|
276
|
+
presses=presses, interval=interval)
|
|
277
|
+
|
|
278
|
+
def hotkey(*keys, interval: float = 0.0):
|
|
279
|
+
pyautogui.hotkey(*[_name(k) for k in _flatten(keys)], interval=interval)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from pyautogui import move # It is used
|
|
2
|
+
|
|
3
|
+
def movedown(distance: int = 10, duration: float = 0.0):
|
|
4
|
+
"""Moves the mouse down
|
|
5
|
+
|
|
6
|
+
Args:
|
|
7
|
+
distance (int, optional): Distance to move. Defaults to 10.
|
|
8
|
+
duration (float, optional): Duration of the movement in seconds. Defaults to 0.0.
|
|
9
|
+
"""
|
|
10
|
+
move(0, int(distance), float(duration))
|
|
11
|
+
|
|
12
|
+
def moveleft(distance: int = 10, duration: float = 0.0):
|
|
13
|
+
"""Moves the mouse left
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
distance (int, optional): Distance to move. Defaults to 10.
|
|
17
|
+
duration (float, optional): Duration of the movement in seconds. Defaults to 0.0.
|
|
18
|
+
"""
|
|
19
|
+
move(-int(distance), 0, float(duration))
|
|
20
|
+
|
|
21
|
+
def moveright(distance: int = 10, duration: float = 0.0):
|
|
22
|
+
"""Moves the mouse right
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
distance (int, optional): Distance to move. Defaults to 10.
|
|
26
|
+
duration (float, optional): Duration of the movement in seconds. Defaults to 0.0.
|
|
27
|
+
"""
|
|
28
|
+
move(int(distance), 0, float(duration))
|
|
29
|
+
|
|
30
|
+
def moveup(distance: int = 10, duration: float = 0.0):
|
|
31
|
+
"""Moves the mouse up
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
distance (int, optional): Distance to move. Defaults to 10.
|
|
35
|
+
duration (float, optional): Duration of the movement in seconds. Defaults to 0.0.
|
|
36
|
+
"""
|
|
37
|
+
move(0, -int(distance), float(duration))
|