PyClickerRPA 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/PKG-INFO +7 -4
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/PyClickerRPA.egg-info/PKG-INFO +7 -4
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/PyClickerRPA.egg-info/SOURCES.txt +1 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/README.md +6 -3
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/capture.py +26 -10
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/gui/flow_connection.py +8 -1
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/gui/flow_editor.py +194 -67
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/gui/flow_node.py +55 -8
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/gui/main_window.py +173 -33
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/gui/task_widget.py +7 -1
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/models.py +7 -1
- pyclickerrpa-0.3.0/autoclicker/ocr.py +107 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/runner.py +43 -4
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/windows.py +24 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/pyproject.toml +1 -1
- pyclickerrpa-0.3.0/tests/test_flow_branch.py +146 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/tests/test_runner.py +37 -0
- pyclickerrpa-0.2.0/autoclicker/ocr.py +0 -63
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/LICENSE +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/PyClickerRPA.egg-info/dependency_links.txt +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/PyClickerRPA.egg-info/entry_points.txt +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/PyClickerRPA.egg-info/requires.txt +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/PyClickerRPA.egg-info/top_level.txt +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/__init__.py +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/adb.py +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/app.py +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/clicker.py +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/dpi.py +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/gui/__init__.py +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/gui/hotkeys.py +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/gui/monitor.py +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/gui/preview_label.py +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/gui/qt_runner.py +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/gui/recorder.py +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/gui/scheduler_widget.py +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/gui/snippet_library.py +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/gui/testkit_widget.py +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/matcher.py +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/run.py +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/testkit/__init__.py +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/testkit/__main__.py +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/testkit/assertions.py +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/testkit/case.py +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/testkit/loader.py +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/testkit/pytest_plugin.py +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/testkit/report.py +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/testkit/runner.py +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/testkit/visual.py +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/web/__init__.py +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/web/__main__.py +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/web/run_manager.py +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/web/scheduler.py +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/web/serialize.py +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/web/server.py +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/web/static/app.js +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/web/static/index.html +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/web/static/style.css +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/setup.cfg +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/tests/test_adb.py +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/tests/test_matcher.py +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/tests/test_models.py +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/tests/test_testkit.py +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/tests/test_testkit_gui.py +0 -0
- {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/tests/test_web.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PyClickerRPA
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Visual UI automation & testing tool: Windows + Android(ADB) automation, robust/self-healing image matching, OCR, node flow editor, recorder, a visual test framework (assertions, visual regression, HTML/JUnit reports) and a web console.
|
|
5
5
|
Author: Equinox
|
|
6
6
|
License-Expression: MIT
|
|
@@ -84,12 +84,15 @@ autoclicker-web --suites-dir examples # web console at http://127.0.0.1
|
|
|
84
84
|
- **~30+ step types**: click image/text/position, click-all, relative/offset
|
|
85
85
|
clicks, wait / random / wait-stable, drag / long-press / pinch / scroll,
|
|
86
86
|
key / type / hotkey, loop / goto / while / do-until, if image/text/color/
|
|
87
|
-
count/var, set-var, read-text→variable,
|
|
88
|
-
call sub-task, groups
|
|
87
|
+
count/var with **true/false branching** (if-else), set-var, read-text→variable,
|
|
88
|
+
activate-window, HTTP request, run-Python-script, call sub-task, groups, and a
|
|
89
|
+
**stop** step to end a run — with retry / repeat / until policies.
|
|
89
90
|
|
|
90
91
|
## Authoring (desktop GUI)
|
|
91
92
|
|
|
92
|
-
Drag-and-drop **node flow graph**
|
|
93
|
+
Drag-and-drop **node flow graph** (condition nodes have **true/false branch
|
|
94
|
+
ports** for visual if-else; a terminal **stop** node ends the flow) and an
|
|
95
|
+
interoperable **step list** with a full
|
|
93
96
|
per-step dialog; an action **recorder** (global hooks), snippet library, task
|
|
94
97
|
packages (`.zip` with templates), on-preview coordinate/color picking, template
|
|
95
98
|
crop, single-step test run; global hotkeys (F6 run/stop, ESC stop), scheduler,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PyClickerRPA
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Visual UI automation & testing tool: Windows + Android(ADB) automation, robust/self-healing image matching, OCR, node flow editor, recorder, a visual test framework (assertions, visual regression, HTML/JUnit reports) and a web console.
|
|
5
5
|
Author: Equinox
|
|
6
6
|
License-Expression: MIT
|
|
@@ -84,12 +84,15 @@ autoclicker-web --suites-dir examples # web console at http://127.0.0.1
|
|
|
84
84
|
- **~30+ step types**: click image/text/position, click-all, relative/offset
|
|
85
85
|
clicks, wait / random / wait-stable, drag / long-press / pinch / scroll,
|
|
86
86
|
key / type / hotkey, loop / goto / while / do-until, if image/text/color/
|
|
87
|
-
count/var, set-var, read-text→variable,
|
|
88
|
-
call sub-task, groups
|
|
87
|
+
count/var with **true/false branching** (if-else), set-var, read-text→variable,
|
|
88
|
+
activate-window, HTTP request, run-Python-script, call sub-task, groups, and a
|
|
89
|
+
**stop** step to end a run — with retry / repeat / until policies.
|
|
89
90
|
|
|
90
91
|
## Authoring (desktop GUI)
|
|
91
92
|
|
|
92
|
-
Drag-and-drop **node flow graph**
|
|
93
|
+
Drag-and-drop **node flow graph** (condition nodes have **true/false branch
|
|
94
|
+
ports** for visual if-else; a terminal **stop** node ends the flow) and an
|
|
95
|
+
interoperable **step list** with a full
|
|
93
96
|
per-step dialog; an action **recorder** (global hooks), snippet library, task
|
|
94
97
|
packages (`.zip` with templates), on-preview coordinate/color picking, template
|
|
95
98
|
crop, single-step test run; global hotkeys (F6 run/stop, ESC stop), scheduler,
|
|
@@ -46,12 +46,15 @@ autoclicker-web --suites-dir examples # web console at http://127.0.0.1
|
|
|
46
46
|
- **~30+ step types**: click image/text/position, click-all, relative/offset
|
|
47
47
|
clicks, wait / random / wait-stable, drag / long-press / pinch / scroll,
|
|
48
48
|
key / type / hotkey, loop / goto / while / do-until, if image/text/color/
|
|
49
|
-
count/var, set-var, read-text→variable,
|
|
50
|
-
call sub-task, groups
|
|
49
|
+
count/var with **true/false branching** (if-else), set-var, read-text→variable,
|
|
50
|
+
activate-window, HTTP request, run-Python-script, call sub-task, groups, and a
|
|
51
|
+
**stop** step to end a run — with retry / repeat / until policies.
|
|
51
52
|
|
|
52
53
|
## Authoring (desktop GUI)
|
|
53
54
|
|
|
54
|
-
Drag-and-drop **node flow graph**
|
|
55
|
+
Drag-and-drop **node flow graph** (condition nodes have **true/false branch
|
|
56
|
+
ports** for visual if-else; a terminal **stop** node ends the flow) and an
|
|
57
|
+
interoperable **step list** with a full
|
|
55
58
|
per-step dialog; an action **recorder** (global hooks), snippet library, task
|
|
56
59
|
packages (`.zip` with templates), on-preview coordinate/color picking, template
|
|
57
60
|
crop, single-step test run; global hotkeys (F6 run/stop, ESC stop), scheduler,
|
|
@@ -30,35 +30,51 @@ def _bitmap_to_bgr(bmp, width: int, height: int) -> np.ndarray:
|
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
def capture_window(hwnd) -> np.ndarray:
|
|
33
|
-
"""Capture the client area of *hwnd* as a BGR ndarray.
|
|
33
|
+
"""Capture the **client area** of *hwnd* as a BGR ndarray.
|
|
34
|
+
|
|
35
|
+
PrintWindow renders the whole window (title bar + borders + client) from the
|
|
36
|
+
window's top-left, so we render the full window and then crop to the client
|
|
37
|
+
rect. The result's (0, 0) is the client top-left — matching the Clicker's
|
|
38
|
+
``ClientToScreen`` mapping, so match coords click the right spot (no offset
|
|
39
|
+
by the non-client area / title bar).
|
|
40
|
+
"""
|
|
34
41
|
if not _HAS_WIN32:
|
|
35
42
|
raise RuntimeError("win32 modules unavailable")
|
|
43
|
+
import ctypes
|
|
36
44
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
45
|
+
_, _, cw, ch = win32gui.GetClientRect(hwnd) # client size
|
|
46
|
+
if cw <= 0 or ch <= 0:
|
|
47
|
+
return np.empty((0, 0, 3), dtype=np.uint8)
|
|
48
|
+
|
|
49
|
+
wl, wt, wr, wb = win32gui.GetWindowRect(hwnd) # whole-window screen rect
|
|
50
|
+
ww, wh = wr - wl, wb - wt
|
|
51
|
+
csx, csy = win32gui.ClientToScreen(hwnd, (0, 0)) # client top-left in screen
|
|
52
|
+
ox, oy = csx - wl, csy - wt # client offset within window
|
|
53
|
+
if ww <= 0 or wh <= 0:
|
|
40
54
|
return np.empty((0, 0, 3), dtype=np.uint8)
|
|
41
55
|
|
|
42
56
|
hwnd_dc = win32gui.GetWindowDC(hwnd)
|
|
43
57
|
mfc_dc = win32ui.CreateDCFromHandle(hwnd_dc)
|
|
44
58
|
save_dc = mfc_dc.CreateCompatibleDC()
|
|
45
59
|
bmp = win32ui.CreateBitmap()
|
|
46
|
-
bmp.CreateCompatibleBitmap(mfc_dc,
|
|
60
|
+
bmp.CreateCompatibleBitmap(mfc_dc, ww, wh) # full-window sized bitmap
|
|
47
61
|
save_dc.SelectObject(bmp)
|
|
48
62
|
|
|
49
63
|
try:
|
|
50
|
-
import ctypes
|
|
51
64
|
ok = ctypes.windll.user32.PrintWindow(hwnd, save_dc.GetSafeHdc(), PW_RENDERFULLCONTENT)
|
|
52
65
|
if not ok:
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
img = _bitmap_to_bgr(bmp, width, height)
|
|
66
|
+
save_dc.BitBlt((0, 0), (ww, wh), mfc_dc, (0, 0), win32con.SRCCOPY)
|
|
67
|
+
full = _bitmap_to_bgr(bmp, ww, wh)
|
|
56
68
|
finally:
|
|
57
69
|
win32gui.DeleteObject(bmp.GetHandle())
|
|
58
70
|
save_dc.DeleteDC()
|
|
59
71
|
mfc_dc.DeleteDC()
|
|
60
72
|
win32gui.ReleaseDC(hwnd, hwnd_dc)
|
|
61
|
-
|
|
73
|
+
|
|
74
|
+
ox = max(0, min(ox, max(0, ww - 1)))
|
|
75
|
+
oy = max(0, min(oy, max(0, wh - 1)))
|
|
76
|
+
client = full[oy:oy + ch, ox:ox + cw]
|
|
77
|
+
return np.ascontiguousarray(client)
|
|
62
78
|
|
|
63
79
|
|
|
64
80
|
def capture_screen() -> np.ndarray:
|
|
@@ -65,7 +65,14 @@ class FlowConnection(QGraphicsPathItem):
|
|
|
65
65
|
dy = end.y() - start.y()
|
|
66
66
|
ctrl = min(dy * 0.5, 80.0)
|
|
67
67
|
path.cubicTo(start.x(), start.y() + ctrl, end.x(), end.y() - ctrl, end.x(), end.y())
|
|
68
|
-
self.
|
|
68
|
+
branch = getattr(self.from_port, "branch", None)
|
|
69
|
+
if branch == "true":
|
|
70
|
+
col = QColor(76, 175, 80) # 真:绿
|
|
71
|
+
elif branch == "false":
|
|
72
|
+
col = QColor(229, 57, 53) # 假:红
|
|
73
|
+
else:
|
|
74
|
+
col = QColor(200, 200, 200)
|
|
75
|
+
self.setPen(QPen(col, 2))
|
|
69
76
|
|
|
70
77
|
self.setPath(path)
|
|
71
78
|
mid = path.pointAtPercent(0.5)
|
|
@@ -22,14 +22,24 @@ from autoclicker.gui.flow_connection import FlowConnection
|
|
|
22
22
|
# Context-menu "add node" grouping (label, StepType)
|
|
23
23
|
_ADD_MENU = {
|
|
24
24
|
"点击": [("找图点击", StepType.ClickImage), ("找字点击", StepType.ClickText),
|
|
25
|
-
("坐标点击", StepType.ClickPosition), ("
|
|
25
|
+
("坐标点击", StepType.ClickPosition), ("点击所有匹配", StepType.ClickAll),
|
|
26
|
+
("相对点击(last+偏移)", StepType.ClickRelative), ("长按", StepType.LongPress)],
|
|
26
27
|
"输入": [("按键", StepType.KeyPress), ("输入文字", StepType.TypeText), ("快捷键", StepType.Hotkey)],
|
|
27
|
-
"手势": [("拖拽", StepType.Drag), ("
|
|
28
|
+
"手势": [("拖拽", StepType.Drag), ("多点拖拽", StepType.MultiDrag),
|
|
29
|
+
("捏合", StepType.Pinch), ("滚轮", StepType.Scroll)],
|
|
30
|
+
"识别/变量": [("读取文字→变量", StepType.ReadText), ("设置变量", StepType.SetVar)],
|
|
31
|
+
"条件": [("条件(图片)", StepType.IfImageFound), ("条件(文字)", StepType.IfTextFound),
|
|
32
|
+
("条件(颜色)", StepType.IfColorMatch), ("条件(图片数量)", StepType.IfImageCount),
|
|
33
|
+
("条件(变量)", StepType.IfVar), ("If/Else", StepType.IfElse)],
|
|
28
34
|
"流程": [("等待", StepType.Wait), ("随机等待", StepType.RandomWait),
|
|
35
|
+
("等待画面稳定", StepType.WaitStable),
|
|
29
36
|
("等待图片出现", StepType.WaitImage), ("等待图片消失", StepType.WaitImageGone),
|
|
30
|
-
("
|
|
31
|
-
("
|
|
32
|
-
("
|
|
37
|
+
("循环直到", StepType.DoUntil), ("While 条件", StepType.WhileCondition),
|
|
38
|
+
("跳转", StepType.Goto), ("调用子任务", StepType.CallSub),
|
|
39
|
+
("分组开始", StepType.GroupBegin), ("分组结束", StepType.GroupEnd),
|
|
40
|
+
("结束任务", StepType.Stop)],
|
|
41
|
+
"窗口/高级": [("刷新/激活窗口", StepType.ActivateWindow), ("合成拖拽", StepType.MergeImage),
|
|
42
|
+
("HTTP 请求", StepType.HttpRequest), ("运行脚本(Python)", StepType.RunScript)],
|
|
33
43
|
}
|
|
34
44
|
|
|
35
45
|
|
|
@@ -196,6 +206,13 @@ class FlowEditor(QGraphicsView):
|
|
|
196
206
|
node.set_step(dlg.result_step())
|
|
197
207
|
self.taskChanged.emit()
|
|
198
208
|
|
|
209
|
+
def selected_node(self):
|
|
210
|
+
"""当前选中的第一个节点(FlowNode),没有则返回 None。"""
|
|
211
|
+
for item in self._scene.selectedItems():
|
|
212
|
+
if isinstance(item, FlowNode):
|
|
213
|
+
return item
|
|
214
|
+
return None
|
|
215
|
+
|
|
199
216
|
def _set_repeat(self, conn: FlowConnection):
|
|
200
217
|
count, ok = QInputDialog.getInt(self, "重复次数", "执行次数 (-1 = 无限, 1 = 单次):",
|
|
201
218
|
conn.repeat_count, -1, 99999, 1)
|
|
@@ -231,88 +248,197 @@ class FlowEditor(QGraphicsView):
|
|
|
231
248
|
return self.nodes[0] if self.nodes else None
|
|
232
249
|
|
|
233
250
|
# ---- graph <-> Task ----
|
|
251
|
+
def _first_forward(self, port, emitted) -> FlowNode | None:
|
|
252
|
+
"""该端口的首个「尚未发出」的目标(前向边);全被发出过则回退到首个目标(回边)。"""
|
|
253
|
+
targets = [c.to_port.parentItem() for c in port.connections]
|
|
254
|
+
for t in targets:
|
|
255
|
+
if t not in emitted:
|
|
256
|
+
return t
|
|
257
|
+
return targets[0] if targets else None
|
|
258
|
+
|
|
259
|
+
def _reachable(self, node, blocked: set) -> set:
|
|
260
|
+
"""从 node 沿所有输出口前向可达的节点集合(带环保护)。
|
|
261
|
+
不穿过 blocked 里的节点——它们是已发出的祖先/循环目标,若允许穿过,回边会
|
|
262
|
+
绕一圈把分支入口自身也算成「可达」,导致汇合点误判(见 test_flow_branch 回边用例)。"""
|
|
263
|
+
seen, stack = set(), [node]
|
|
264
|
+
while stack:
|
|
265
|
+
n = stack.pop()
|
|
266
|
+
if n is None or n in seen or n in blocked:
|
|
267
|
+
continue
|
|
268
|
+
seen.add(n)
|
|
269
|
+
for p in n.output_ports():
|
|
270
|
+
for c in p.connections:
|
|
271
|
+
stack.append(c.to_port.parentItem())
|
|
272
|
+
return seen
|
|
273
|
+
|
|
274
|
+
def _find_merge(self, a, b, blocked: set):
|
|
275
|
+
"""真/假两分支入口 a、b 的首个共同汇合点;无则 None(两支各自终止或直接回边)。
|
|
276
|
+
blocked = 已发出节点,遍历不穿过它们,避免回边绕回上游造成误判。"""
|
|
277
|
+
if a is None or b is None:
|
|
278
|
+
return None
|
|
279
|
+
ra = self._reachable(a, blocked)
|
|
280
|
+
seen, stack = set(), [b]
|
|
281
|
+
while stack:
|
|
282
|
+
n = stack.pop()
|
|
283
|
+
if n is None or n in seen or n in blocked:
|
|
284
|
+
continue
|
|
285
|
+
seen.add(n)
|
|
286
|
+
if n in ra:
|
|
287
|
+
return n
|
|
288
|
+
for p in n.output_ports():
|
|
289
|
+
for c in p.connections:
|
|
290
|
+
stack.append(c.to_port.parentItem())
|
|
291
|
+
return None
|
|
292
|
+
|
|
234
293
|
def build_task(self) -> Task:
|
|
235
294
|
task = Task(name="FlowTask")
|
|
236
295
|
start = self._find_start()
|
|
237
296
|
if not start:
|
|
238
297
|
return task
|
|
239
298
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
299
|
+
emitted: set = set()
|
|
300
|
+
node_index: dict = {}
|
|
301
|
+
|
|
302
|
+
def emit(node, stop):
|
|
303
|
+
while node is not None and node is not stop and node not in emitted:
|
|
304
|
+
emitted.add(node)
|
|
305
|
+
s = node.step()
|
|
306
|
+
idx = len(task.steps)
|
|
307
|
+
node_index[node] = idx
|
|
308
|
+
task.steps.append(s)
|
|
309
|
+
|
|
310
|
+
has_else = (node.is_condition() and node.false_port is not None
|
|
311
|
+
and bool(node.false_port.connections))
|
|
312
|
+
if has_else:
|
|
313
|
+
then_entry = self._first_forward(node.output_port, emitted)
|
|
314
|
+
else_entry = self._first_forward(node.false_port, emitted)
|
|
315
|
+
merge = self._find_merge(then_entry, else_entry, emitted)
|
|
316
|
+
emit(then_entry, merge) # then 块
|
|
317
|
+
ifelse = Step(type=StepType.IfElse)
|
|
318
|
+
ifelse_idx = len(task.steps)
|
|
319
|
+
task.steps.append(ifelse)
|
|
320
|
+
emit(else_entry, merge) # else 块
|
|
321
|
+
merge_idx = len(task.steps)
|
|
322
|
+
# cond 为假 → 跳过 then 块 + IfElse 标记,落到 else 块首行
|
|
323
|
+
s.elseSkip = ifelse_idx - idx
|
|
324
|
+
# IfElse 为真(then 已跑完)→ 跳过整个 else 块
|
|
325
|
+
ifelse.elseSkip = merge_idx - ifelse_idx - 1
|
|
326
|
+
node = merge
|
|
327
|
+
else:
|
|
328
|
+
if node.is_condition():
|
|
329
|
+
s.elseSkip = 0 # 图里未接双分支 → 只求值不跳转
|
|
330
|
+
nxt = None
|
|
331
|
+
if node.output_port is not None: # 终止节点无输出口
|
|
332
|
+
nxt = self._first_forward(node.output_port, emitted)
|
|
333
|
+
if nxt is None and node.false_port is not None:
|
|
334
|
+
nxt = self._first_forward(node.false_port, emitted)
|
|
335
|
+
node = nxt
|
|
336
|
+
|
|
337
|
+
emit(start, None)
|
|
338
|
+
|
|
339
|
+
for node, idx in node_index.items():
|
|
254
340
|
s = node.step()
|
|
255
341
|
if s.type == StepType.DoUntil:
|
|
256
|
-
for
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
# Back-edges with a repeat count become Loop steps
|
|
342
|
+
for p in node.output_ports():
|
|
343
|
+
for conn in p.connections:
|
|
344
|
+
ti = node_index.get(conn.to_port.parentItem(), -1)
|
|
345
|
+
if 0 <= ti <= idx:
|
|
346
|
+
s.loopTarget = ti
|
|
347
|
+
break
|
|
348
|
+
|
|
349
|
+
# 带重复次数的回边 → Loop 步骤。注意:不支持把重复回边嵌进 if/else 分支内部
|
|
350
|
+
# (会打乱分支的相对 elseSkip 偏移);回边应作用于线性/循环段。
|
|
266
351
|
for conn in self.connections:
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
fi = node_index.get(from_node, -1)
|
|
270
|
-
ti = node_index.get(to_node, -1)
|
|
352
|
+
fi = node_index.get(conn.from_port.parentItem(), -1)
|
|
353
|
+
ti = node_index.get(conn.to_port.parentItem(), -1)
|
|
271
354
|
if fi >= 0 and ti >= 0 and ti <= fi and conn.repeat_count != 1:
|
|
272
355
|
loop = Step(type=StepType.Loop, loopTarget=ti, loopCount=conn.repeat_count)
|
|
273
356
|
insert_pos = fi + 1
|
|
274
357
|
task.steps.insert(insert_pos, loop)
|
|
275
|
-
for
|
|
276
|
-
if node_index[
|
|
277
|
-
node_index[
|
|
358
|
+
for n in list(node_index):
|
|
359
|
+
if node_index[n] >= insert_pos:
|
|
360
|
+
node_index[n] += 1
|
|
278
361
|
return task
|
|
279
362
|
|
|
280
363
|
def load_task(self, task: Task):
|
|
364
|
+
"""线性 Task → 图。Loop 步骤还原为回边;IfElse 是 build 时生成的分界标记,
|
|
365
|
+
不建节点,而是据此把条件节点的真/假分支口重新连回。非嵌套 if/else 可完整往返;
|
|
366
|
+
深度嵌套分支可能退化为线性连线(不影响已保存的线性 .json)。"""
|
|
367
|
+
from PySide6.QtCore import QPointF
|
|
368
|
+
from autoclicker.gui.flow_node import _CONDITION_TYPES
|
|
281
369
|
self.clear()
|
|
370
|
+
steps = task.steps
|
|
371
|
+
|
|
372
|
+
node_step_idx = [i for i, s in enumerate(steps)
|
|
373
|
+
if s.type not in (StepType.Loop, StepType.IfElse)]
|
|
374
|
+
node_set = set(node_step_idx)
|
|
375
|
+
|
|
376
|
+
# 识别 if/else 区间: 条件步(elseSkip>0) 且 C+elseSkip 处是 IfElse 标记
|
|
377
|
+
regions = [] # (C, then_nodes, else_nodes, merge_idx)
|
|
378
|
+
else_steps: set = set()
|
|
379
|
+
for c in node_step_idx:
|
|
380
|
+
s = steps[c]
|
|
381
|
+
if s.type in _CONDITION_TYPES and s.elseSkip > 0:
|
|
382
|
+
m = c + s.elseSkip
|
|
383
|
+
if 0 <= m < len(steps) and steps[m].type == StepType.IfElse:
|
|
384
|
+
e = steps[m].elseSkip
|
|
385
|
+
merge_idx = m + e + 1
|
|
386
|
+
then_nodes = [j for j in range(c + 1, m) if j in node_set]
|
|
387
|
+
else_nodes = [j for j in range(m + 1, min(merge_idx, len(steps))) if j in node_set]
|
|
388
|
+
regions.append((c, then_nodes, else_nodes, merge_idx))
|
|
389
|
+
else_steps.update(else_nodes)
|
|
390
|
+
|
|
391
|
+
node_of: dict[int, FlowNode] = {}
|
|
282
392
|
y = 0.0
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
if step.type == StepType.Loop:
|
|
288
|
-
continue
|
|
289
|
-
step_to_node[i] = node_idx
|
|
290
|
-
node_idx += 1
|
|
393
|
+
for i in node_step_idx:
|
|
394
|
+
x = 260.0 if i in else_steps else 0.0
|
|
395
|
+
node_of[i] = self.add_node(steps[i], QPointF(x, y))
|
|
396
|
+
y += 100
|
|
291
397
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
398
|
+
def wire(a: int, b: int, false=False):
|
|
399
|
+
if b not in node_of:
|
|
400
|
+
return
|
|
401
|
+
src = node_of[a]
|
|
402
|
+
port = src.false_port if (false and src.false_port is not None) else src.output_port
|
|
403
|
+
if port is None: # 终止节点无输出口
|
|
404
|
+
return
|
|
405
|
+
self.connect_nodes(port, node_of[b].input_port)
|
|
406
|
+
|
|
407
|
+
handled: set = set()
|
|
408
|
+
for c, then_nodes, else_nodes, merge_idx in regions:
|
|
409
|
+
handled.add(c)
|
|
410
|
+
for chain, is_false in ((then_nodes, False), (else_nodes, True)):
|
|
411
|
+
if chain:
|
|
412
|
+
wire(c, chain[0], false=is_false)
|
|
413
|
+
for a, b in zip(chain, chain[1:]):
|
|
414
|
+
wire(a, b)
|
|
415
|
+
if merge_idx in node_of:
|
|
416
|
+
wire(chain[-1], merge_idx)
|
|
417
|
+
handled.update(chain)
|
|
418
|
+
elif merge_idx in node_of:
|
|
419
|
+
wire(c, merge_idx, false=is_false)
|
|
420
|
+
|
|
421
|
+
# 未被分支逻辑接管的相邻节点 → 顺序连线
|
|
422
|
+
for a, b in zip(node_step_idx, node_step_idx[1:]):
|
|
423
|
+
if a in handled:
|
|
295
424
|
continue
|
|
296
|
-
|
|
297
|
-
if prev is not None:
|
|
298
|
-
self.connect_nodes(prev.output_port, node.input_port)
|
|
299
|
-
prev = node
|
|
300
|
-
y += 100
|
|
425
|
+
wire(a, b)
|
|
301
426
|
|
|
302
|
-
|
|
427
|
+
# 回边: 带重复次数的 Loop
|
|
428
|
+
for i, step in enumerate(steps):
|
|
303
429
|
if step.type == StepType.Loop:
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
if
|
|
307
|
-
conn = self.connect_nodes(
|
|
430
|
+
from_node = node_of.get(i - 1)
|
|
431
|
+
to_node = node_of.get(step.loopTarget)
|
|
432
|
+
if from_node is not None and to_node is not None:
|
|
433
|
+
conn = self.connect_nodes(from_node.output_port, to_node.input_port)
|
|
308
434
|
if conn:
|
|
309
435
|
conn.set_repeat_count(step.loopCount)
|
|
310
436
|
|
|
311
|
-
for i
|
|
312
|
-
if
|
|
313
|
-
|
|
314
|
-
if
|
|
315
|
-
self.connect_nodes(
|
|
437
|
+
for i in node_step_idx:
|
|
438
|
+
if steps[i].type == StepType.DoUntil:
|
|
439
|
+
to_node = node_of.get(steps[i].loopTarget)
|
|
440
|
+
if to_node is not None and steps[i].loopTarget <= i:
|
|
441
|
+
self.connect_nodes(node_of[i].output_port, to_node.input_port)
|
|
316
442
|
|
|
317
443
|
def validate_graph(self) -> tuple[bool, list[str]]:
|
|
318
444
|
if not self.nodes:
|
|
@@ -324,11 +450,12 @@ class FlowEditor(QGraphicsView):
|
|
|
324
450
|
queue = deque([start])
|
|
325
451
|
while queue:
|
|
326
452
|
current = queue.popleft()
|
|
327
|
-
for
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
reachable
|
|
331
|
-
|
|
453
|
+
for port in current.output_ports():
|
|
454
|
+
for conn in port.connections:
|
|
455
|
+
nxt = conn.to_port.parentItem()
|
|
456
|
+
if nxt not in reachable:
|
|
457
|
+
reachable.add(nxt)
|
|
458
|
+
queue.append(nxt)
|
|
332
459
|
for conn in current.input_port.connections:
|
|
333
460
|
prev = conn.from_port.parentItem()
|
|
334
461
|
if prev not in reachable:
|
|
@@ -33,17 +33,31 @@ _COLORS = {
|
|
|
33
33
|
StepType.DoUntil: (183, 28, 28),
|
|
34
34
|
StepType.HttpRequest: (3, 155, 229),
|
|
35
35
|
StepType.RunScript: (94, 53, 177),
|
|
36
|
+
StepType.ActivateWindow: (0, 172, 193),
|
|
37
|
+
StepType.Stop: (55, 71, 79),
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
# 终止节点:没有输出口(到此结束)
|
|
41
|
+
_TERMINAL_TYPES = {StepType.Stop}
|
|
42
|
+
|
|
38
43
|
|
|
39
44
|
class FlowPort(QGraphicsEllipseItem):
|
|
40
45
|
INPUT = "input"
|
|
41
46
|
OUTPUT = "output"
|
|
42
47
|
|
|
43
|
-
def __init__(self, port_type: str, parent):
|
|
48
|
+
def __init__(self, port_type: str, parent, branch: str | None = None):
|
|
44
49
|
super().__init__(-6, -6, 12, 12, parent)
|
|
45
50
|
self.port_type = port_type
|
|
46
|
-
self.
|
|
51
|
+
self.branch = branch # None / "true" / "false" —— 条件节点的真假分支口
|
|
52
|
+
if port_type == self.INPUT:
|
|
53
|
+
fill = QColor(100, 200, 100)
|
|
54
|
+
elif branch == "true":
|
|
55
|
+
fill = QColor(76, 175, 80) # 真:绿
|
|
56
|
+
elif branch == "false":
|
|
57
|
+
fill = QColor(229, 57, 53) # 假:红
|
|
58
|
+
else:
|
|
59
|
+
fill = QColor(100, 100, 200)
|
|
60
|
+
self.setBrush(fill)
|
|
47
61
|
self.setPen(QPen(Qt.white, 1))
|
|
48
62
|
self.setFlag(QGraphicsItem.ItemIsSelectable, False)
|
|
49
63
|
self.setCursor(Qt.CrossCursor)
|
|
@@ -53,6 +67,13 @@ class FlowPort(QGraphicsEllipseItem):
|
|
|
53
67
|
return self.mapToScene(self.rect().center())
|
|
54
68
|
|
|
55
69
|
|
|
70
|
+
# 会设置 _last_condition 并用 else_skip 分支的条件步骤 —— 这些节点有真/假两个输出口
|
|
71
|
+
_CONDITION_TYPES = {
|
|
72
|
+
StepType.IfImageFound, StepType.IfTextFound, StepType.IfColorMatch,
|
|
73
|
+
StepType.IfImageCount, StepType.IfVar,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
56
77
|
class FlowNode(QGraphicsItem):
|
|
57
78
|
WIDTH = 180
|
|
58
79
|
HEIGHT = 60
|
|
@@ -68,14 +89,40 @@ class FlowNode(QGraphicsItem):
|
|
|
68
89
|
|
|
69
90
|
self.input_port = FlowPort(FlowPort.INPUT, self)
|
|
70
91
|
self.input_port.setPos(self.WIDTH / 2, -6)
|
|
71
|
-
|
|
72
|
-
|
|
92
|
+
|
|
93
|
+
# 条件节点有真(绿)/假(红)两个输出口;其余节点只有一个。
|
|
94
|
+
# output_port 始终代表「主/真」分支(条件节点时是真分支),false_port
|
|
95
|
+
# 只有条件节点才有(否则为 None)。
|
|
96
|
+
self.false_port = None
|
|
97
|
+
if self._step.type in _TERMINAL_TYPES:
|
|
98
|
+
self.output_port = None # 终止节点无输出口
|
|
99
|
+
elif self.is_condition():
|
|
100
|
+
self.output_port = FlowPort(FlowPort.OUTPUT, self, branch="true")
|
|
101
|
+
self.output_port.setPos(self.WIDTH * 0.28, self.HEIGHT + 6)
|
|
102
|
+
self.false_port = FlowPort(FlowPort.OUTPUT, self, branch="false")
|
|
103
|
+
self.false_port.setPos(self.WIDTH * 0.72, self.HEIGHT + 6)
|
|
104
|
+
self._add_port_label("真", self.WIDTH * 0.28)
|
|
105
|
+
self._add_port_label("假", self.WIDTH * 0.72)
|
|
106
|
+
else:
|
|
107
|
+
self.output_port = FlowPort(FlowPort.OUTPUT, self)
|
|
108
|
+
self.output_port.setPos(self.WIDTH / 2, self.HEIGHT + 6)
|
|
73
109
|
|
|
74
110
|
self._label = QGraphicsTextItem(self)
|
|
75
111
|
self._label.setDefaultTextColor(Qt.white)
|
|
76
112
|
self._label.setTextWidth(self.WIDTH - 16)
|
|
77
113
|
self._update_label()
|
|
78
114
|
|
|
115
|
+
def is_condition(self) -> bool:
|
|
116
|
+
return self._step.type in _CONDITION_TYPES
|
|
117
|
+
|
|
118
|
+
def output_ports(self) -> list:
|
|
119
|
+
return [p for p in (self.output_port, self.false_port) if p is not None]
|
|
120
|
+
|
|
121
|
+
def _add_port_label(self, text: str, x: float):
|
|
122
|
+
lbl = QGraphicsTextItem(text, self)
|
|
123
|
+
lbl.setDefaultTextColor(Qt.white)
|
|
124
|
+
lbl.setPos(x - 16, self.HEIGHT + 8)
|
|
125
|
+
|
|
79
126
|
def step(self) -> Step:
|
|
80
127
|
return self._step
|
|
81
128
|
|
|
@@ -102,8 +149,8 @@ class FlowNode(QGraphicsItem):
|
|
|
102
149
|
|
|
103
150
|
def itemChange(self, change, value):
|
|
104
151
|
if change == QGraphicsItem.ItemPositionHasChanged:
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
152
|
+
ports = [self.input_port, *self.output_ports()]
|
|
153
|
+
for port in ports:
|
|
154
|
+
for conn in list(port.connections):
|
|
155
|
+
conn.update_path()
|
|
109
156
|
return super().itemChange(change, value)
|