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,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
|