PyClickerRPA 0.3.0__tar.gz → 0.3.1__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.3.0 → pyclickerrpa-0.3.1}/PKG-INFO +1 -1
  2. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/PyClickerRPA.egg-info/PKG-INFO +1 -1
  3. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/PyClickerRPA.egg-info/SOURCES.txt +1 -0
  4. pyclickerrpa-0.3.1/autoclicker/expr.py +94 -0
  5. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/gui/flow_editor.py +60 -3
  6. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/gui/main_window.py +95 -4
  7. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/gui/qt_runner.py +16 -1
  8. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/gui/task_widget.py +73 -14
  9. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/runner.py +64 -1
  10. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/pyproject.toml +1 -1
  11. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/LICENSE +0 -0
  12. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/PyClickerRPA.egg-info/dependency_links.txt +0 -0
  13. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/PyClickerRPA.egg-info/entry_points.txt +0 -0
  14. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/PyClickerRPA.egg-info/requires.txt +0 -0
  15. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/PyClickerRPA.egg-info/top_level.txt +0 -0
  16. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/README.md +0 -0
  17. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/__init__.py +0 -0
  18. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/adb.py +0 -0
  19. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/app.py +0 -0
  20. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/capture.py +0 -0
  21. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/clicker.py +0 -0
  22. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/dpi.py +0 -0
  23. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/gui/__init__.py +0 -0
  24. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/gui/flow_connection.py +0 -0
  25. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/gui/flow_node.py +0 -0
  26. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/gui/hotkeys.py +0 -0
  27. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/gui/monitor.py +0 -0
  28. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/gui/preview_label.py +0 -0
  29. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/gui/recorder.py +0 -0
  30. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/gui/scheduler_widget.py +0 -0
  31. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/gui/snippet_library.py +0 -0
  32. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/gui/testkit_widget.py +0 -0
  33. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/matcher.py +0 -0
  34. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/models.py +0 -0
  35. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/ocr.py +0 -0
  36. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/run.py +0 -0
  37. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/testkit/__init__.py +0 -0
  38. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/testkit/__main__.py +0 -0
  39. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/testkit/assertions.py +0 -0
  40. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/testkit/case.py +0 -0
  41. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/testkit/loader.py +0 -0
  42. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/testkit/pytest_plugin.py +0 -0
  43. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/testkit/report.py +0 -0
  44. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/testkit/runner.py +0 -0
  45. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/testkit/visual.py +0 -0
  46. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/web/__init__.py +0 -0
  47. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/web/__main__.py +0 -0
  48. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/web/run_manager.py +0 -0
  49. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/web/scheduler.py +0 -0
  50. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/web/serialize.py +0 -0
  51. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/web/server.py +0 -0
  52. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/web/static/app.js +0 -0
  53. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/web/static/index.html +0 -0
  54. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/web/static/style.css +0 -0
  55. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/windows.py +0 -0
  56. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/setup.cfg +0 -0
  57. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/tests/test_adb.py +0 -0
  58. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/tests/test_flow_branch.py +0 -0
  59. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/tests/test_matcher.py +0 -0
  60. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/tests/test_models.py +0 -0
  61. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/tests/test_runner.py +0 -0
  62. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/tests/test_testkit.py +0 -0
  63. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/tests/test_testkit_gui.py +0 -0
  64. {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/tests/test_web.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyClickerRPA
3
- Version: 0.3.0
3
+ Version: 0.3.1
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyClickerRPA
3
- Version: 0.3.0
3
+ Version: 0.3.1
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
@@ -13,6 +13,7 @@ autoclicker/app.py
13
13
  autoclicker/capture.py
14
14
  autoclicker/clicker.py
15
15
  autoclicker/dpi.py
16
+ autoclicker/expr.py
16
17
  autoclicker/matcher.py
17
18
  autoclicker/models.py
18
19
  autoclicker/ocr.py
@@ -0,0 +1,94 @@
1
+ """Safe arithmetic/string expression evaluator for the SetVar step.
2
+
3
+ `SetVar` values starting with ``=`` are evaluated here (e.g. ``=count + 1``,
4
+ ``=max(a, b) * 2``, ``=upper(name)``). Variable names resolve against the
5
+ runner's variable dict — numeric-looking values become numbers, everything
6
+ else stays a string; unknown names default to 0.
7
+
8
+ Security: this never calls the builtin ``eval``. It walks a whitelisted AST
9
+ and rejects attribute access, subscripting, comprehensions, and any call to a
10
+ function outside a fixed safe set — so ``__import__(...)`` / ``().__class__``
11
+ style escapes raise ``ValueError`` instead of executing.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import ast
16
+ import operator
17
+
18
+ _BIN_OPS = {
19
+ ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
20
+ ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
21
+ ast.Mod: operator.mod, ast.Pow: operator.pow,
22
+ }
23
+ _UNARY_OPS = {ast.UAdd: operator.pos, ast.USub: operator.neg, ast.Not: operator.not_}
24
+ _CMP_OPS = {
25
+ ast.Eq: operator.eq, ast.NotEq: operator.ne, ast.Lt: operator.lt,
26
+ ast.LtE: operator.le, ast.Gt: operator.gt, ast.GtE: operator.ge,
27
+ }
28
+
29
+ _FUNCS = {
30
+ "int": int, "float": float, "str": str, "round": round, "abs": abs,
31
+ "min": min, "max": max, "len": len,
32
+ "upper": lambda s: str(s).upper(), "lower": lambda s: str(s).lower(),
33
+ }
34
+
35
+
36
+ def _coerce(value: str):
37
+ """A variable value string → number when it looks numeric, else the string."""
38
+ try:
39
+ f = float(value)
40
+ return int(f) if f.is_integer() else f
41
+ except (TypeError, ValueError):
42
+ return value
43
+
44
+
45
+ def _eval(node, variables):
46
+ if isinstance(node, ast.Expression):
47
+ return _eval(node.body, variables)
48
+ if isinstance(node, ast.Constant): # numbers, strings, True/False/None
49
+ return node.value
50
+ if isinstance(node, ast.Name):
51
+ return _coerce(variables.get(node.id, "0"))
52
+ if isinstance(node, ast.BinOp) and type(node.op) in _BIN_OPS:
53
+ return _BIN_OPS[type(node.op)](_eval(node.left, variables), _eval(node.right, variables))
54
+ if isinstance(node, ast.UnaryOp) and type(node.op) in _UNARY_OPS:
55
+ return _UNARY_OPS[type(node.op)](_eval(node.operand, variables))
56
+ if isinstance(node, ast.BoolOp):
57
+ vals = [_eval(v, variables) for v in node.values]
58
+ result = vals[0]
59
+ for v in vals[1:]:
60
+ result = (result and v) if isinstance(node.op, ast.And) else (result or v)
61
+ return result
62
+ if isinstance(node, ast.Compare):
63
+ left = _eval(node.left, variables)
64
+ for op, comparator in zip(node.ops, node.comparators):
65
+ if type(op) not in _CMP_OPS:
66
+ raise ValueError(f"不支持的比较运算符: {type(op).__name__}")
67
+ right = _eval(comparator, variables)
68
+ if not _CMP_OPS[type(op)](left, right):
69
+ return False
70
+ left = right
71
+ return True
72
+ if isinstance(node, ast.Call):
73
+ if not isinstance(node.func, ast.Name) or node.func.id not in _FUNCS:
74
+ raise ValueError("只允许调用: " + ", ".join(sorted(_FUNCS)))
75
+ if node.keywords:
76
+ raise ValueError("函数不支持关键字参数")
77
+ args = [_eval(a, variables) for a in node.args]
78
+ return _FUNCS[node.func.id](*args)
79
+ raise ValueError(f"表达式包含不允许的语法: {type(node).__name__}")
80
+
81
+
82
+ def safe_eval(expr: str, variables: dict) -> str:
83
+ """Evaluate `expr` against `variables`, returning a string result.
84
+
85
+ Integer-valued floats render without a trailing ``.0`` so counters stay clean.
86
+ Raises ``ValueError`` (or SyntaxError) on anything outside the whitelist.
87
+ """
88
+ tree = ast.parse(expr, mode="eval")
89
+ result = _eval(tree, variables)
90
+ if isinstance(result, float) and result.is_integer():
91
+ return str(int(result))
92
+ if isinstance(result, bool):
93
+ return "1" if result else "0"
94
+ return str(result)
@@ -7,10 +7,11 @@ recreates loop/do-until back-edges.
7
7
  """
8
8
  from __future__ import annotations
9
9
 
10
+ import copy
10
11
  from collections import deque
11
12
 
12
- from PySide6.QtCore import QLineF, Qt, Signal
13
- from PySide6.QtGui import QColor, QPainter, QPen
13
+ from PySide6.QtCore import QLineF, QPointF, Qt, Signal
14
+ from PySide6.QtGui import QColor, QKeySequence, QPainter, QPen
14
15
  from PySide6.QtWidgets import (
15
16
  QDialog, QGraphicsScene, QGraphicsView, QInputDialog, QMenu,
16
17
  )
@@ -52,7 +53,7 @@ class FlowEditor(QGraphicsView):
52
53
  self._scene.setSceneRect(-2000, -2000, 4000, 4000)
53
54
  self.setScene(self._scene)
54
55
  self.setRenderHint(QPainter.Antialiasing)
55
- self.setDragMode(QGraphicsView.NoDrag)
56
+ self.setDragMode(QGraphicsView.RubberBandDrag)
56
57
  self.setBackgroundBrush(QColor(30, 30, 30))
57
58
  self.setMinimumSize(400, 300)
58
59
 
@@ -64,6 +65,7 @@ class FlowEditor(QGraphicsView):
64
65
 
65
66
  self.nodes: list[FlowNode] = []
66
67
  self.connections: list[FlowConnection] = []
68
+ self._clip: dict | None = None
67
69
  self._dragging = False
68
70
  self._drag_start_port: FlowPort | None = None
69
71
  self._drag_line = None
@@ -165,11 +167,61 @@ class FlowEditor(QGraphicsView):
165
167
  self.scale(factor, factor)
166
168
 
167
169
  def keyPressEvent(self, event):
170
+ if event.matches(QKeySequence.Copy):
171
+ self._copy_selected()
172
+ return
173
+ if event.matches(QKeySequence.Paste):
174
+ self._paste_clip()
175
+ return
168
176
  if event.key() in (Qt.Key_Delete, Qt.Key_Backspace):
169
177
  self._delete_selected()
170
178
  return
171
179
  super().keyPressEvent(event)
172
180
 
181
+ # ---- copy / paste selected nodes ----
182
+ def selected_nodes(self) -> list[FlowNode]:
183
+ return [n for n in self.nodes if n.isSelected()]
184
+
185
+ def get_selected_steps(self) -> list[Step]:
186
+ nodes = sorted(self.selected_nodes(), key=lambda n: (n.pos().y(), n.pos().x()))
187
+ return [copy.deepcopy(n.step()) for n in nodes]
188
+
189
+ def _copy_selected(self):
190
+ nodes = self.selected_nodes()
191
+ if not nodes:
192
+ return
193
+ idx = {n: i for i, n in enumerate(nodes)}
194
+ edges = []
195
+ for conn in self.connections:
196
+ fn = conn.from_port.parentItem()
197
+ tn = conn.to_port.parentItem()
198
+ if fn in idx and tn in idx:
199
+ # 记录出边走的是「真」还是「假」分支(条件节点有 false_port)
200
+ branch = "false" if conn.from_port is fn.false_port else "true"
201
+ edges.append((idx[fn], idx[tn], branch, conn.repeat_count))
202
+ self._clip = {
203
+ "steps": [copy.deepcopy(n.step()) for n in nodes],
204
+ "pos": [(n.pos().x(), n.pos().y()) for n in nodes],
205
+ "edges": edges,
206
+ }
207
+
208
+ def _paste_clip(self):
209
+ if not self._clip or not self._clip["steps"]:
210
+ return
211
+ new_nodes = []
212
+ for step, (px, py) in zip(self._clip["steps"], self._clip["pos"]):
213
+ new_nodes.append(self.add_node(copy.deepcopy(step), QPointF(px + 40, py + 40)))
214
+ for fi, ti, branch, rc in self._clip["edges"]:
215
+ src = new_nodes[fi].false_port if branch == "false" else new_nodes[fi].output_port
216
+ if src is None: # 终止节点没有输出口
217
+ continue
218
+ conn = self.connect_nodes(src, new_nodes[ti].input_port)
219
+ if conn:
220
+ conn.set_repeat_count(rc)
221
+ self._scene.clearSelection()
222
+ for n in new_nodes:
223
+ n.setSelected(True)
224
+
173
225
  def contextMenuEvent(self, event):
174
226
  menu = QMenu(self)
175
227
  scene_pos = self.mapToScene(event.pos())
@@ -183,6 +235,11 @@ class FlowEditor(QGraphicsView):
183
235
  menu.addAction("设置重复次数", lambda: self._set_repeat(conn))
184
236
  if node or conn:
185
237
  menu.addAction("删除", self._delete_selected)
238
+ if self.selected_nodes():
239
+ menu.addAction("复制选中节点 (Ctrl+C)", self._copy_selected)
240
+ if self._clip and self._clip["steps"]:
241
+ menu.addAction("粘贴 (Ctrl+V)", self._paste_clip)
242
+ if node or conn or self.selected_nodes() or self._clip:
186
243
  menu.addSeparator()
187
244
 
188
245
  add_menu = menu.addMenu("添加节点")
@@ -67,6 +67,7 @@ class MainWindow(QMainWindow):
67
67
  self._runner.matchFound.connect(
68
68
  lambda x, y, c: self._append_log(f" → 命中 ({x},{y}) {c:.3f}"))
69
69
  self._runner.runFinished.connect(self._on_finished)
70
+ self._runner.pausedAt.connect(self._on_paused)
70
71
 
71
72
  self._recorder = Recorder(self)
72
73
  self._recorder.stepRecorded.connect(self._on_step_recorded)
@@ -236,6 +237,14 @@ class MainWindow(QMainWindow):
236
237
  self.stop_btn = QPushButton("■ 停止")
237
238
  self.stop_btn.clicked.connect(self.stop_task)
238
239
  self.stop_btn.setEnabled(False)
240
+ self.debug_btn = QPushButton("🐞 调试")
241
+ self.debug_btn.clicked.connect(self.debug_run)
242
+ self.step_btn = QPushButton("单步")
243
+ self.step_btn.clicked.connect(lambda: self._runner.debug_step())
244
+ self.step_btn.setEnabled(False)
245
+ self.continue_btn = QPushButton("继续")
246
+ self.continue_btn.clicked.connect(lambda: self._runner.debug_continue())
247
+ self.continue_btn.setEnabled(False)
239
248
  bar.addWidget(QLabel("目标窗口"))
240
249
  bar.addWidget(self.window_combo, 1)
241
250
  bar.addWidget(refresh_btn)
@@ -245,6 +254,9 @@ class MainWindow(QMainWindow):
245
254
  bar.addWidget(self.run_btn)
246
255
  bar.addWidget(self.pause_btn)
247
256
  bar.addWidget(self.stop_btn)
257
+ bar.addWidget(self.debug_btn)
258
+ bar.addWidget(self.step_btn)
259
+ bar.addWidget(self.continue_btn)
248
260
  root.addLayout(bar)
249
261
 
250
262
  splitter = QSplitter(Qt.Horizontal)
@@ -327,6 +339,9 @@ class MainWindow(QMainWindow):
327
339
  e = self.menuBar().addMenu("编辑")
328
340
  e.addAction(QAction("选中步骤存为片段…", self, triggered=self.save_snippet))
329
341
  e.addSeparator()
342
+ e.addAction(QAction("导出选中步骤为文件(.json)…", self, triggered=self.export_steps_file))
343
+ e.addAction(QAction("从文件导入步骤(.json)…", self, triggered=self.import_steps_file))
344
+ e.addSeparator()
330
345
  e.addAction(QAction("复制步骤到剪贴板(JSON)", self, triggered=self.copy_steps_json))
331
346
  e.addAction(QAction("从剪贴板粘贴步骤(JSON)", self, triggered=self.paste_steps_json))
332
347
 
@@ -402,6 +417,29 @@ class MainWindow(QMainWindow):
402
417
  self._runner.start(task, target_hwnd=self._target_hwnd(),
403
418
  screenshot_on_fail=True, screenshot_dir=".failures")
404
419
 
420
+ def debug_run(self):
421
+ if self._runner.is_running():
422
+ return
423
+ self.tabs.setCurrentWidget(self.task_widget)
424
+ task = self._current_task()
425
+ if not task.steps:
426
+ self._append_log("任务为空")
427
+ return
428
+ self.log.clear()
429
+ self._clear_vars()
430
+ self.preview.clear_markers()
431
+ self._set_running(True)
432
+ self._append_log("调试模式:停在第 1 步,用「单步」/「继续」推进")
433
+ self._runner.start(task, target_hwnd=self._target_hwnd(),
434
+ screenshot_on_fail=True, screenshot_dir=".failures",
435
+ breakpoints=self.task_widget.breakpoints(), step_mode=True)
436
+
437
+ def _on_paused(self, index: int, desc: str):
438
+ self._append_log(f"⏸ 暂停于 [{index + 1}] {desc}")
439
+ self.task_widget.set_running_row(index)
440
+ self.step_btn.setEnabled(True)
441
+ self.continue_btn.setEnabled(True)
442
+
405
443
  def stop_task(self):
406
444
  self._runner.stop()
407
445
 
@@ -415,16 +453,20 @@ class MainWindow(QMainWindow):
415
453
 
416
454
  def _set_running(self, running: bool):
417
455
  self.run_btn.setEnabled(not running)
456
+ self.debug_btn.setEnabled(not running)
418
457
  self.stop_btn.setEnabled(running)
419
458
  self.pause_btn.setEnabled(running)
420
459
  if not running:
421
460
  self.pause_btn.setText("⏸ 暂停")
461
+ self.step_btn.setEnabled(False)
462
+ self.continue_btn.setEnabled(False)
422
463
 
423
464
  def _on_step_started(self, index: int, desc: str):
424
465
  self._append_log(f"[{index + 1}] {desc}")
425
466
 
426
467
  def _on_finished(self):
427
468
  self._set_running(False)
469
+ self.task_widget.clear_running_row()
428
470
 
429
471
  def _append_log(self, msg: str):
430
472
  self.log.append(msg)
@@ -503,10 +545,16 @@ class MainWindow(QMainWindow):
503
545
  self.tabs.setCurrentWidget(self.task_widget)
504
546
  self._append_log(f"插入片段: {len(steps)} 步")
505
547
 
548
+ def _selected_steps(self) -> list[Step]:
549
+ """Selected steps from whichever editor tab is active."""
550
+ if self.tabs.currentWidget() is self.flow_editor:
551
+ return self.flow_editor.get_selected_steps()
552
+ return self.task_widget.get_selected_steps()
553
+
506
554
  def save_snippet(self):
507
- steps = self.task_widget.get_selected_steps()
555
+ steps = self._selected_steps()
508
556
  if not steps:
509
- self._append_log("请先在「步骤列表」中选中要保存的步骤")
557
+ self._append_log("请先在「步骤列表」或「流程图」中选中要保存的步骤")
510
558
  return
511
559
  name, ok = QInputDialog.getText(self, "存为片段", "片段名称:")
512
560
  if ok and name:
@@ -516,13 +564,56 @@ class MainWindow(QMainWindow):
516
564
  # ---- step clipboard (JSON) ----
517
565
  def copy_steps_json(self):
518
566
  import json
519
- steps = self.task_widget.get_selected_steps()
567
+ steps = self._selected_steps()
520
568
  if not steps:
521
- self._append_log("请先在「步骤列表」中选中要复制的步骤")
569
+ self._append_log("请先在「步骤列表」或「流程图」中选中要复制的步骤")
522
570
  return
523
571
  QGuiApplication.clipboard().setText(json.dumps([s.to_json() for s in steps], ensure_ascii=False, indent=2))
524
572
  self._append_log(f"已复制 {len(steps)} 步到剪贴板(JSON)")
525
573
 
574
+ def export_steps_file(self):
575
+ import json
576
+ steps = self._selected_steps()
577
+ if not steps:
578
+ self._append_log("请先在「步骤列表」或「流程图」中选中要导出的步骤")
579
+ return
580
+ path, _ = QFileDialog.getSaveFileName(self, "导出选中步骤", "", "步骤文件 (*.json)")
581
+ if not path:
582
+ return
583
+ if not path.lower().endswith(".json"):
584
+ path += ".json"
585
+ try:
586
+ with open(path, "w", encoding="utf-8") as f:
587
+ json.dump([s.to_json() for s in steps], f, ensure_ascii=False, indent=4)
588
+ except OSError as ex:
589
+ QMessageBox.warning(self, "导出失败", str(ex))
590
+ return
591
+ self._append_log(f"已导出 {len(steps)} 步到: {path}")
592
+
593
+ def import_steps_file(self):
594
+ import json
595
+ path, _ = QFileDialog.getOpenFileName(self, "导入步骤", "", "步骤文件 (*.json)")
596
+ if not path:
597
+ return
598
+ try:
599
+ with open(path, "r", encoding="utf-8") as f:
600
+ data = json.load(f)
601
+ except (OSError, json.JSONDecodeError) as ex:
602
+ QMessageBox.warning(self, "导入失败", str(ex))
603
+ return
604
+ if isinstance(data, dict) and "steps" in data: # accept a full task file too
605
+ data = data["steps"]
606
+ if not isinstance(data, list):
607
+ QMessageBox.warning(self, "导入失败", "文件内容既不是步骤数组也不是任务")
608
+ return
609
+ steps = [Step.from_json(o) for o in data]
610
+ if not steps:
611
+ self._append_log("文件里没有步骤")
612
+ return
613
+ self.task_widget.insert_steps(steps)
614
+ self.tabs.setCurrentWidget(self.task_widget)
615
+ self._append_log(f"已从文件导入 {len(steps)} 步: {path}")
616
+
526
617
  def paste_steps_json(self):
527
618
  import json
528
619
  text = QGuiApplication.clipboard().text()
@@ -32,6 +32,9 @@ class _SignalCallbacks(RunnerCallbacks):
32
32
  def var_changed(self, name, value):
33
33
  self._o.varChanged.emit(name, value)
34
34
 
35
+ def paused(self, index, description):
36
+ self._o.pausedAt.emit(index, description)
37
+
35
38
  def finished(self):
36
39
  self._o.runFinished.emit()
37
40
 
@@ -42,19 +45,31 @@ class QtRunner(QObject):
42
45
  matchFound = Signal(int, int, float)
43
46
  logMessage = Signal(str)
44
47
  varChanged = Signal(str, str)
48
+ pausedAt = Signal(int, str)
45
49
  runFinished = Signal()
46
50
 
47
51
  def __init__(self, parent=None) -> None:
48
52
  super().__init__(parent)
49
53
  self._runner: TaskRunner | None = None
50
54
 
51
- def start(self, task: Task, target_hwnd=None, screenshot_on_fail=False, screenshot_dir=None) -> None:
55
+ def start(self, task: Task, target_hwnd=None, screenshot_on_fail=False, screenshot_dir=None,
56
+ breakpoints=None, step_mode=False) -> None:
52
57
  self._runner = TaskRunner(task, target_hwnd=target_hwnd, callbacks=_SignalCallbacks(self))
53
58
  self._runner.screenshot_on_fail = screenshot_on_fail
54
59
  if screenshot_dir:
55
60
  self._runner.screenshot_dir = screenshot_dir
61
+ self._runner.set_breakpoints(breakpoints)
62
+ self._runner.enable_step_mode(step_mode)
56
63
  self._runner.start()
57
64
 
65
+ def debug_step(self) -> None:
66
+ if self._runner:
67
+ self._runner.debug_step()
68
+
69
+ def debug_continue(self) -> None:
70
+ if self._runner:
71
+ self._runner.debug_continue()
72
+
58
73
  def stop(self) -> None:
59
74
  if self._runner:
60
75
  self._runner.stop()
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
  import copy
11
11
 
12
12
  from PySide6.QtCore import Qt, Signal
13
+ from PySide6.QtGui import QColor, QKeySequence, QShortcut
13
14
  from PySide6.QtWidgets import (
14
15
  QCheckBox, QComboBox, QDialog, QDialogButtonBox, QDoubleSpinBox, QFileDialog,
15
16
  QFormLayout, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem,
@@ -73,6 +74,14 @@ _ACTION_TYPES = {StepType.ClickImage, StepType.ClickText, StepType.ClickPosition
73
74
  StepType.MergeImage, StepType.ClickAll}
74
75
  _UNTIL_TYPES = {StepType.DoUntil, StepType.WhileCondition}
75
76
 
77
+ # 变量比较运算符 (显示标签, 存储 token)。前 6 个 label==token 兼容旧任务。
78
+ _COMPARE_OPS = [
79
+ ("==", "=="), ("!=", "!="), (">", ">"), ("<", "<"), (">=", ">="), ("<=", "<="),
80
+ ("介于 [a,b]", "between"), ("不介于 [a,b]", "not_between"),
81
+ ("包含", "contains"), ("不包含", "not_contains"),
82
+ ("开头是", "startswith"), ("结尾是", "endswith"),
83
+ ]
84
+
76
85
 
77
86
  class ImageListEditor(QWidget):
78
87
  """Edit one-or-more template image paths."""
@@ -278,16 +287,20 @@ class StepEditorDialog(QDialog):
278
287
  self.form.addRow("子任务路径", self._line("subTaskPath", s.subTaskPath))
279
288
  if t == StepType.SetVar:
280
289
  self.form.addRow("变量名", self._line("varName", s.varName))
281
- self.form.addRow("值 (++/-- 支持)", self._line("varValue", s.varValue))
290
+ self.form.addRow("值 (=表达式 / ++ / -- / 字面量)", self._line("varValue", s.varValue))
282
291
  if t in (StepType.IfVar, StepType.WhileCondition):
283
292
  self.form.addRow("变量名", self._line("varName", s.varName))
284
293
  op = QComboBox()
285
- op.addItems(["==", "!=", ">", "<", ">=", "<="])
286
- if s.compareOp:
287
- op.setCurrentText(s.compareOp)
294
+ for label, token in _COMPARE_OPS:
295
+ op.addItem(label, token)
296
+ idx = op.findData(s.compareOp)
297
+ if idx < 0 and s.compareOp: # legacy op not in list → keep it
298
+ op.addItem(s.compareOp, s.compareOp)
299
+ idx = op.count() - 1
300
+ op.setCurrentIndex(max(0, idx))
288
301
  self.w["compareOp"] = op
289
302
  self.form.addRow("比较", op)
290
- self.form.addRow("比较值", self._line("compareValue", s.compareValue))
303
+ self.form.addRow("比较值 (区间填 a,b)", self._line("compareValue", s.compareValue))
291
304
  if t == StepType.KeyPress:
292
305
  self.form.addRow("按键", self._line("keyName", s.keyName))
293
306
  if t == StepType.Hotkey:
@@ -394,7 +407,7 @@ class StepEditorDialog(QDialog):
394
407
  if "script" in w:
395
408
  s.text = w["script"].toPlainText()
396
409
  if "compareOp" in w:
397
- s.compareOp = w["compareOp"].currentText()
410
+ s.compareOp = w["compareOp"].currentData() or w["compareOp"].currentText()
398
411
  if "readmode" in w:
399
412
  s.varValue = w["readmode"].currentData()
400
413
  if "use_var" in w:
@@ -427,6 +440,8 @@ class TaskWidget(QWidget):
427
440
  super().__init__(parent)
428
441
  self._task = Task()
429
442
  self._clipboard: list[Step] = []
443
+ self._breakpoints: set[int] = set()
444
+ self._running_row: int = -1
430
445
 
431
446
  lay = QVBoxLayout(self)
432
447
  top = QHBoxLayout()
@@ -440,30 +455,63 @@ class TaskWidget(QWidget):
440
455
  lay.addLayout(top)
441
456
 
442
457
  self.list = QListWidget()
458
+ self.list.setSelectionMode(QListWidget.ExtendedSelection)
443
459
  self.list.itemDoubleClicked.connect(self._edit_item)
444
460
  lay.addWidget(self.list, 1)
445
461
 
446
462
  row = QHBoxLayout()
447
463
  for text, slot in [("↑", self._move_up), ("↓", self._move_down),
448
464
  ("删除", self._remove), ("复制", self._copy), ("粘贴", self._paste),
449
- ("试跑选中步", self._test_step)]:
465
+ ("断点", self._toggle_breakpoint), ("试跑选中步", self._test_step)]:
450
466
  b = QPushButton(text)
451
467
  b.clicked.connect(slot)
452
468
  row.addWidget(b)
453
469
  lay.addLayout(row)
454
470
 
471
+ for seq, slot in [(QKeySequence.Copy, self._copy), (QKeySequence.Paste, self._paste)]:
472
+ sc = QShortcut(seq, self)
473
+ sc.setContext(Qt.WidgetWithChildrenShortcut)
474
+ sc.activated.connect(slot)
475
+
455
476
  # -- task <-> ui ----
456
477
  def get_task(self) -> Task:
457
478
  return self._task
458
479
 
459
480
  def set_task(self, task: Task):
460
481
  self._task = task
482
+ self._breakpoints.clear() # step indices change with the task
483
+ self._running_row = -1
461
484
  self._refresh()
462
485
 
463
486
  def _refresh(self):
464
487
  self.list.clear()
465
488
  for i, step in enumerate(self._task.steps):
466
- self.list.addItem(QListWidgetItem(f"{i + 1}. {step.display_name()}"))
489
+ mark = "● " if i in self._breakpoints else ""
490
+ item = QListWidgetItem(f"{mark}{i + 1}. {step.display_name()}")
491
+ if i == self._running_row:
492
+ item.setBackground(QColor(60, 90, 40))
493
+ self.list.addItem(item)
494
+
495
+ # -- step debugging ----
496
+ def _toggle_breakpoint(self):
497
+ rows = [self.list.row(it) for it in self.list.selectedItems()] or [self._selected_row()]
498
+ for r in rows:
499
+ if 0 <= r < len(self._task.steps):
500
+ self._breakpoints.discard(r) if r in self._breakpoints else self._breakpoints.add(r)
501
+ self._refresh()
502
+
503
+ def breakpoints(self) -> set[int]:
504
+ return set(self._breakpoints)
505
+
506
+ def set_running_row(self, i: int):
507
+ self._running_row = i
508
+ self._refresh()
509
+ if 0 <= i < self.list.count():
510
+ self.list.setCurrentRow(i)
511
+
512
+ def clear_running_row(self):
513
+ self._running_row = -1
514
+ self._refresh()
467
515
 
468
516
  def get_selected_steps(self) -> list[Step]:
469
517
  rows = sorted(self.list.row(it) for it in self.list.selectedItems())
@@ -487,6 +535,7 @@ class TaskWidget(QWidget):
487
535
  at = row + 1 if row >= 0 else len(self._task.steps)
488
536
  for i, s in enumerate(steps):
489
537
  self._task.steps.insert(at + i, copy.deepcopy(s))
538
+ self._breakpoints.clear() # indices shift
490
539
  self._refresh()
491
540
  self.taskChanged.emit()
492
541
 
@@ -519,9 +568,16 @@ class TaskWidget(QWidget):
519
568
  self.taskChanged.emit()
520
569
 
521
570
  def _remove(self):
522
- row = self._selected_row()
523
- if 0 <= row < len(self._task.steps):
524
- del self._task.steps[row]
571
+ rows = sorted((self.list.row(it) for it in self.list.selectedItems()), reverse=True)
572
+ if not rows:
573
+ rows = [self._selected_row()]
574
+ removed = False
575
+ for row in rows:
576
+ if 0 <= row < len(self._task.steps):
577
+ del self._task.steps[row]
578
+ removed = True
579
+ if removed:
580
+ self._breakpoints.clear() # indices shift
525
581
  self._refresh()
526
582
  self.taskChanged.emit()
527
583
 
@@ -529,6 +585,7 @@ class TaskWidget(QWidget):
529
585
  row = self._selected_row()
530
586
  if row > 0:
531
587
  self._task.steps[row - 1], self._task.steps[row] = self._task.steps[row], self._task.steps[row - 1]
588
+ self._breakpoints.clear()
532
589
  self._refresh()
533
590
  self.list.setCurrentRow(row - 1)
534
591
  self.taskChanged.emit()
@@ -537,14 +594,15 @@ class TaskWidget(QWidget):
537
594
  row = self._selected_row()
538
595
  if 0 <= row < len(self._task.steps) - 1:
539
596
  self._task.steps[row + 1], self._task.steps[row] = self._task.steps[row], self._task.steps[row + 1]
597
+ self._breakpoints.clear()
540
598
  self._refresh()
541
599
  self.list.setCurrentRow(row + 1)
542
600
  self.taskChanged.emit()
543
601
 
544
602
  def _copy(self):
545
- row = self._selected_row()
546
- if 0 <= row < len(self._task.steps):
547
- self._clipboard = [copy.deepcopy(self._task.steps[row])]
603
+ steps = self.get_selected_steps()
604
+ if steps:
605
+ self._clipboard = steps
548
606
 
549
607
  def _test_step(self):
550
608
  step = self.step_at(self._selected_row())
@@ -558,6 +616,7 @@ class TaskWidget(QWidget):
558
616
  insert_at = row + 1 if row >= 0 else len(self._task.steps)
559
617
  for i, s in enumerate(self._clipboard):
560
618
  self._task.steps.insert(insert_at + i, copy.deepcopy(s))
619
+ self._breakpoints.clear() # indices shift
561
620
  self._refresh()
562
621
  self.taskChanged.emit()
563
622
 
@@ -31,6 +31,7 @@ class RunnerCallbacks:
31
31
  def match_found(self, x: int, y: int, confidence: float) -> None: ...
32
32
  def log(self, msg: str) -> None: ...
33
33
  def var_changed(self, name: str, value: str) -> None: ...
34
+ def paused(self, index: int, description: str) -> None: ...
34
35
  def finished(self) -> None: ...
35
36
 
36
37
 
@@ -61,6 +62,11 @@ class TaskRunner(threading.Thread):
61
62
 
62
63
  self._running = False
63
64
  self._paused = False
65
+ # Step-debugging: pause before a step when in step-mode or on a breakpoint;
66
+ # _advance is released by debug_step()/debug_continue().
67
+ self._breakpoints: set[int] = set()
68
+ self._step_mode = False
69
+ self._advance = threading.Event()
64
70
  self._loop_counters: dict[int, int] = {}
65
71
  self._vars: dict[str, str] = {}
66
72
  self._last_condition = False
@@ -85,6 +91,23 @@ class TaskRunner(threading.Thread):
85
91
  def resume(self) -> None:
86
92
  self._paused = False
87
93
 
94
+ # ---- step debugging ----
95
+ def set_breakpoints(self, indices) -> None:
96
+ self._breakpoints = set(indices or ())
97
+
98
+ def enable_step_mode(self, on: bool) -> None:
99
+ self._step_mode = bool(on)
100
+
101
+ def debug_step(self) -> None:
102
+ """Advance one step, then pause again at the next step."""
103
+ self._step_mode = True
104
+ self._advance.set()
105
+
106
+ def debug_continue(self) -> None:
107
+ """Leave step-mode; run until the next breakpoint (or the end)."""
108
+ self._step_mode = False
109
+ self._advance.set()
110
+
88
111
  def get_var(self, name: str) -> str:
89
112
  return self._vars.get(name, "")
90
113
 
@@ -167,6 +190,7 @@ class TaskRunner(threading.Thread):
167
190
 
168
191
  def run(self) -> None:
169
192
  self._running = True
193
+ self._advance.clear()
170
194
  self._loop_counters.clear()
171
195
  self._vars.clear()
172
196
  self._vars.update({str(k): str(v) for k, v in self._initial_vars.items()})
@@ -200,6 +224,15 @@ class TaskRunner(threading.Thread):
200
224
  i = skip
201
225
  continue
202
226
 
227
+ if self._running and (self._step_mode or i in self._breakpoints):
228
+ self.cb.paused(i, log_prefix + step.display_name())
229
+ self._advance.clear()
230
+ while self._running and not self._advance.is_set():
231
+ time.sleep(0.05)
232
+ self._advance.clear()
233
+ if not self._running:
234
+ break
235
+
203
236
  self.cb.step_started(i, log_prefix + step.display_name())
204
237
  self._snap_timeline(i, log_prefix + step.display_name())
205
238
  result = self._execute_with_repeat(i)
@@ -329,7 +362,30 @@ class TaskRunner(threading.Thread):
329
362
  self.cb.log(f" 重试 {step.retry.count} 次后仍失败")
330
363
  return StepResult.Fail
331
364
 
332
- def _compare(self, val: str, op: str, cmp: str) -> bool:
365
+ @staticmethod
366
+ def _compare(val: str, op: str, cmp: str) -> bool:
367
+ # Range ops: cmp is "a,b" or "a~b" (inclusive)
368
+ if op in ("between", "not_between"):
369
+ parts = cmp.replace(",", ",").replace("~", ",").split(",")
370
+ try:
371
+ x = float(val)
372
+ lo, hi = float(parts[0]), float(parts[1])
373
+ except (ValueError, IndexError):
374
+ return False
375
+ if lo > hi:
376
+ lo, hi = hi, lo
377
+ inside = lo <= x <= hi
378
+ return inside if op == "between" else not inside
379
+ # String ops (compare raw strings, no numeric coercion)
380
+ if op == "contains":
381
+ return cmp in val
382
+ if op == "not_contains":
383
+ return cmp not in val
384
+ if op == "startswith":
385
+ return val.startswith(cmp)
386
+ if op == "endswith":
387
+ return val.endswith(cmp)
388
+ # Numeric-first ordering ops, string fallback for ==/!=
333
389
  try:
334
390
  a, b = float(val), float(cmp)
335
391
  except ValueError:
@@ -485,6 +541,13 @@ class TaskRunner(threading.Thread):
485
541
  value = str(int(self._vars.get(step.varName, "0")) + 1)
486
542
  elif value.startswith("--"):
487
543
  value = str(int(self._vars.get(step.varName, "0")) - 1)
544
+ elif value.startswith("="):
545
+ from autoclicker import expr
546
+ try:
547
+ value = expr.safe_eval(value[1:], self._vars)
548
+ except Exception as e: # noqa: BLE001 - bad expr is a recoverable step failure
549
+ self.cb.log(f" 表达式错误: {e}")
550
+ return StepResult.Fail
488
551
  self._vars[step.varName] = value
489
552
  self.cb.var_changed(step.varName, value)
490
553
  self.cb.log(f" 变量 {step.varName} = {value}")
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "PyClickerRPA"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "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."
9
9
  readme = "README.md"
10
10
  license = "MIT"
File without changes
File without changes
File without changes