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.
- autoclicker/__init__.py +14 -0
- autoclicker/app.py +21 -0
- autoclicker/capture.py +94 -0
- autoclicker/clicker.py +212 -0
- autoclicker/dpi.py +30 -0
- autoclicker/gui/__init__.py +1 -0
- autoclicker/gui/flow_connection.py +91 -0
- autoclicker/gui/flow_editor.py +340 -0
- autoclicker/gui/flow_node.py +107 -0
- autoclicker/gui/hotkeys.py +40 -0
- autoclicker/gui/main_window.py +651 -0
- autoclicker/gui/monitor.py +84 -0
- autoclicker/gui/preview_label.py +176 -0
- autoclicker/gui/qt_runner.py +71 -0
- autoclicker/gui/recorder.py +163 -0
- autoclicker/gui/scheduler_widget.py +160 -0
- autoclicker/gui/snippet_library.py +117 -0
- autoclicker/gui/task_widget.py +547 -0
- autoclicker/matcher.py +152 -0
- autoclicker/models.py +468 -0
- autoclicker/ocr.py +63 -0
- autoclicker/run.py +64 -0
- autoclicker/runner.py +666 -0
- autoclicker/windows.py +53 -0
- pyclickerrpa-0.1.0.dist-info/METADATA +90 -0
- pyclickerrpa-0.1.0.dist-info/RECORD +29 -0
- pyclickerrpa-0.1.0.dist-info/WHEEL +5 -0
- pyclickerrpa-0.1.0.dist-info/entry_points.txt +5 -0
- pyclickerrpa-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Trigger / monitor mode.
|
|
2
|
+
|
|
3
|
+
Polls the target window (or full screen) on an interval for a condition (an
|
|
4
|
+
image appearing, or a pixel matching a color) and fires ``triggered`` on the
|
|
5
|
+
rising edge (false -> true), so a task runs once per appearance rather than
|
|
6
|
+
every tick.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from PySide6.QtCore import QObject, QTimer, Signal
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _parse_hex(name: str):
|
|
14
|
+
s = (name or "").strip().lstrip("#")
|
|
15
|
+
if len(s) == 3:
|
|
16
|
+
s = "".join(c * 2 for c in s)
|
|
17
|
+
if len(s) != 6:
|
|
18
|
+
return None
|
|
19
|
+
try:
|
|
20
|
+
return int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16)
|
|
21
|
+
except ValueError:
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MonitorController(QObject):
|
|
26
|
+
triggered = Signal()
|
|
27
|
+
status = Signal(str)
|
|
28
|
+
|
|
29
|
+
def __init__(self, parent=None):
|
|
30
|
+
super().__init__(parent)
|
|
31
|
+
self._timer = QTimer(self)
|
|
32
|
+
self._timer.timeout.connect(self._tick)
|
|
33
|
+
self._cond: dict | None = None
|
|
34
|
+
self._hwnd = None
|
|
35
|
+
self._prev = False
|
|
36
|
+
|
|
37
|
+
def start(self, hwnd, cond: dict):
|
|
38
|
+
self._hwnd = hwnd
|
|
39
|
+
self._cond = cond
|
|
40
|
+
self._prev = False
|
|
41
|
+
self._timer.setInterval(int(cond.get("interval_ms", 1000)))
|
|
42
|
+
self._timer.start()
|
|
43
|
+
self.status.emit("监视中…")
|
|
44
|
+
|
|
45
|
+
def stop(self):
|
|
46
|
+
self._timer.stop()
|
|
47
|
+
self.status.emit("已停止监视")
|
|
48
|
+
|
|
49
|
+
def is_active(self) -> bool:
|
|
50
|
+
return self._timer.isActive()
|
|
51
|
+
|
|
52
|
+
def _tick(self):
|
|
53
|
+
met = False
|
|
54
|
+
try:
|
|
55
|
+
met = self._check()
|
|
56
|
+
except Exception as e:
|
|
57
|
+
self.status.emit(f"监视出错: {e}")
|
|
58
|
+
if met and not self._prev:
|
|
59
|
+
self.triggered.emit()
|
|
60
|
+
self._prev = met
|
|
61
|
+
|
|
62
|
+
def _check(self) -> bool:
|
|
63
|
+
import cv2
|
|
64
|
+
from autoclicker import capture, matcher
|
|
65
|
+
screen = capture.capture_window(self._hwnd) if self._hwnd else capture.capture_screen()
|
|
66
|
+
if screen is None or screen.size == 0:
|
|
67
|
+
return False
|
|
68
|
+
c = self._cond or {}
|
|
69
|
+
if c.get("type") == "image":
|
|
70
|
+
templ = cv2.imread(c["image"]) if c.get("image") else None
|
|
71
|
+
if templ is None:
|
|
72
|
+
return False
|
|
73
|
+
return bool(matcher.find_image(screen, templ, c.get("threshold", 0.8)))
|
|
74
|
+
if c.get("type") == "color":
|
|
75
|
+
x, y = int(c.get("x", 0)), int(c.get("y", 0))
|
|
76
|
+
if x >= screen.shape[1] or y >= screen.shape[0]:
|
|
77
|
+
return False
|
|
78
|
+
rgb = _parse_hex(c.get("color", ""))
|
|
79
|
+
if rgb is None:
|
|
80
|
+
return False
|
|
81
|
+
b, g, r = (int(v) for v in screen[y, x][:3])
|
|
82
|
+
tol = int(c.get("tolerance", 30))
|
|
83
|
+
return abs(r - rgb[0]) <= tol and abs(g - rgb[1]) <= tol and abs(b - rgb[2]) <= tol
|
|
84
|
+
return False
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Screenshot preview with interactive picking — port of previewlabel.cpp.
|
|
2
|
+
|
|
3
|
+
Shows a BGR ndarray scaled-to-fit, maps mouse coordinates back to image space,
|
|
4
|
+
and supports four pick modes used by the toolbar:
|
|
5
|
+
- point : single click -> pointPicked(QPoint)
|
|
6
|
+
- region : drag rectangle -> regionSelected(QRect) (ROI / template crop)
|
|
7
|
+
- twopts : two clicks -> twoPointsPicked(start, end) (drag/swipe design)
|
|
8
|
+
- color : single click -> colorPicked(QPoint, QColor)
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
from PySide6.QtCore import QPoint, QRect, QSize, Qt, Signal
|
|
17
|
+
from PySide6.QtGui import QColor, QImage, QPixmap
|
|
18
|
+
from PySide6.QtWidgets import QLabel, QRubberBand
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def ndarray_to_qimage(bgr: np.ndarray) -> QImage:
|
|
22
|
+
"""Convert an (H, W, 3) BGR ndarray to a QImage (copied, RGB888)."""
|
|
23
|
+
if bgr is None or bgr.size == 0:
|
|
24
|
+
return QImage()
|
|
25
|
+
rgb = np.ascontiguousarray(bgr[:, :, ::-1]) # BGR -> RGB
|
|
26
|
+
h, w = rgb.shape[:2]
|
|
27
|
+
return QImage(rgb.data, w, h, 3 * w, QImage.Format_RGB888).copy()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PickMode(Enum):
|
|
31
|
+
NONE = 0
|
|
32
|
+
POINT = 1
|
|
33
|
+
REGION = 2
|
|
34
|
+
TWO_POINTS = 3
|
|
35
|
+
COLOR = 4
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class PreviewLabel(QLabel):
|
|
39
|
+
pointPicked = Signal(QPoint)
|
|
40
|
+
regionSelected = Signal(QRect)
|
|
41
|
+
twoPointsPicked = Signal(QPoint, QPoint)
|
|
42
|
+
colorPicked = Signal(QPoint, QColor)
|
|
43
|
+
|
|
44
|
+
def __init__(self, parent=None) -> None:
|
|
45
|
+
super().__init__(parent)
|
|
46
|
+
self.setAlignment(Qt.AlignCenter)
|
|
47
|
+
self.setMinimumSize(320, 240)
|
|
48
|
+
self.setStyleSheet("background:#1e1e1e; color:#888;")
|
|
49
|
+
self.setText("(截图预览)")
|
|
50
|
+
self._img: Optional[np.ndarray] = None
|
|
51
|
+
self._orig: QPixmap = QPixmap()
|
|
52
|
+
self._mode = PickMode.NONE
|
|
53
|
+
self._rubber: Optional[QRubberBand] = None
|
|
54
|
+
self._press_pos: Optional[QPoint] = None
|
|
55
|
+
self._first_point: Optional[QPoint] = None
|
|
56
|
+
self._markers: list[tuple[int, int]] = []
|
|
57
|
+
|
|
58
|
+
# ---- image ----
|
|
59
|
+
def set_image_ndarray(self, bgr: np.ndarray) -> None:
|
|
60
|
+
self._img = bgr
|
|
61
|
+
self._orig = QPixmap.fromImage(ndarray_to_qimage(bgr))
|
|
62
|
+
self._markers.clear()
|
|
63
|
+
self._rescale()
|
|
64
|
+
|
|
65
|
+
def set_image_qimage(self, img: QImage) -> None:
|
|
66
|
+
self._img = None
|
|
67
|
+
self._orig = QPixmap.fromImage(img)
|
|
68
|
+
self._markers.clear()
|
|
69
|
+
self._rescale()
|
|
70
|
+
|
|
71
|
+
def has_image(self) -> bool:
|
|
72
|
+
return not self._orig.isNull()
|
|
73
|
+
|
|
74
|
+
def add_marker(self, x: int, y: int) -> None:
|
|
75
|
+
"""Mark a hit at image-space (x, y); shown as a green ring on the preview."""
|
|
76
|
+
self._markers.append((int(x), int(y)))
|
|
77
|
+
self._rescale()
|
|
78
|
+
|
|
79
|
+
def clear_markers(self) -> None:
|
|
80
|
+
self._markers.clear()
|
|
81
|
+
self._rescale()
|
|
82
|
+
|
|
83
|
+
def _rescale(self) -> None:
|
|
84
|
+
if self._orig.isNull():
|
|
85
|
+
return
|
|
86
|
+
scaled = self._orig.scaled(self.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
|
87
|
+
if self._markers:
|
|
88
|
+
from PySide6.QtGui import QPainter, QPen, QColor
|
|
89
|
+
sx = scaled.width() / self._orig.width()
|
|
90
|
+
sy = scaled.height() / self._orig.height()
|
|
91
|
+
painter = QPainter(scaled)
|
|
92
|
+
painter.setPen(QPen(QColor(0, 255, 0), 2))
|
|
93
|
+
for mx, my in self._markers:
|
|
94
|
+
cx, cy = int(mx * sx), int(my * sy)
|
|
95
|
+
painter.drawEllipse(cx - 7, cy - 7, 14, 14)
|
|
96
|
+
painter.drawLine(cx - 10, cy, cx + 10, cy)
|
|
97
|
+
painter.drawLine(cx, cy - 10, cx, cy + 10)
|
|
98
|
+
painter.end()
|
|
99
|
+
self.setPixmap(scaled)
|
|
100
|
+
|
|
101
|
+
def resizeEvent(self, event) -> None:
|
|
102
|
+
self._rescale()
|
|
103
|
+
super().resizeEvent(event)
|
|
104
|
+
|
|
105
|
+
# ---- coordinate mapping ----
|
|
106
|
+
def _displayed_rect(self) -> QRect:
|
|
107
|
+
"""Rect (in widget coords) where the scaled pixmap is actually drawn."""
|
|
108
|
+
if self._orig.isNull():
|
|
109
|
+
return QRect()
|
|
110
|
+
iw, ih = self._orig.width(), self._orig.height()
|
|
111
|
+
lw, lh = self.width(), self.height()
|
|
112
|
+
scale = min(lw / iw, lh / ih)
|
|
113
|
+
dw, dh = int(iw * scale), int(ih * scale)
|
|
114
|
+
return QRect((lw - dw) // 2, (lh - dh) // 2, dw, dh)
|
|
115
|
+
|
|
116
|
+
def _to_image(self, pos: QPoint) -> Optional[QPoint]:
|
|
117
|
+
rect = self._displayed_rect()
|
|
118
|
+
if rect.isEmpty() or not rect.contains(pos):
|
|
119
|
+
return None
|
|
120
|
+
iw, ih = self._orig.width(), self._orig.height()
|
|
121
|
+
ix = int((pos.x() - rect.x()) / rect.width() * iw)
|
|
122
|
+
iy = int((pos.y() - rect.y()) / rect.height() * ih)
|
|
123
|
+
return QPoint(max(0, min(iw - 1, ix)), max(0, min(ih - 1, iy)))
|
|
124
|
+
|
|
125
|
+
# ---- pick modes ----
|
|
126
|
+
def set_pick_mode(self, mode: PickMode) -> None:
|
|
127
|
+
self._mode = mode
|
|
128
|
+
self._first_point = None
|
|
129
|
+
self.setCursor(Qt.CrossCursor if mode != PickMode.NONE else Qt.ArrowCursor)
|
|
130
|
+
|
|
131
|
+
def mousePressEvent(self, event) -> None:
|
|
132
|
+
if self._orig.isNull() or event.button() != Qt.LeftButton:
|
|
133
|
+
return
|
|
134
|
+
img_pt = self._to_image(event.position().toPoint())
|
|
135
|
+
if img_pt is None:
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
if self._mode == PickMode.POINT:
|
|
139
|
+
self.pointPicked.emit(img_pt)
|
|
140
|
+
elif self._mode == PickMode.COLOR:
|
|
141
|
+
self.colorPicked.emit(img_pt, self._color_at(img_pt))
|
|
142
|
+
elif self._mode == PickMode.TWO_POINTS:
|
|
143
|
+
if self._first_point is None:
|
|
144
|
+
self._first_point = img_pt
|
|
145
|
+
else:
|
|
146
|
+
self.twoPointsPicked.emit(self._first_point, img_pt)
|
|
147
|
+
self._first_point = None
|
|
148
|
+
self.set_pick_mode(PickMode.NONE)
|
|
149
|
+
elif self._mode == PickMode.REGION:
|
|
150
|
+
self._press_pos = event.position().toPoint()
|
|
151
|
+
if self._rubber is None:
|
|
152
|
+
self._rubber = QRubberBand(QRubberBand.Rectangle, self)
|
|
153
|
+
self._rubber.setGeometry(QRect(self._press_pos, QSize()))
|
|
154
|
+
self._rubber.show()
|
|
155
|
+
|
|
156
|
+
def mouseMoveEvent(self, event) -> None:
|
|
157
|
+
if self._mode == PickMode.REGION and self._press_pos is not None and self._rubber:
|
|
158
|
+
self._rubber.setGeometry(QRect(self._press_pos, event.position().toPoint()).normalized())
|
|
159
|
+
|
|
160
|
+
def mouseReleaseEvent(self, event) -> None:
|
|
161
|
+
if self._mode == PickMode.REGION and self._press_pos is not None and self._rubber:
|
|
162
|
+
self._rubber.hide()
|
|
163
|
+
start = self._to_image(self._press_pos)
|
|
164
|
+
end = self._to_image(event.position().toPoint())
|
|
165
|
+
self._press_pos = None
|
|
166
|
+
if start and end:
|
|
167
|
+
rect = QRect(start, end).normalized()
|
|
168
|
+
if rect.width() > 2 and rect.height() > 2:
|
|
169
|
+
self.regionSelected.emit(rect)
|
|
170
|
+
self.set_pick_mode(PickMode.NONE)
|
|
171
|
+
|
|
172
|
+
def _color_at(self, img_pt: QPoint) -> QColor:
|
|
173
|
+
if self._img is not None:
|
|
174
|
+
b, g, r = (int(v) for v in self._img[img_pt.y(), img_pt.x()][:3])
|
|
175
|
+
return QColor(r, g, b)
|
|
176
|
+
return QColor(self._orig.toImage().pixel(img_pt))
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Qt adapter around the Qt-free TaskRunner engine.
|
|
2
|
+
|
|
3
|
+
The engine runs on its own thread and invokes RunnerCallbacks; we re-emit those
|
|
4
|
+
as Qt signals. Emitting a signal from the worker thread to a slot living in the
|
|
5
|
+
GUI thread is delivered via a queued connection automatically, so the GUI stays
|
|
6
|
+
responsive and thread-safe.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from PySide6.QtCore import QObject, Signal
|
|
11
|
+
|
|
12
|
+
from autoclicker.models import Task
|
|
13
|
+
from autoclicker.runner import RunnerCallbacks, TaskRunner
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _SignalCallbacks(RunnerCallbacks):
|
|
17
|
+
def __init__(self, owner: "QtRunner") -> None:
|
|
18
|
+
self._o = owner
|
|
19
|
+
|
|
20
|
+
def step_started(self, index, description):
|
|
21
|
+
self._o.stepStarted.emit(index, description)
|
|
22
|
+
|
|
23
|
+
def step_completed(self, index, success):
|
|
24
|
+
self._o.stepCompleted.emit(index, success)
|
|
25
|
+
|
|
26
|
+
def match_found(self, x, y, confidence):
|
|
27
|
+
self._o.matchFound.emit(x, y, confidence)
|
|
28
|
+
|
|
29
|
+
def log(self, msg):
|
|
30
|
+
self._o.logMessage.emit(msg)
|
|
31
|
+
|
|
32
|
+
def var_changed(self, name, value):
|
|
33
|
+
self._o.varChanged.emit(name, value)
|
|
34
|
+
|
|
35
|
+
def finished(self):
|
|
36
|
+
self._o.runFinished.emit()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class QtRunner(QObject):
|
|
40
|
+
stepStarted = Signal(int, str)
|
|
41
|
+
stepCompleted = Signal(int, bool)
|
|
42
|
+
matchFound = Signal(int, int, float)
|
|
43
|
+
logMessage = Signal(str)
|
|
44
|
+
varChanged = Signal(str, str)
|
|
45
|
+
runFinished = Signal()
|
|
46
|
+
|
|
47
|
+
def __init__(self, parent=None) -> None:
|
|
48
|
+
super().__init__(parent)
|
|
49
|
+
self._runner: TaskRunner | None = None
|
|
50
|
+
|
|
51
|
+
def start(self, task: Task, target_hwnd=None, screenshot_on_fail=False, screenshot_dir=None) -> None:
|
|
52
|
+
self._runner = TaskRunner(task, target_hwnd=target_hwnd, callbacks=_SignalCallbacks(self))
|
|
53
|
+
self._runner.screenshot_on_fail = screenshot_on_fail
|
|
54
|
+
if screenshot_dir:
|
|
55
|
+
self._runner.screenshot_dir = screenshot_dir
|
|
56
|
+
self._runner.start()
|
|
57
|
+
|
|
58
|
+
def stop(self) -> None:
|
|
59
|
+
if self._runner:
|
|
60
|
+
self._runner.stop()
|
|
61
|
+
|
|
62
|
+
def pause(self) -> None:
|
|
63
|
+
if self._runner:
|
|
64
|
+
self._runner.pause()
|
|
65
|
+
|
|
66
|
+
def resume(self) -> None:
|
|
67
|
+
if self._runner:
|
|
68
|
+
self._runner.resume()
|
|
69
|
+
|
|
70
|
+
def is_running(self) -> bool:
|
|
71
|
+
return self._runner is not None and self._runner.is_alive()
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Action recorder — port of recorder.cpp using pynput global hooks.
|
|
2
|
+
|
|
3
|
+
Records left clicks / drags (client-relative to the target window) and key
|
|
4
|
+
presses into Steps, inserting Wait steps for gaps >= 150ms. Emits stepRecorded
|
|
5
|
+
from the listener thread; the signal is delivered to the GUI thread by Qt.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
from PySide6.QtCore import QObject, Signal
|
|
13
|
+
|
|
14
|
+
from autoclicker.models import Step, StepType
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
import win32gui
|
|
18
|
+
_HAS_WIN32 = True
|
|
19
|
+
except ImportError:
|
|
20
|
+
_HAS_WIN32 = False
|
|
21
|
+
|
|
22
|
+
from pynput import mouse, keyboard
|
|
23
|
+
|
|
24
|
+
# pynput special key -> our key name (mirrors Recorder::vkToName specials)
|
|
25
|
+
_SPECIAL_KEYS = {
|
|
26
|
+
keyboard.Key.enter: "ENTER", keyboard.Key.esc: "ESC", keyboard.Key.tab: "TAB",
|
|
27
|
+
keyboard.Key.space: "SPACE", keyboard.Key.backspace: "BACKSPACE",
|
|
28
|
+
keyboard.Key.delete: "DELETE", keyboard.Key.up: "UP", keyboard.Key.down: "DOWN",
|
|
29
|
+
keyboard.Key.left: "LEFT", keyboard.Key.right: "RIGHT", keyboard.Key.home: "HOME",
|
|
30
|
+
keyboard.Key.end: "END", keyboard.Key.page_up: "PAGEUP", keyboard.Key.page_down: "PAGEDOWN",
|
|
31
|
+
}
|
|
32
|
+
_MODIFIERS = {
|
|
33
|
+
keyboard.Key.ctrl, keyboard.Key.ctrl_l, keyboard.Key.ctrl_r,
|
|
34
|
+
keyboard.Key.shift, keyboard.Key.shift_l, keyboard.Key.shift_r,
|
|
35
|
+
keyboard.Key.alt, keyboard.Key.alt_l, keyboard.Key.alt_r, keyboard.Key.alt_gr,
|
|
36
|
+
keyboard.Key.cmd, keyboard.Key.cmd_l, keyboard.Key.cmd_r,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Recorder(QObject):
|
|
41
|
+
stepRecorded = Signal(object) # emits a Step
|
|
42
|
+
recordingStopped = Signal()
|
|
43
|
+
|
|
44
|
+
def __init__(self, parent=None):
|
|
45
|
+
super().__init__(parent)
|
|
46
|
+
self._hwnd = None
|
|
47
|
+
self._recording = False
|
|
48
|
+
self._mouse_listener = None
|
|
49
|
+
self._key_listener = None
|
|
50
|
+
self._t0 = 0.0
|
|
51
|
+
self._last_event = 0.0
|
|
52
|
+
self._down = False
|
|
53
|
+
self._down_pos = (0, 0)
|
|
54
|
+
self._down_time = 0.0
|
|
55
|
+
|
|
56
|
+
def is_recording(self) -> bool:
|
|
57
|
+
return self._recording
|
|
58
|
+
|
|
59
|
+
def start(self, hwnd):
|
|
60
|
+
if self._recording:
|
|
61
|
+
return
|
|
62
|
+
self._hwnd = hwnd
|
|
63
|
+
self._recording = True
|
|
64
|
+
self._t0 = time.monotonic()
|
|
65
|
+
self._last_event = 0.0
|
|
66
|
+
self._down = False
|
|
67
|
+
self._mouse_listener = mouse.Listener(on_click=self._on_click)
|
|
68
|
+
self._key_listener = keyboard.Listener(on_press=self._on_press)
|
|
69
|
+
self._mouse_listener.start()
|
|
70
|
+
self._key_listener.start()
|
|
71
|
+
|
|
72
|
+
def stop(self):
|
|
73
|
+
if not self._recording:
|
|
74
|
+
return
|
|
75
|
+
self._recording = False
|
|
76
|
+
if self._mouse_listener:
|
|
77
|
+
self._mouse_listener.stop()
|
|
78
|
+
self._mouse_listener = None
|
|
79
|
+
if self._key_listener:
|
|
80
|
+
self._key_listener.stop()
|
|
81
|
+
self._key_listener = None
|
|
82
|
+
self.recordingStopped.emit()
|
|
83
|
+
|
|
84
|
+
# ---- helpers ----
|
|
85
|
+
def _elapsed_ms(self) -> float:
|
|
86
|
+
return (time.monotonic() - self._t0) * 1000.0
|
|
87
|
+
|
|
88
|
+
def _inside(self, x, y) -> bool:
|
|
89
|
+
if not _HAS_WIN32 or not self._hwnd:
|
|
90
|
+
return True
|
|
91
|
+
try:
|
|
92
|
+
l, t, r, b = win32gui.GetWindowRect(self._hwnd)
|
|
93
|
+
except Exception:
|
|
94
|
+
return False
|
|
95
|
+
return l <= x <= r and t <= y <= b
|
|
96
|
+
|
|
97
|
+
def _to_client(self, x, y):
|
|
98
|
+
if _HAS_WIN32 and self._hwnd:
|
|
99
|
+
try:
|
|
100
|
+
return win32gui.ScreenToClient(self._hwnd, (x, y))
|
|
101
|
+
except Exception:
|
|
102
|
+
return x, y
|
|
103
|
+
return x, y
|
|
104
|
+
|
|
105
|
+
def _emit_wait_if_needed(self):
|
|
106
|
+
now = self._elapsed_ms()
|
|
107
|
+
if self._last_event > 0:
|
|
108
|
+
gap = int(now - self._last_event)
|
|
109
|
+
if gap >= 150:
|
|
110
|
+
self.stepRecorded.emit(Step(type=StepType.Wait, waitMs=gap))
|
|
111
|
+
self._last_event = now
|
|
112
|
+
|
|
113
|
+
# ---- pynput callbacks (run on listener thread) ----
|
|
114
|
+
def _on_click(self, x, y, button, pressed):
|
|
115
|
+
if not self._recording or button != mouse.Button.left:
|
|
116
|
+
return
|
|
117
|
+
if not self._inside(x, y):
|
|
118
|
+
return
|
|
119
|
+
cx, cy = self._to_client(x, y)
|
|
120
|
+
if pressed:
|
|
121
|
+
self._down = True
|
|
122
|
+
self._down_pos = (cx, cy)
|
|
123
|
+
self._down_time = self._elapsed_ms()
|
|
124
|
+
elif self._down:
|
|
125
|
+
self._down = False
|
|
126
|
+
dx, dy = cx - self._down_pos[0], cy - self._down_pos[1]
|
|
127
|
+
dist = math.hypot(dx, dy)
|
|
128
|
+
self._emit_wait_if_needed()
|
|
129
|
+
if dist > 10:
|
|
130
|
+
dur = int(self._elapsed_ms() - self._down_time)
|
|
131
|
+
self.stepRecorded.emit(Step(type=StepType.Drag,
|
|
132
|
+
x=self._down_pos[0], y=self._down_pos[1],
|
|
133
|
+
x2=cx, y2=cy, durationMs=dur if dur >= 50 else 200))
|
|
134
|
+
else:
|
|
135
|
+
self.stepRecorded.emit(Step(type=StepType.ClickPosition, x=cx, y=cy))
|
|
136
|
+
|
|
137
|
+
def _on_press(self, key):
|
|
138
|
+
if not self._recording or key in _MODIFIERS:
|
|
139
|
+
return
|
|
140
|
+
if _HAS_WIN32 and self._hwnd:
|
|
141
|
+
try:
|
|
142
|
+
if win32gui.GetForegroundWindow() != self._hwnd:
|
|
143
|
+
return
|
|
144
|
+
except Exception:
|
|
145
|
+
pass
|
|
146
|
+
name = self._key_name(key)
|
|
147
|
+
if name:
|
|
148
|
+
self._emit_wait_if_needed()
|
|
149
|
+
self.stepRecorded.emit(Step(type=StepType.KeyPress, keyName=name))
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
def _key_name(key) -> str:
|
|
153
|
+
if key in _SPECIAL_KEYS:
|
|
154
|
+
return _SPECIAL_KEYS[key]
|
|
155
|
+
if isinstance(key, keyboard.Key):
|
|
156
|
+
k = str(key).replace("Key.", "")
|
|
157
|
+
if k.startswith("f") and k[1:].isdigit():
|
|
158
|
+
return k.upper()
|
|
159
|
+
return ""
|
|
160
|
+
ch = getattr(key, "char", None)
|
|
161
|
+
if ch and (ch.isalnum()):
|
|
162
|
+
return ch.upper()
|
|
163
|
+
return ""
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Scheduled-task list — port of schedulerwidget.cpp.
|
|
2
|
+
|
|
3
|
+
Runs saved .json task files on a fixed interval. Persists via QSettings under
|
|
4
|
+
the same ("AutoClicker","AutoClicker") key as the C++ app, so schedules are
|
|
5
|
+
shared between the two versions.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from PySide6.QtCore import QTimer, Signal
|
|
14
|
+
from PySide6.QtWidgets import (
|
|
15
|
+
QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QHBoxLayout,
|
|
16
|
+
QLabel, QLineEdit, QListWidget, QPushButton, QSpinBox, QVBoxLayout, QWidget,
|
|
17
|
+
)
|
|
18
|
+
from PySide6.QtCore import QSettings
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ScheduledTask:
|
|
23
|
+
name: str = ""
|
|
24
|
+
task_path: str = ""
|
|
25
|
+
interval_ms: int = 600000
|
|
26
|
+
enabled: bool = True
|
|
27
|
+
next_run: float = 0.0 # monotonic seconds
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SchedulerWidget(QWidget):
|
|
31
|
+
runTaskRequested = Signal(str)
|
|
32
|
+
|
|
33
|
+
def __init__(self, parent=None):
|
|
34
|
+
super().__init__(parent)
|
|
35
|
+
self._tasks: list[ScheduledTask] = []
|
|
36
|
+
|
|
37
|
+
lay = QVBoxLayout(self)
|
|
38
|
+
hint = QLabel("定时任务列表 — 自动按间隔执行已保存的任务文件")
|
|
39
|
+
hint.setStyleSheet("color: gray;")
|
|
40
|
+
lay.addWidget(hint)
|
|
41
|
+
self.list = QListWidget()
|
|
42
|
+
lay.addWidget(self.list)
|
|
43
|
+
row = QHBoxLayout()
|
|
44
|
+
for text, slot in [("添加定时任务", self._add), ("启用/禁用", self._toggle), ("删除", self._remove)]:
|
|
45
|
+
b = QPushButton(text)
|
|
46
|
+
b.clicked.connect(slot)
|
|
47
|
+
row.addWidget(b)
|
|
48
|
+
lay.addLayout(row)
|
|
49
|
+
|
|
50
|
+
self._timer = QTimer(self)
|
|
51
|
+
self._timer.setInterval(1000)
|
|
52
|
+
self._timer.timeout.connect(self._tick)
|
|
53
|
+
self._timer.start()
|
|
54
|
+
self._load_settings()
|
|
55
|
+
|
|
56
|
+
def _add(self):
|
|
57
|
+
dlg = QDialog(self)
|
|
58
|
+
dlg.setWindowTitle("添加定时任务")
|
|
59
|
+
dlg.setMinimumWidth(400)
|
|
60
|
+
form = QFormLayout(dlg)
|
|
61
|
+
path_edit = QLineEdit()
|
|
62
|
+
browse = QPushButton("浏览…")
|
|
63
|
+
prow = QHBoxLayout()
|
|
64
|
+
prow.addWidget(path_edit)
|
|
65
|
+
prow.addWidget(browse)
|
|
66
|
+
browse.clicked.connect(lambda: self._pick_path(dlg, path_edit))
|
|
67
|
+
form.addRow("任务文件:", prow)
|
|
68
|
+
interval = QSpinBox()
|
|
69
|
+
interval.setRange(1, 9999)
|
|
70
|
+
interval.setValue(10)
|
|
71
|
+
form.addRow("间隔:", interval)
|
|
72
|
+
unit = QComboBox()
|
|
73
|
+
unit.addItem("秒", 1000)
|
|
74
|
+
unit.addItem("分钟", 60000)
|
|
75
|
+
unit.addItem("小时", 3600000)
|
|
76
|
+
unit.setCurrentIndex(1)
|
|
77
|
+
form.addRow("单位:", unit)
|
|
78
|
+
bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
|
79
|
+
bb.accepted.connect(dlg.accept)
|
|
80
|
+
bb.rejected.connect(dlg.reject)
|
|
81
|
+
form.addRow(bb)
|
|
82
|
+
if dlg.exec() != QDialog.Accepted or not path_edit.text():
|
|
83
|
+
return
|
|
84
|
+
interval_ms = interval.value() * unit.currentData()
|
|
85
|
+
t = ScheduledTask(name=os.path.splitext(os.path.basename(path_edit.text()))[0],
|
|
86
|
+
task_path=path_edit.text(), interval_ms=interval_ms, enabled=True,
|
|
87
|
+
next_run=time.monotonic() + interval_ms / 1000.0)
|
|
88
|
+
self._tasks.append(t)
|
|
89
|
+
self._save_settings()
|
|
90
|
+
self._refresh()
|
|
91
|
+
|
|
92
|
+
def _pick_path(self, dlg, edit):
|
|
93
|
+
p, _ = QFileDialog.getOpenFileName(dlg, "选择任务文件", "", "JSON (*.json)")
|
|
94
|
+
if p:
|
|
95
|
+
edit.setText(p)
|
|
96
|
+
|
|
97
|
+
def _remove(self):
|
|
98
|
+
row = self.list.currentRow()
|
|
99
|
+
if 0 <= row < len(self._tasks):
|
|
100
|
+
del self._tasks[row]
|
|
101
|
+
self._save_settings()
|
|
102
|
+
self._refresh()
|
|
103
|
+
|
|
104
|
+
def _toggle(self):
|
|
105
|
+
row = self.list.currentRow()
|
|
106
|
+
if 0 <= row < len(self._tasks):
|
|
107
|
+
t = self._tasks[row]
|
|
108
|
+
t.enabled = not t.enabled
|
|
109
|
+
if t.enabled:
|
|
110
|
+
t.next_run = time.monotonic() + t.interval_ms / 1000.0
|
|
111
|
+
self._save_settings()
|
|
112
|
+
self._refresh()
|
|
113
|
+
|
|
114
|
+
def _tick(self):
|
|
115
|
+
now = time.monotonic()
|
|
116
|
+
for t in self._tasks:
|
|
117
|
+
if t.enabled and now >= t.next_run:
|
|
118
|
+
self.runTaskRequested.emit(t.task_path)
|
|
119
|
+
t.next_run = now + t.interval_ms / 1000.0
|
|
120
|
+
|
|
121
|
+
def _refresh(self):
|
|
122
|
+
self.list.clear()
|
|
123
|
+
for t in self._tasks:
|
|
124
|
+
secs = t.interval_ms // 1000
|
|
125
|
+
interval = (f"{secs // 3600}小时" if secs >= 3600
|
|
126
|
+
else f"{secs // 60}分钟" if secs >= 60 else f"{secs}秒")
|
|
127
|
+
status = "[启用]" if t.enabled else "[禁用]"
|
|
128
|
+
self.list.addItem(f"{status} {t.name} — 每{interval}")
|
|
129
|
+
|
|
130
|
+
# ---- persistence (QSettings, shared with C++) ----
|
|
131
|
+
def _load_settings(self):
|
|
132
|
+
s = QSettings("AutoClicker", "AutoClicker")
|
|
133
|
+
n = s.beginReadArray("scheduledTasks")
|
|
134
|
+
self._tasks.clear()
|
|
135
|
+
for i in range(n):
|
|
136
|
+
s.setArrayIndex(i)
|
|
137
|
+
interval_ms = int(s.value("interval", 600000))
|
|
138
|
+
self._tasks.append(ScheduledTask(
|
|
139
|
+
name=s.value("name", ""), task_path=s.value("path", ""),
|
|
140
|
+
interval_ms=interval_ms, enabled=_as_bool(s.value("enabled", True)),
|
|
141
|
+
next_run=time.monotonic() + interval_ms / 1000.0))
|
|
142
|
+
s.endArray()
|
|
143
|
+
self._refresh()
|
|
144
|
+
|
|
145
|
+
def _save_settings(self):
|
|
146
|
+
s = QSettings("AutoClicker", "AutoClicker")
|
|
147
|
+
s.beginWriteArray("scheduledTasks", len(self._tasks))
|
|
148
|
+
for i, t in enumerate(self._tasks):
|
|
149
|
+
s.setArrayIndex(i)
|
|
150
|
+
s.setValue("name", t.name)
|
|
151
|
+
s.setValue("path", t.task_path)
|
|
152
|
+
s.setValue("interval", t.interval_ms)
|
|
153
|
+
s.setValue("enabled", t.enabled)
|
|
154
|
+
s.endArray()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _as_bool(v) -> bool:
|
|
158
|
+
if isinstance(v, bool):
|
|
159
|
+
return v
|
|
160
|
+
return str(v).lower() in ("1", "true", "yes")
|