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.
Files changed (64) hide show
  1. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/PKG-INFO +7 -4
  2. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/PyClickerRPA.egg-info/PKG-INFO +7 -4
  3. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/PyClickerRPA.egg-info/SOURCES.txt +1 -0
  4. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/README.md +6 -3
  5. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/capture.py +26 -10
  6. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/gui/flow_connection.py +8 -1
  7. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/gui/flow_editor.py +194 -67
  8. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/gui/flow_node.py +55 -8
  9. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/gui/main_window.py +173 -33
  10. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/gui/task_widget.py +7 -1
  11. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/models.py +7 -1
  12. pyclickerrpa-0.3.0/autoclicker/ocr.py +107 -0
  13. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/runner.py +43 -4
  14. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/windows.py +24 -0
  15. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/pyproject.toml +1 -1
  16. pyclickerrpa-0.3.0/tests/test_flow_branch.py +146 -0
  17. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/tests/test_runner.py +37 -0
  18. pyclickerrpa-0.2.0/autoclicker/ocr.py +0 -63
  19. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/LICENSE +0 -0
  20. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/PyClickerRPA.egg-info/dependency_links.txt +0 -0
  21. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/PyClickerRPA.egg-info/entry_points.txt +0 -0
  22. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/PyClickerRPA.egg-info/requires.txt +0 -0
  23. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/PyClickerRPA.egg-info/top_level.txt +0 -0
  24. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/__init__.py +0 -0
  25. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/adb.py +0 -0
  26. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/app.py +0 -0
  27. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/clicker.py +0 -0
  28. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/dpi.py +0 -0
  29. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/gui/__init__.py +0 -0
  30. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/gui/hotkeys.py +0 -0
  31. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/gui/monitor.py +0 -0
  32. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/gui/preview_label.py +0 -0
  33. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/gui/qt_runner.py +0 -0
  34. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/gui/recorder.py +0 -0
  35. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/gui/scheduler_widget.py +0 -0
  36. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/gui/snippet_library.py +0 -0
  37. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/gui/testkit_widget.py +0 -0
  38. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/matcher.py +0 -0
  39. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/run.py +0 -0
  40. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/testkit/__init__.py +0 -0
  41. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/testkit/__main__.py +0 -0
  42. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/testkit/assertions.py +0 -0
  43. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/testkit/case.py +0 -0
  44. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/testkit/loader.py +0 -0
  45. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/testkit/pytest_plugin.py +0 -0
  46. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/testkit/report.py +0 -0
  47. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/testkit/runner.py +0 -0
  48. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/testkit/visual.py +0 -0
  49. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/web/__init__.py +0 -0
  50. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/web/__main__.py +0 -0
  51. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/web/run_manager.py +0 -0
  52. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/web/scheduler.py +0 -0
  53. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/web/serialize.py +0 -0
  54. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/web/server.py +0 -0
  55. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/web/static/app.js +0 -0
  56. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/web/static/index.html +0 -0
  57. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/autoclicker/web/static/style.css +0 -0
  58. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/setup.cfg +0 -0
  59. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/tests/test_adb.py +0 -0
  60. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/tests/test_matcher.py +0 -0
  61. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/tests/test_models.py +0 -0
  62. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/tests/test_testkit.py +0 -0
  63. {pyclickerrpa-0.2.0 → pyclickerrpa-0.3.0}/tests/test_testkit_gui.py +0 -0
  64. {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.2.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, HTTP request, run-Python-script,
88
- call sub-task, groups — with retry / repeat / until policies.
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** and an interoperable **step list** with a full
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.2.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, HTTP request, run-Python-script,
88
- call sub-task, groups — with retry / repeat / until policies.
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** and an interoperable **step list** with a full
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,
@@ -52,6 +52,7 @@ autoclicker/web/static/app.js
52
52
  autoclicker/web/static/index.html
53
53
  autoclicker/web/static/style.css
54
54
  tests/test_adb.py
55
+ tests/test_flow_branch.py
55
56
  tests/test_matcher.py
56
57
  tests/test_models.py
57
58
  tests/test_runner.py
@@ -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, HTTP request, run-Python-script,
50
- call sub-task, groups — with retry / repeat / until policies.
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** and an interoperable **step list** with a full
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
- left, top, right, bottom = win32gui.GetClientRect(hwnd)
38
- width, height = right - left, bottom - top
39
- if width <= 0 or height <= 0:
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, width, height)
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
- # Fallback: BitBlt the window DC
54
- save_dc.BitBlt((0, 0), (width, height), mfc_dc, (0, 0), win32con.SRCCOPY)
55
- img = _bitmap_to_bgr(bmp, width, height)
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
- return img
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.setPen(QPen(QColor(200, 200, 200), 2))
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), ("长按", StepType.LongPress)],
25
+ ("坐标点击", StepType.ClickPosition), ("点击所有匹配", StepType.ClickAll),
26
+ ("相对点击(last+偏移)", StepType.ClickRelative), ("长按", StepType.LongPress)],
26
27
  "输入": [("按键", StepType.KeyPress), ("输入文字", StepType.TypeText), ("快捷键", StepType.Hotkey)],
27
- "手势": [("拖拽", StepType.Drag), ("捏合", StepType.Pinch), ("滚轮", StepType.Scroll)],
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
- ("条件(图片)", StepType.IfImageFound), ("条件(文字)", StepType.IfTextFound),
31
- ("条件(颜色)", StepType.IfColorMatch), ("调用子任务", StepType.CallSub),
32
- ("设置变量", StepType.SetVar), ("循环直到", StepType.DoUntil)],
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
- 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:
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 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
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
- 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)
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 node in list(node_index):
276
- if node_index[node] >= insert_pos:
277
- node_index[node] += 1
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
- 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
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
- from PySide6.QtCore import QPointF
293
- for i, step in enumerate(task.steps):
294
- if step.type == StepType.Loop:
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
- 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
425
+ wire(a, b)
301
426
 
302
- for i, step in enumerate(task.steps):
427
+ # 回边: 带重复次数的 Loop
428
+ for i, step in enumerate(steps):
303
429
  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)
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, 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)
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 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)
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.setBrush(QColor(100, 200, 100) if port_type == self.INPUT else QColor(100, 100, 200))
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
- self.output_port = FlowPort(FlowPort.OUTPUT, self)
72
- self.output_port.setPos(self.WIDTH / 2, self.HEIGHT + 6)
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
- for conn in list(self.input_port.connections):
106
- conn.update_path()
107
- for conn in list(self.output_port.connections):
108
- conn.update_path()
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)