PyClickerRPA 0.1.0__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.
@@ -0,0 +1,14 @@
1
+ """AutoClicker — Python port of the Qt/C++ visual auto-click / RPA tool.
2
+
3
+ Phase 1 exposes the headless engine (models + capture + clicker + matcher +
4
+ windows + runner). The GUI (PySide6) lands in later phases.
5
+ """
6
+ from autoclicker.models import (
7
+ Step, Task, StepType, UntilCondition, RetryPolicy,
8
+ RepeatConfig, UntilConfig, RetryConfig,
9
+ )
10
+
11
+ __all__ = [
12
+ "Step", "Task", "StepType", "UntilCondition", "RetryPolicy",
13
+ "RepeatConfig", "UntilConfig", "RetryConfig",
14
+ ]
autoclicker/app.py ADDED
@@ -0,0 +1,21 @@
1
+ """GUI entry point: python -m autoclicker.app"""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+
6
+
7
+ def main() -> int:
8
+ from autoclicker.dpi import set_dpi_aware
9
+ set_dpi_aware()
10
+
11
+ from PySide6.QtWidgets import QApplication
12
+ from autoclicker.gui.main_window import MainWindow
13
+
14
+ app = QApplication(sys.argv)
15
+ win = MainWindow()
16
+ win.show()
17
+ return app.exec()
18
+
19
+
20
+ if __name__ == "__main__":
21
+ raise SystemExit(main())
autoclicker/capture.py ADDED
@@ -0,0 +1,94 @@
1
+ """Screen / window capture -> BGR numpy arrays (OpenCV order).
2
+
3
+ Port of capture.cpp: ``capture_window`` grabs a specific HWND via PrintWindow
4
+ (works for background windows that aren't fully occluded); ``capture_screen``
5
+ grabs the whole virtual desktop via GDI BitBlt. No mss dependency — win32 GDI
6
+ covers both.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+
12
+ try:
13
+ import win32gui
14
+ import win32ui
15
+ import win32con
16
+ _HAS_WIN32 = True
17
+ except ImportError: # allow import on non-Windows for tests that mock capture
18
+ _HAS_WIN32 = False
19
+
20
+
21
+ # PrintWindow flag that renders the full content (incl. DWM-composited/partly hidden)
22
+ PW_RENDERFULLCONTENT = 0x00000002
23
+
24
+
25
+ def _bitmap_to_bgr(bmp, width: int, height: int) -> np.ndarray:
26
+ """Convert a win32ui bitmap's BGRA bits into an (H, W, 3) BGR ndarray."""
27
+ bits = bmp.GetBitmapBits(True)
28
+ arr = np.frombuffer(bits, dtype=np.uint8).reshape((height, width, 4))
29
+ return arr[:, :, :3].copy() # drop alpha; BGRA -> BGR
30
+
31
+
32
+ def capture_window(hwnd) -> np.ndarray:
33
+ """Capture the client area of *hwnd* as a BGR ndarray."""
34
+ if not _HAS_WIN32:
35
+ raise RuntimeError("win32 modules unavailable")
36
+
37
+ left, top, right, bottom = win32gui.GetClientRect(hwnd)
38
+ width, height = right - left, bottom - top
39
+ if width <= 0 or height <= 0:
40
+ return np.empty((0, 0, 3), dtype=np.uint8)
41
+
42
+ hwnd_dc = win32gui.GetWindowDC(hwnd)
43
+ mfc_dc = win32ui.CreateDCFromHandle(hwnd_dc)
44
+ save_dc = mfc_dc.CreateCompatibleDC()
45
+ bmp = win32ui.CreateBitmap()
46
+ bmp.CreateCompatibleBitmap(mfc_dc, width, height)
47
+ save_dc.SelectObject(bmp)
48
+
49
+ try:
50
+ import ctypes
51
+ ok = ctypes.windll.user32.PrintWindow(hwnd, save_dc.GetSafeHdc(), PW_RENDERFULLCONTENT)
52
+ if not ok:
53
+ # Fallback: BitBlt the window DC
54
+ save_dc.BitBlt((0, 0), (width, height), mfc_dc, (0, 0), win32con.SRCCOPY)
55
+ img = _bitmap_to_bgr(bmp, width, height)
56
+ finally:
57
+ win32gui.DeleteObject(bmp.GetHandle())
58
+ save_dc.DeleteDC()
59
+ mfc_dc.DeleteDC()
60
+ win32gui.ReleaseDC(hwnd, hwnd_dc)
61
+ return img
62
+
63
+
64
+ def capture_screen() -> np.ndarray:
65
+ """Capture the entire virtual desktop as a BGR ndarray."""
66
+ if not _HAS_WIN32:
67
+ raise RuntimeError("win32 modules unavailable")
68
+
69
+ x = win32api_GetSystemMetrics(76) # SM_XVIRTUALSCREEN
70
+ y = win32api_GetSystemMetrics(77) # SM_YVIRTUALSCREEN
71
+ width = win32api_GetSystemMetrics(78) # SM_CXVIRTUALSCREEN
72
+ height = win32api_GetSystemMetrics(79) # SM_CYVIRTUALSCREEN
73
+
74
+ hdesktop = win32gui.GetDesktopWindow()
75
+ desktop_dc = win32gui.GetWindowDC(hdesktop)
76
+ mfc_dc = win32ui.CreateDCFromHandle(desktop_dc)
77
+ save_dc = mfc_dc.CreateCompatibleDC()
78
+ bmp = win32ui.CreateBitmap()
79
+ bmp.CreateCompatibleBitmap(mfc_dc, width, height)
80
+ save_dc.SelectObject(bmp)
81
+ try:
82
+ save_dc.BitBlt((0, 0), (width, height), mfc_dc, (x, y), win32con.SRCCOPY)
83
+ img = _bitmap_to_bgr(bmp, width, height)
84
+ finally:
85
+ win32gui.DeleteObject(bmp.GetHandle())
86
+ save_dc.DeleteDC()
87
+ mfc_dc.DeleteDC()
88
+ win32gui.ReleaseDC(hdesktop, desktop_dc)
89
+ return img
90
+
91
+
92
+ def win32api_GetSystemMetrics(index: int) -> int:
93
+ import win32api
94
+ return win32api.GetSystemMetrics(index)
autoclicker/clicker.py ADDED
@@ -0,0 +1,212 @@
1
+ """Mouse / keyboard synthesis via Win32 SendInput — port of clicker.cpp.
2
+
3
+ Window-local coordinates are converted to screen coords (ClientToScreen) then
4
+ normalised to the 0..65535 absolute range over the primary screen, matching the
5
+ C++ implementation exactly (incl. its single-monitor assumption).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import ctypes
10
+ import time
11
+
12
+ try:
13
+ import win32gui
14
+ import win32con
15
+ _HAS_WIN32 = True
16
+ except ImportError:
17
+ _HAS_WIN32 = False
18
+
19
+ WHEEL_DELTA = 120
20
+
21
+ # ---- ctypes SendInput plumbing ------------------------------------------------
22
+ if _HAS_WIN32:
23
+ from ctypes import wintypes
24
+
25
+ ULONG_PTR = ctypes.wintypes.WPARAM
26
+
27
+ class _MOUSEINPUT(ctypes.Structure):
28
+ _fields_ = [("dx", wintypes.LONG), ("dy", wintypes.LONG),
29
+ ("mouseData", wintypes.DWORD), ("dwFlags", wintypes.DWORD),
30
+ ("time", wintypes.DWORD), ("dwExtraInfo", ULONG_PTR)]
31
+
32
+ class _KEYBDINPUT(ctypes.Structure):
33
+ _fields_ = [("wVk", wintypes.WORD), ("wScan", wintypes.WORD),
34
+ ("dwFlags", wintypes.DWORD), ("time", wintypes.DWORD),
35
+ ("dwExtraInfo", ULONG_PTR)]
36
+
37
+ class _INPUTunion(ctypes.Union):
38
+ _fields_ = [("mi", _MOUSEINPUT), ("ki", _KEYBDINPUT)]
39
+
40
+ class _INPUT(ctypes.Structure):
41
+ _fields_ = [("type", wintypes.DWORD), ("u", _INPUTunion)]
42
+
43
+ INPUT_MOUSE, INPUT_KEYBOARD = 0, 1
44
+ MOUSEEVENTF_MOVE, MOUSEEVENTF_ABSOLUTE = 0x0001, 0x8000
45
+ MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP = 0x0002, 0x0004
46
+ MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP = 0x0008, 0x0010
47
+ MOUSEEVENTF_WHEEL = 0x0800
48
+ MOUSEEVENTF_VIRTUALDESK = 0x4000
49
+ KEYEVENTF_KEYUP, KEYEVENTF_UNICODE = 0x0002, 0x0004
50
+ SM_CXSCREEN, SM_CYSCREEN = 0, 1
51
+ SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN = 76, 77
52
+ SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN = 78, 79
53
+ # Absolute move across the whole virtual desktop (multi-monitor correct)
54
+ _ABS_MOVE = MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK
55
+
56
+ def _send(*inputs: _INPUT) -> None:
57
+ n = len(inputs)
58
+ arr = (_INPUT * n)(*inputs)
59
+ ctypes.windll.user32.SendInput(n, arr, ctypes.sizeof(_INPUT))
60
+
61
+ def _mouse(dx=0, dy=0, data=0, flags=0) -> _INPUT:
62
+ return _INPUT(type=INPUT_MOUSE, u=_INPUTunion(
63
+ mi=_MOUSEINPUT(dx, dy, data & 0xFFFFFFFF, flags, 0, 0)))
64
+
65
+ def _kbd(vk=0, scan=0, flags=0) -> _INPUT:
66
+ return _INPUT(type=INPUT_KEYBOARD, u=_INPUTunion(
67
+ ki=_KEYBDINPUT(vk, scan, flags, 0, 0)))
68
+
69
+ def _metric(i: int) -> int:
70
+ return ctypes.windll.user32.GetSystemMetrics(i)
71
+
72
+
73
+ def _to_screen(hwnd, x: int, y: int) -> tuple[int, int]:
74
+ if hwnd:
75
+ return win32gui.ClientToScreen(hwnd, (x, y))
76
+ return x, y
77
+
78
+
79
+ def _norm(sx: int, sy: int) -> tuple[int, int]:
80
+ # Normalize to 0..65535 over the whole virtual desktop (all monitors),
81
+ # to be paired with MOUSEEVENTF_VIRTUALDESK. Fixes the primary-screen-only
82
+ # assumption that misplaced clicks on multi-monitor / scaled setups.
83
+ x0, y0 = _metric(SM_XVIRTUALSCREEN), _metric(SM_YVIRTUALSCREEN)
84
+ cx = _metric(SM_CXVIRTUALSCREEN) or 1
85
+ cy = _metric(SM_CYVIRTUALSCREEN) or 1
86
+ nx = int((sx - x0) * 65535.0 / cx)
87
+ ny = int((sy - y0) * 65535.0 / cy)
88
+ return nx, ny
89
+
90
+
91
+ class Clicker:
92
+ """Thin, injectable wrapper over Win32 input synthesis."""
93
+
94
+ # ---- mouse ----
95
+ def click(self, hwnd, x: int, y: int) -> None:
96
+ sx, sy = _to_screen(hwnd, x, y)
97
+ nx, ny = _norm(sx, sy)
98
+ _send(_mouse(nx, ny, flags=_ABS_MOVE),
99
+ _mouse(flags=MOUSEEVENTF_LEFTDOWN),
100
+ _mouse(flags=MOUSEEVENTF_LEFTUP))
101
+
102
+ def double_click(self, hwnd, x: int, y: int) -> None:
103
+ self.click(hwnd, x, y)
104
+ time.sleep(0.05)
105
+ self.click(hwnd, x, y)
106
+
107
+ def right_click(self, hwnd, x: int, y: int) -> None:
108
+ sx, sy = _to_screen(hwnd, x, y)
109
+ nx, ny = _norm(sx, sy)
110
+ _send(_mouse(nx, ny, flags=_ABS_MOVE),
111
+ _mouse(flags=MOUSEEVENTF_RIGHTDOWN),
112
+ _mouse(flags=MOUSEEVENTF_RIGHTUP))
113
+
114
+ def long_press(self, hwnd, x: int, y: int, hold_ms: int) -> None:
115
+ sx, sy = _to_screen(hwnd, x, y)
116
+ nx, ny = _norm(sx, sy)
117
+ _send(_mouse(nx, ny, flags=_ABS_MOVE))
118
+ _send(_mouse(flags=MOUSEEVENTF_LEFTDOWN))
119
+ time.sleep(hold_ms / 1000.0)
120
+ _send(_mouse(flags=MOUSEEVENTF_LEFTUP))
121
+
122
+ def swipe(self, hwnd, x1, y1, x2, y2, duration_ms: int = 500) -> None:
123
+ steps = 20
124
+ step_delay = duration_ms / steps / 1000.0
125
+ sx1, sy1 = _to_screen(hwnd, x1, y1)
126
+ sx2, sy2 = _to_screen(hwnd, x2, y2)
127
+ nx, ny = _norm(sx1, sy1)
128
+ _send(_mouse(nx, ny, flags=_ABS_MOVE))
129
+ _send(_mouse(flags=MOUSEEVENTF_LEFTDOWN))
130
+ for i in range(1, steps + 1):
131
+ cx = sx1 + (sx2 - sx1) * i // steps
132
+ cy = sy1 + (sy2 - sy1) * i // steps
133
+ mx, my = _norm(cx, cy)
134
+ _send(_mouse(mx, my, flags=_ABS_MOVE))
135
+ time.sleep(step_delay)
136
+ _send(_mouse(flags=MOUSEEVENTF_LEFTUP))
137
+
138
+ def drag(self, hwnd, x1, y1, x2, y2, duration_ms: int = 300) -> None:
139
+ self.swipe(hwnd, x1, y1, x2, y2, duration_ms)
140
+
141
+ def pinch(self, hwnd, cx, cy, start_dist, end_dist, duration_ms: int = 400) -> None:
142
+ sx, sy = _to_screen(hwnd, cx, cy)
143
+ nx, ny = _norm(sx, sy)
144
+ _send(_mouse(nx, ny, flags=_ABS_MOVE))
145
+ steps = 10
146
+ step_delay = duration_ms / steps / 1000.0
147
+ scroll_per_step = ((end_dist - start_dist) * WHEEL_DELTA) // (steps * 50)
148
+ for _ in range(steps):
149
+ _send(_mouse(data=scroll_per_step, flags=MOUSEEVENTF_WHEEL))
150
+ time.sleep(step_delay)
151
+
152
+ def scroll(self, hwnd, x, y, amount: int) -> None:
153
+ sx, sy = _to_screen(hwnd, x, y)
154
+ nx, ny = _norm(sx, sy)
155
+ _send(_mouse(nx, ny, flags=_ABS_MOVE))
156
+ _send(_mouse(data=amount * WHEEL_DELTA, flags=MOUSEEVENTF_WHEEL))
157
+
158
+ # ---- keyboard ----
159
+ def key_down(self, vk: int) -> None:
160
+ _send(_kbd(vk=vk))
161
+
162
+ def key_up(self, vk: int) -> None:
163
+ _send(_kbd(vk=vk, flags=KEYEVENTF_KEYUP))
164
+
165
+ def key_press(self, vk: int) -> None:
166
+ self.key_down(vk)
167
+ time.sleep(0.03)
168
+ self.key_up(vk)
169
+
170
+ def type_text(self, text: str) -> None:
171
+ for ch in text:
172
+ code = ord(ch)
173
+ _send(_kbd(scan=code, flags=KEYEVENTF_UNICODE),
174
+ _kbd(scan=code, flags=KEYEVENTF_UNICODE | KEYEVENTF_KEYUP))
175
+ time.sleep(0.02)
176
+
177
+ def hotkey(self, modifier: int, vk: int) -> None:
178
+ if modifier:
179
+ self.key_down(modifier)
180
+ self.key_press(vk)
181
+ if modifier:
182
+ self.key_up(modifier)
183
+
184
+
185
+ # ---- vk parsing (port of Clicker::parseVkCode) --------------------------------
186
+ _NAMED_VK = {
187
+ "ENTER": 0x0D, "RETURN": 0x0D, "ESC": 0x1B, "ESCAPE": 0x1B, "TAB": 0x09,
188
+ "SPACE": 0x20, "BACKSPACE": 0x08, "BS": 0x08, "DELETE": 0x2E, "DEL": 0x2E,
189
+ "UP": 0x26, "DOWN": 0x28, "LEFT": 0x25, "RIGHT": 0x27, "HOME": 0x24, "END": 0x23,
190
+ "PAGEUP": 0x21, "PGUP": 0x21, "PAGEDOWN": 0x22, "PGDN": 0x22,
191
+ "CTRL": 0x11, "ALT": 0x12, "SHIFT": 0x10, "WIN": 0x5B,
192
+ }
193
+ _VK_F1 = 0x70
194
+
195
+
196
+ def parse_vk(key_name: str) -> int:
197
+ """Map a key name to a virtual-key code (0 if unknown)."""
198
+ k = (key_name or "").upper().strip()
199
+ if k in _NAMED_VK:
200
+ return _NAMED_VK[k]
201
+ if k.startswith("F") and len(k) <= 3:
202
+ try:
203
+ n = int(k[1:])
204
+ except ValueError:
205
+ n = 0
206
+ if 1 <= n <= 24:
207
+ return _VK_F1 + n - 1
208
+ if len(k) == 1:
209
+ c = k[0]
210
+ if "A" <= c <= "Z" or "0" <= c <= "9":
211
+ return ord(c)
212
+ return 0
autoclicker/dpi.py ADDED
@@ -0,0 +1,30 @@
1
+ """Process DPI awareness.
2
+
3
+ Making the process per-monitor DPI-aware keeps window coordinates, screenshots
4
+ and synthesized clicks in the same physical-pixel space on scaled displays
5
+ (>100% scaling), avoiding offset clicks. Call once at startup.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+
11
+
12
+ def set_dpi_aware() -> None:
13
+ if os.name != "nt":
14
+ return
15
+ import ctypes
16
+ # PER_MONITOR_AWARE_V2 (Win10 1703+) -> PER_MONITOR (Win8.1) -> system-DPI (legacy)
17
+ try:
18
+ ctypes.windll.user32.SetProcessDpiAwarenessContext(ctypes.c_void_p(-4))
19
+ return
20
+ except Exception:
21
+ pass
22
+ try:
23
+ ctypes.windll.shcore.SetProcessDpiAwareness(2)
24
+ return
25
+ except Exception:
26
+ pass
27
+ try:
28
+ ctypes.windll.user32.SetProcessDPIAware()
29
+ except Exception:
30
+ pass
@@ -0,0 +1 @@
1
+ """PySide6 GUI for AutoClicker (Phase 2+)."""
@@ -0,0 +1,91 @@
1
+ """Flow graph connection (edge) — port of flowconnection.cpp.
2
+
3
+ Forward edges are drawn as downward bezier curves; back-edges (target above
4
+ source) are dashed loop arrows and carry an optional repeat-count label.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import math
9
+
10
+ from PySide6.QtCore import QPointF, Qt
11
+ from PySide6.QtGui import QColor, QFont, QPainterPath, QPen, QPolygonF
12
+ from PySide6.QtWidgets import QGraphicsItem, QGraphicsPathItem, QGraphicsTextItem
13
+
14
+
15
+ class FlowConnection(QGraphicsPathItem):
16
+ def __init__(self, from_port, to_port, parent=None):
17
+ super().__init__(parent)
18
+ self.from_port = from_port
19
+ self.to_port = to_port
20
+ self.repeat_count = 1
21
+ self.setPen(QPen(QColor(200, 200, 200), 2))
22
+ self.setFlag(QGraphicsItem.ItemIsSelectable)
23
+ self.setZValue(-1)
24
+
25
+ from_port.connections.append(self)
26
+ to_port.connections.append(self)
27
+
28
+ self._label = QGraphicsTextItem(self)
29
+ self._label.setDefaultTextColor(QColor(255, 200, 50))
30
+ self._label.setFont(QFont("Consolas", 9, QFont.Bold))
31
+ self._label.setVisible(False)
32
+
33
+ self.update_path()
34
+
35
+ def detach(self):
36
+ if self in self.from_port.connections:
37
+ self.from_port.connections.remove(self)
38
+ if self in self.to_port.connections:
39
+ self.to_port.connections.remove(self)
40
+
41
+ def set_repeat_count(self, count: int):
42
+ self.repeat_count = count
43
+ if count > 1 or count < 0:
44
+ self._label.setPlainText("∞" if count < 0 else f"×{count}")
45
+ self._label.setVisible(True)
46
+ else:
47
+ self._label.setVisible(False)
48
+ self.update_path()
49
+
50
+ def update_path(self):
51
+ start = self.from_port.center_scene_pos()
52
+ end = self.to_port.center_scene_pos()
53
+ path = QPainterPath()
54
+ path.moveTo(start)
55
+
56
+ if end.y() < start.y(): # back-edge (loop)
57
+ offset = 40 + abs(start.y() - end.y()) * 0.15
58
+ mid_x = min(start.x(), end.x()) - offset
59
+ path.cubicTo(start.x() - offset * 0.5, start.y() + 30,
60
+ mid_x, (start.y() + end.y()) / 2,
61
+ end.x() - offset * 0.5, end.y() - 30)
62
+ path.lineTo(end)
63
+ self.setPen(QPen(QColor(234, 67, 53), 2, Qt.DashLine))
64
+ else:
65
+ dy = end.y() - start.y()
66
+ ctrl = min(dy * 0.5, 80.0)
67
+ path.cubicTo(start.x(), start.y() + ctrl, end.x(), end.y() - ctrl, end.x(), end.y())
68
+ self.setPen(QPen(QColor(200, 200, 200), 2))
69
+
70
+ self.setPath(path)
71
+ mid = path.pointAtPercent(0.5)
72
+ self._label.setPos(mid + QPointF(5, -10))
73
+
74
+ def paint(self, painter, option, widget=None):
75
+ super().paint(painter, option, widget)
76
+ p = self.path()
77
+ if p.isEmpty():
78
+ return
79
+ before = p.pointAtPercent(0.95)
80
+ tip = p.pointAtPercent(1.0)
81
+ angle = math.atan2(tip.y() - before.y(), tip.x() - before.x())
82
+ size = 10
83
+ p1 = tip - QPointF(math.cos(angle - math.pi / 6) * size, math.sin(angle - math.pi / 6) * size)
84
+ p2 = tip - QPointF(math.cos(angle + math.pi / 6) * size, math.sin(angle + math.pi / 6) * size)
85
+ painter.setPen(Qt.NoPen)
86
+ painter.setBrush(self.pen().color())
87
+ painter.drawPolygon(QPolygonF([tip, p1, p2]))
88
+ if self.isSelected():
89
+ painter.setPen(QPen(Qt.yellow, 2, Qt.DotLine))
90
+ painter.setBrush(Qt.NoBrush)
91
+ painter.drawPath(p)