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,340 @@
1
+ """Node-based visual flow editor — port of floweditor.cpp.
2
+
3
+ A QGraphicsView of FlowNodes connected by FlowConnections. Converts to/from the
4
+ linear Task: buildTask() does a BFS from the start node and turns back-edges
5
+ (with a repeat count) into Loop steps; loadTask() lays steps out vertically and
6
+ recreates loop/do-until back-edges.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from collections import deque
11
+
12
+ from PySide6.QtCore import QLineF, Qt, Signal
13
+ from PySide6.QtGui import QColor, QPainter, QPen
14
+ from PySide6.QtWidgets import (
15
+ QDialog, QGraphicsScene, QGraphicsView, QInputDialog, QMenu,
16
+ )
17
+
18
+ from autoclicker.models import Step, StepType, Task
19
+ from autoclicker.gui.flow_node import FlowNode, FlowPort
20
+ from autoclicker.gui.flow_connection import FlowConnection
21
+
22
+ # Context-menu "add node" grouping (label, StepType)
23
+ _ADD_MENU = {
24
+ "点击": [("找图点击", StepType.ClickImage), ("找字点击", StepType.ClickText),
25
+ ("坐标点击", StepType.ClickPosition), ("长按", StepType.LongPress)],
26
+ "输入": [("按键", StepType.KeyPress), ("输入文字", StepType.TypeText), ("快捷键", StepType.Hotkey)],
27
+ "手势": [("拖拽", StepType.Drag), ("捏合", StepType.Pinch), ("滚轮", StepType.Scroll)],
28
+ "流程": [("等待", StepType.Wait), ("随机等待", StepType.RandomWait),
29
+ ("等待图片出现", StepType.WaitImage), ("等待图片消失", StepType.WaitImageGone),
30
+ ("条件(图片)", StepType.IfImageFound), ("条件(文字)", StepType.IfTextFound),
31
+ ("条件(颜色)", StepType.IfColorMatch), ("调用子任务", StepType.CallSub),
32
+ ("设置变量", StepType.SetVar), ("循环直到", StepType.DoUntil)],
33
+ }
34
+
35
+
36
+ class FlowEditor(QGraphicsView):
37
+ taskChanged = Signal()
38
+
39
+ def __init__(self, parent=None):
40
+ super().__init__(parent)
41
+ self._scene = QGraphicsScene(self)
42
+ self._scene.setSceneRect(-2000, -2000, 4000, 4000)
43
+ self.setScene(self._scene)
44
+ self.setRenderHint(QPainter.Antialiasing)
45
+ self.setDragMode(QGraphicsView.NoDrag)
46
+ self.setBackgroundBrush(QColor(30, 30, 30))
47
+ self.setMinimumSize(400, 300)
48
+
49
+ grid = QPen(QColor(50, 50, 50), 1)
50
+ for x in range(-2000, 2001, 50):
51
+ self._scene.addLine(x, -2000, x, 2000, grid)
52
+ for y in range(-2000, 2001, 50):
53
+ self._scene.addLine(-2000, y, 2000, y, grid)
54
+
55
+ self.nodes: list[FlowNode] = []
56
+ self.connections: list[FlowConnection] = []
57
+ self._dragging = False
58
+ self._drag_start_port: FlowPort | None = None
59
+ self._drag_line = None
60
+ self._panning = False
61
+ self._pan_start = None
62
+
63
+ # ---- graph ops ----
64
+ def add_node(self, step: Step, pos) -> FlowNode:
65
+ node = FlowNode(step)
66
+ node.setPos(pos)
67
+ self._scene.addItem(node)
68
+ self.nodes.append(node)
69
+ self.taskChanged.emit()
70
+ return node
71
+
72
+ def connect_nodes(self, from_port: FlowPort, to_port: FlowPort):
73
+ for conn in from_port.connections:
74
+ if conn.to_port is to_port:
75
+ return None
76
+ conn = FlowConnection(from_port, to_port)
77
+ self._scene.addItem(conn)
78
+ self.connections.append(conn)
79
+ self.taskChanged.emit()
80
+ return conn
81
+
82
+ def clear(self):
83
+ for conn in self.connections:
84
+ conn.detach()
85
+ self._scene.removeItem(conn)
86
+ self.connections.clear()
87
+ for node in self.nodes:
88
+ self._scene.removeItem(node)
89
+ self.nodes.clear()
90
+
91
+ # ---- mouse ----
92
+ def mousePressEvent(self, event):
93
+ if event.button() == Qt.MiddleButton:
94
+ self._panning = True
95
+ self._pan_start = event.position().toPoint()
96
+ self.setCursor(Qt.ClosedHandCursor)
97
+ return
98
+ if event.button() == Qt.LeftButton:
99
+ item = self.itemAt(event.position().toPoint())
100
+ if isinstance(item, FlowPort) and item.port_type == FlowPort.OUTPUT:
101
+ self._dragging = True
102
+ self._drag_start_port = item
103
+ start = item.center_scene_pos()
104
+ self._drag_line = self._scene.addLine(QLineF(start, start), QPen(Qt.white, 2, Qt.DashLine))
105
+ return
106
+ super().mousePressEvent(event)
107
+
108
+ def mouseMoveEvent(self, event):
109
+ if self._panning:
110
+ delta = event.position().toPoint() - self._pan_start
111
+ self._pan_start = event.position().toPoint()
112
+ self.horizontalScrollBar().setValue(self.horizontalScrollBar().value() - delta.x())
113
+ self.verticalScrollBar().setValue(self.verticalScrollBar().value() - delta.y())
114
+ return
115
+ if self._dragging and self._drag_line:
116
+ start = self._drag_start_port.center_scene_pos()
117
+ end = self.mapToScene(event.position().toPoint())
118
+ self._drag_line.setLine(QLineF(start, end))
119
+ return
120
+ super().mouseMoveEvent(event)
121
+
122
+ def mouseReleaseEvent(self, event):
123
+ if self._panning:
124
+ self._panning = False
125
+ self.setCursor(Qt.ArrowCursor)
126
+ return
127
+ if self._dragging:
128
+ self._dragging = False
129
+ if self._drag_line:
130
+ self._scene.removeItem(self._drag_line)
131
+ self._drag_line = None
132
+ item = self.itemAt(event.position().toPoint())
133
+ if isinstance(item, FlowPort) and item.port_type == FlowPort.INPUT and item is not self._drag_start_port:
134
+ conn = self.connect_nodes(self._drag_start_port, item)
135
+ if conn:
136
+ from_node = self._drag_start_port.parentItem()
137
+ to_node = item.parentItem()
138
+ if to_node.pos().y() < from_node.pos().y(): # back-edge
139
+ count, ok = QInputDialog.getInt(self, "循环次数", "重复次数 (-1 = 无限):", -1, -1, 99999, 1)
140
+ if ok:
141
+ conn.set_repeat_count(count)
142
+ self._drag_start_port = None
143
+ return
144
+ super().mouseReleaseEvent(event)
145
+
146
+ def mouseDoubleClickEvent(self, event):
147
+ node = self._node_at(event.position().toPoint())
148
+ if node:
149
+ self._edit_node(node)
150
+ return
151
+ super().mouseDoubleClickEvent(event)
152
+
153
+ def wheelEvent(self, event):
154
+ factor = 1.15 if event.angleDelta().y() > 0 else 1 / 1.15
155
+ self.scale(factor, factor)
156
+
157
+ def keyPressEvent(self, event):
158
+ if event.key() in (Qt.Key_Delete, Qt.Key_Backspace):
159
+ self._delete_selected()
160
+ return
161
+ super().keyPressEvent(event)
162
+
163
+ def contextMenuEvent(self, event):
164
+ menu = QMenu(self)
165
+ scene_pos = self.mapToScene(event.pos())
166
+ node = self._node_at(event.pos())
167
+ item = self.itemAt(event.pos())
168
+ conn = item if isinstance(item, FlowConnection) else None
169
+
170
+ if node:
171
+ menu.addAction("编辑节点", lambda: self._edit_node(node))
172
+ if conn:
173
+ menu.addAction("设置重复次数", lambda: self._set_repeat(conn))
174
+ if node or conn:
175
+ menu.addAction("删除", self._delete_selected)
176
+ menu.addSeparator()
177
+
178
+ add_menu = menu.addMenu("添加节点")
179
+ for group, items in _ADD_MENU.items():
180
+ sub = add_menu.addMenu(group)
181
+ for label, st in items:
182
+ sub.addAction(label, lambda st=st, p=scene_pos: self.add_node(Step(type=st), p))
183
+ menu.exec(event.globalPos())
184
+
185
+ # ---- helpers ----
186
+ def _node_at(self, view_pos) -> FlowNode | None:
187
+ item = self.itemAt(view_pos)
188
+ while item is not None and not isinstance(item, FlowNode):
189
+ item = item.parentItem()
190
+ return item
191
+
192
+ def _edit_node(self, node: FlowNode):
193
+ from autoclicker.gui.task_widget import StepEditorDialog
194
+ dlg = StepEditorDialog(node.step(), self)
195
+ if dlg.exec() == QDialog.Accepted:
196
+ node.set_step(dlg.result_step())
197
+ self.taskChanged.emit()
198
+
199
+ def _set_repeat(self, conn: FlowConnection):
200
+ count, ok = QInputDialog.getInt(self, "重复次数", "执行次数 (-1 = 无限, 1 = 单次):",
201
+ conn.repeat_count, -1, 99999, 1)
202
+ if ok:
203
+ conn.set_repeat_count(count)
204
+ self.taskChanged.emit()
205
+
206
+ def _delete_selected(self):
207
+ selected = self._scene.selectedItems()
208
+ for item in selected:
209
+ if isinstance(item, FlowConnection):
210
+ item.detach()
211
+ if item in self.connections:
212
+ self.connections.remove(item)
213
+ self._scene.removeItem(item)
214
+ for item in selected:
215
+ if isinstance(item, FlowNode):
216
+ to_remove = [c for c in self.connections
217
+ if c.from_port.parentItem() is item or c.to_port.parentItem() is item]
218
+ for c in to_remove:
219
+ c.detach()
220
+ self.connections.remove(c)
221
+ self._scene.removeItem(c)
222
+ if item in self.nodes:
223
+ self.nodes.remove(item)
224
+ self._scene.removeItem(item)
225
+ self.taskChanged.emit()
226
+
227
+ def _find_start(self) -> FlowNode | None:
228
+ for node in self.nodes:
229
+ if not node.input_port.connections:
230
+ return node
231
+ return self.nodes[0] if self.nodes else None
232
+
233
+ # ---- graph <-> Task ----
234
+ def build_task(self) -> Task:
235
+ task = Task(name="FlowTask")
236
+ start = self._find_start()
237
+ if not start:
238
+ return task
239
+
240
+ order: list[FlowNode] = []
241
+ visited = {start}
242
+ queue = deque([start])
243
+ while queue:
244
+ current = queue.popleft()
245
+ order.append(current)
246
+ for conn in current.output_port.connections:
247
+ nxt = conn.to_port.parentItem()
248
+ if nxt not in visited:
249
+ visited.add(nxt)
250
+ queue.append(nxt)
251
+
252
+ node_index = {node: i for i, node in enumerate(order)}
253
+ for node in order:
254
+ s = node.step()
255
+ if s.type == StepType.DoUntil:
256
+ for conn in node.output_port.connections:
257
+ target = conn.to_port.parentItem()
258
+ ti = node_index.get(target, -1)
259
+ mi = node_index.get(node, -1)
260
+ if ti >= 0 and ti <= mi:
261
+ s.loopTarget = ti
262
+ break
263
+ task.steps.append(s)
264
+
265
+ # Back-edges with a repeat count become Loop steps
266
+ for conn in self.connections:
267
+ from_node = conn.from_port.parentItem()
268
+ to_node = conn.to_port.parentItem()
269
+ fi = node_index.get(from_node, -1)
270
+ ti = node_index.get(to_node, -1)
271
+ if fi >= 0 and ti >= 0 and ti <= fi and conn.repeat_count != 1:
272
+ loop = Step(type=StepType.Loop, loopTarget=ti, loopCount=conn.repeat_count)
273
+ insert_pos = fi + 1
274
+ task.steps.insert(insert_pos, loop)
275
+ for node in list(node_index):
276
+ if node_index[node] >= insert_pos:
277
+ node_index[node] += 1
278
+ return task
279
+
280
+ def load_task(self, task: Task):
281
+ self.clear()
282
+ y = 0.0
283
+ prev = None
284
+ step_to_node: dict[int, int] = {}
285
+ node_idx = 0
286
+ for i, step in enumerate(task.steps):
287
+ if step.type == StepType.Loop:
288
+ continue
289
+ step_to_node[i] = node_idx
290
+ node_idx += 1
291
+
292
+ from PySide6.QtCore import QPointF
293
+ for i, step in enumerate(task.steps):
294
+ if step.type == StepType.Loop:
295
+ continue
296
+ node = self.add_node(step, QPointF(0, y))
297
+ if prev is not None:
298
+ self.connect_nodes(prev.output_port, node.input_port)
299
+ prev = node
300
+ y += 100
301
+
302
+ for i, step in enumerate(task.steps):
303
+ if step.type == StepType.Loop:
304
+ real_from = step_to_node.get(i - 1, -1)
305
+ real_to = step_to_node.get(step.loopTarget, -1)
306
+ if 0 <= real_from < len(self.nodes) and 0 <= real_to < len(self.nodes):
307
+ conn = self.connect_nodes(self.nodes[real_from].output_port, self.nodes[real_to].input_port)
308
+ if conn:
309
+ conn.set_repeat_count(step.loopCount)
310
+
311
+ for i, node in enumerate(self.nodes):
312
+ if node.step().type == StepType.DoUntil:
313
+ real_to = step_to_node.get(node.step().loopTarget, -1)
314
+ if 0 <= real_to < len(self.nodes) and real_to <= i:
315
+ self.connect_nodes(node.output_port, self.nodes[real_to].input_port)
316
+
317
+ def validate_graph(self) -> tuple[bool, list[str]]:
318
+ if not self.nodes:
319
+ return False, ["流程图为空,请添加节点"]
320
+ start = self._find_start()
321
+ if not start:
322
+ return False, ["找不到起始节点"]
323
+ reachable = {start}
324
+ queue = deque([start])
325
+ while queue:
326
+ current = queue.popleft()
327
+ for conn in current.output_port.connections:
328
+ nxt = conn.to_port.parentItem()
329
+ if nxt not in reachable:
330
+ reachable.add(nxt)
331
+ queue.append(nxt)
332
+ for conn in current.input_port.connections:
333
+ prev = conn.from_port.parentItem()
334
+ if prev not in reachable:
335
+ reachable.add(prev)
336
+ queue.append(prev)
337
+ warnings = [f" · {n.step().display_name()}" for n in self.nodes if n not in reachable]
338
+ if warnings:
339
+ warnings.insert(0, f"有 {len(warnings)} 个节点未连接到主流程:")
340
+ return True, warnings
@@ -0,0 +1,107 @@
1
+ """Flow graph node + port — port of flownode.cpp."""
2
+ from __future__ import annotations
3
+
4
+ import uuid
5
+
6
+ from PySide6.QtCore import QRectF, Qt
7
+ from PySide6.QtGui import QColor, QPen
8
+ from PySide6.QtWidgets import QGraphicsEllipseItem, QGraphicsItem, QGraphicsTextItem
9
+
10
+ from autoclicker.models import Step, StepType
11
+
12
+ _COLORS = {
13
+ StepType.ClickImage: (66, 133, 244),
14
+ StepType.ClickText: (52, 168, 83),
15
+ StepType.ClickPosition: (251, 188, 4),
16
+ StepType.Wait: (100, 100, 100),
17
+ StepType.RandomWait: (100, 100, 100),
18
+ StepType.Loop: (234, 67, 53),
19
+ StepType.IfImageFound: (171, 71, 188),
20
+ StepType.IfTextFound: (171, 71, 188),
21
+ StepType.IfElse: (171, 71, 188),
22
+ StepType.CallSub: (0, 150, 136),
23
+ StepType.SetVar: (121, 85, 72),
24
+ StepType.KeyPress: (255, 112, 67),
25
+ StepType.TypeText: (255, 112, 67),
26
+ StepType.Hotkey: (255, 112, 67),
27
+ StepType.Drag: (255, 167, 38),
28
+ StepType.Pinch: (156, 39, 176),
29
+ StepType.WaitImage: (0, 150, 136),
30
+ StepType.WaitImageGone: (0, 121, 107),
31
+ StepType.Scroll: (158, 158, 158),
32
+ StepType.IfColorMatch: (233, 30, 99),
33
+ StepType.DoUntil: (183, 28, 28),
34
+ }
35
+
36
+
37
+ class FlowPort(QGraphicsEllipseItem):
38
+ INPUT = "input"
39
+ OUTPUT = "output"
40
+
41
+ def __init__(self, port_type: str, parent):
42
+ super().__init__(-6, -6, 12, 12, parent)
43
+ self.port_type = port_type
44
+ self.setBrush(QColor(100, 200, 100) if port_type == self.INPUT else QColor(100, 100, 200))
45
+ self.setPen(QPen(Qt.white, 1))
46
+ self.setFlag(QGraphicsItem.ItemIsSelectable, False)
47
+ self.setCursor(Qt.CrossCursor)
48
+ self.connections: list = []
49
+
50
+ def center_scene_pos(self):
51
+ return self.mapToScene(self.rect().center())
52
+
53
+
54
+ class FlowNode(QGraphicsItem):
55
+ WIDTH = 180
56
+ HEIGHT = 60
57
+
58
+ def __init__(self, step: Step, parent=None):
59
+ super().__init__(parent)
60
+ self._step = step
61
+ self.id = uuid.uuid4().hex[:8]
62
+ self.setFlag(QGraphicsItem.ItemIsMovable)
63
+ self.setFlag(QGraphicsItem.ItemIsSelectable)
64
+ self.setFlag(QGraphicsItem.ItemSendsGeometryChanges)
65
+ self.setCursor(Qt.OpenHandCursor)
66
+
67
+ self.input_port = FlowPort(FlowPort.INPUT, self)
68
+ self.input_port.setPos(self.WIDTH / 2, -6)
69
+ self.output_port = FlowPort(FlowPort.OUTPUT, self)
70
+ self.output_port.setPos(self.WIDTH / 2, self.HEIGHT + 6)
71
+
72
+ self._label = QGraphicsTextItem(self)
73
+ self._label.setDefaultTextColor(Qt.white)
74
+ self._label.setTextWidth(self.WIDTH - 16)
75
+ self._update_label()
76
+
77
+ def step(self) -> Step:
78
+ return self._step
79
+
80
+ def set_step(self, step: Step):
81
+ self._step = step
82
+ self._update_label()
83
+ self.update()
84
+
85
+ def boundingRect(self) -> QRectF:
86
+ return QRectF(-2, -8, self.WIDTH + 4, self.HEIGHT + 16)
87
+
88
+ def paint(self, painter, option, widget=None):
89
+ color = self._color()
90
+ painter.setPen(QPen(Qt.yellow, 2) if self.isSelected() else QPen(color.darker(150), 1))
91
+ painter.setBrush(color)
92
+ painter.drawRoundedRect(QRectF(0, 0, self.WIDTH, self.HEIGHT), 8, 8)
93
+
94
+ def _update_label(self):
95
+ self._label.setPlainText(self._step.display_name())
96
+ self._label.setPos(8, 8)
97
+
98
+ def _color(self) -> QColor:
99
+ return QColor(*_COLORS.get(self._step.type, (80, 80, 80)))
100
+
101
+ def itemChange(self, change, value):
102
+ if change == QGraphicsItem.ItemPositionHasChanged:
103
+ for conn in list(self.input_port.connections):
104
+ conn.update_path()
105
+ for conn in list(self.output_port.connections):
106
+ conn.update_path()
107
+ return super().itemChange(change, value)
@@ -0,0 +1,40 @@
1
+ """Global hotkeys (pynput) → Qt signals.
2
+
3
+ F6 toggles run/stop; ESC is an emergency stop. Hooks are observe-only (keys
4
+ still pass through to other apps). Signals are emitted from the listener thread
5
+ and delivered to the GUI thread by Qt.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from PySide6.QtCore import QObject, Signal
10
+
11
+ from pynput import keyboard
12
+
13
+
14
+ class HotkeyManager(QObject):
15
+ toggleRunRequested = Signal() # F6
16
+ stopRequested = Signal() # ESC
17
+
18
+ def __init__(self, parent=None):
19
+ super().__init__(parent)
20
+ self._gh = None
21
+
22
+ def start(self):
23
+ if self._gh is not None:
24
+ return
25
+ try:
26
+ self._gh = keyboard.GlobalHotKeys({
27
+ "<f6>": self.toggleRunRequested.emit,
28
+ "<esc>": self.stopRequested.emit,
29
+ })
30
+ self._gh.start()
31
+ except Exception:
32
+ self._gh = None
33
+
34
+ def stop(self):
35
+ if self._gh is not None:
36
+ try:
37
+ self._gh.stop()
38
+ except Exception:
39
+ pass
40
+ self._gh = None