PyClickerRPA 0.3.0__tar.gz → 0.4.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 (68) hide show
  1. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/PKG-INFO +25 -4
  2. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/PyClickerRPA.egg-info/PKG-INFO +25 -4
  3. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/PyClickerRPA.egg-info/SOURCES.txt +4 -0
  4. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/README.md +23 -2
  5. pyclickerrpa-0.4.0/autoclicker/expr.py +94 -0
  6. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/gui/flow_editor.py +60 -3
  7. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/gui/main_window.py +115 -8
  8. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/gui/qt_runner.py +16 -1
  9. pyclickerrpa-0.4.0/autoclicker/gui/recorder.py +290 -0
  10. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/gui/task_widget.py +86 -18
  11. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/models.py +3 -0
  12. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/runner.py +102 -1
  13. pyclickerrpa-0.4.0/autoclicker/templatize.py +119 -0
  14. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/pyproject.toml +2 -2
  15. pyclickerrpa-0.4.0/tests/test_recorder_gui.py +321 -0
  16. pyclickerrpa-0.4.0/tests/test_templatize.py +133 -0
  17. pyclickerrpa-0.3.0/autoclicker/gui/recorder.py +0 -163
  18. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/LICENSE +0 -0
  19. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/PyClickerRPA.egg-info/dependency_links.txt +0 -0
  20. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/PyClickerRPA.egg-info/entry_points.txt +0 -0
  21. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/PyClickerRPA.egg-info/requires.txt +0 -0
  22. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/PyClickerRPA.egg-info/top_level.txt +0 -0
  23. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/__init__.py +0 -0
  24. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/adb.py +0 -0
  25. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/app.py +0 -0
  26. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/capture.py +0 -0
  27. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/clicker.py +0 -0
  28. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/dpi.py +0 -0
  29. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/gui/__init__.py +0 -0
  30. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/gui/flow_connection.py +0 -0
  31. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/gui/flow_node.py +0 -0
  32. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/gui/hotkeys.py +0 -0
  33. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/gui/monitor.py +0 -0
  34. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/gui/preview_label.py +0 -0
  35. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/gui/scheduler_widget.py +0 -0
  36. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/gui/snippet_library.py +0 -0
  37. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/gui/testkit_widget.py +0 -0
  38. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/matcher.py +0 -0
  39. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/ocr.py +0 -0
  40. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/run.py +0 -0
  41. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/testkit/__init__.py +0 -0
  42. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/testkit/__main__.py +0 -0
  43. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/testkit/assertions.py +0 -0
  44. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/testkit/case.py +0 -0
  45. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/testkit/loader.py +0 -0
  46. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/testkit/pytest_plugin.py +0 -0
  47. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/testkit/report.py +0 -0
  48. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/testkit/runner.py +0 -0
  49. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/testkit/visual.py +0 -0
  50. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/web/__init__.py +0 -0
  51. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/web/__main__.py +0 -0
  52. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/web/run_manager.py +0 -0
  53. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/web/scheduler.py +0 -0
  54. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/web/serialize.py +0 -0
  55. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/web/server.py +0 -0
  56. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/web/static/app.js +0 -0
  57. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/web/static/index.html +0 -0
  58. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/web/static/style.css +0 -0
  59. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/autoclicker/windows.py +0 -0
  60. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/setup.cfg +0 -0
  61. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/tests/test_adb.py +0 -0
  62. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/tests/test_flow_branch.py +0 -0
  63. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/tests/test_matcher.py +0 -0
  64. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/tests/test_models.py +0 -0
  65. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/tests/test_runner.py +0 -0
  66. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/tests/test_testkit.py +0 -0
  67. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/tests/test_testkit_gui.py +0 -0
  68. {pyclickerrpa-0.3.0 → pyclickerrpa-0.4.0}/tests/test_web.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyClickerRPA
3
- Version: 0.3.0
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.
3
+ Version: 0.4.0
4
+ Summary: Visual UI automation & testing tool: Windows + Android(ADB) automation, robust/self-healing image matching, OCR, node flow editor, a smart recorder that records images instead of coordinates, a visual test framework (assertions, visual regression, HTML/JUnit reports) and a web console.
5
5
  Author: Equinox
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/Equinox/PyClickerRPA
@@ -82,7 +82,7 @@ autoclicker-web --suites-dir examples # web console at http://127.0.0.1
82
82
  - **Smart wait**: `wait_stable` waits until the screen (or an ROI) stops
83
83
  changing — replaces guessed `sleep`s (fast when ready, patient when slow).
84
84
  - **~30+ step types**: click image/text/position, click-all, relative/offset
85
- clicks, wait / random / wait-stable, drag / long-press / pinch / scroll,
85
+ clicks, wait / random / wait-stable, drag / **drag-from-image** / long-press / pinch / scroll,
86
86
  key / type / hotkey, loop / goto / while / do-until, if image/text/color/
87
87
  count/var with **true/false branching** (if-else), set-var, read-text→variable,
88
88
  activate-window, HTTP request, run-Python-script, call sub-task, groups, and a
@@ -93,12 +93,33 @@ autoclicker-web --suites-dir examples # web console at http://127.0.0.1
93
93
  Drag-and-drop **node flow graph** (condition nodes have **true/false branch
94
94
  ports** for visual if-else; a terminal **stop** node ends the flow) and an
95
95
  interoperable **step list** with a full
96
- per-step dialog; an action **recorder** (global hooks), snippet library, task
96
+ per-step dialog; snippet library, task
97
97
  packages (`.zip` with templates), on-preview coordinate/color picking, template
98
98
  crop, single-step test run; global hotkeys (F6 run/stop, ESC stop), scheduler,
99
99
  and an image/color **monitor/trigger** with tray icon; a **Test** tab to load and
100
100
  run visual test suites.
101
101
 
102
+ ### Smart recorder
103
+
104
+ The action **recorder** (global hooks) doesn't just replay coordinates and
105
+ sleeps — with **智能录制 / smart mode** on (default) it records what things *look
106
+ like* instead of where they *were*:
107
+
108
+ - **Clicks become image steps.** A patch is cropped around the click and grown
109
+ (48 → 192 px) until it is both textured enough and **unique on screen**, then
110
+ emitted as a `click_image` step — so the flow still works after the window
111
+ moves or the resolution changes. Identical patches are de-duplicated by
112
+ content hash.
113
+ - **Drags become `drag_image`.** The drag's *start* is anchored to an image and
114
+ the *displacement* is stored, so start and end move together.
115
+ - **Pauses become `wait_stable`.** A long pause is only converted if the
116
+ recorder actually **observed the screen settle** during it; on a permanently
117
+ animating screen it keeps the fixed wait (a `wait_stable` that never settles
118
+ would fail the run).
119
+
120
+ Anything that can't be made robust falls back to the plain coordinate/fixed-wait
121
+ step and says why in the log.
122
+
102
123
  ## Visual test framework (`autoclicker.testkit`)
103
124
 
104
125
  Turn automation into tests:
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyClickerRPA
3
- Version: 0.3.0
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.
3
+ Version: 0.4.0
4
+ Summary: Visual UI automation & testing tool: Windows + Android(ADB) automation, robust/self-healing image matching, OCR, node flow editor, a smart recorder that records images instead of coordinates, a visual test framework (assertions, visual regression, HTML/JUnit reports) and a web console.
5
5
  Author: Equinox
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/Equinox/PyClickerRPA
@@ -82,7 +82,7 @@ autoclicker-web --suites-dir examples # web console at http://127.0.0.1
82
82
  - **Smart wait**: `wait_stable` waits until the screen (or an ROI) stops
83
83
  changing — replaces guessed `sleep`s (fast when ready, patient when slow).
84
84
  - **~30+ step types**: click image/text/position, click-all, relative/offset
85
- clicks, wait / random / wait-stable, drag / long-press / pinch / scroll,
85
+ clicks, wait / random / wait-stable, drag / **drag-from-image** / long-press / pinch / scroll,
86
86
  key / type / hotkey, loop / goto / while / do-until, if image/text/color/
87
87
  count/var with **true/false branching** (if-else), set-var, read-text→variable,
88
88
  activate-window, HTTP request, run-Python-script, call sub-task, groups, and a
@@ -93,12 +93,33 @@ autoclicker-web --suites-dir examples # web console at http://127.0.0.1
93
93
  Drag-and-drop **node flow graph** (condition nodes have **true/false branch
94
94
  ports** for visual if-else; a terminal **stop** node ends the flow) and an
95
95
  interoperable **step list** with a full
96
- per-step dialog; an action **recorder** (global hooks), snippet library, task
96
+ per-step dialog; snippet library, task
97
97
  packages (`.zip` with templates), on-preview coordinate/color picking, template
98
98
  crop, single-step test run; global hotkeys (F6 run/stop, ESC stop), scheduler,
99
99
  and an image/color **monitor/trigger** with tray icon; a **Test** tab to load and
100
100
  run visual test suites.
101
101
 
102
+ ### Smart recorder
103
+
104
+ The action **recorder** (global hooks) doesn't just replay coordinates and
105
+ sleeps — with **智能录制 / smart mode** on (default) it records what things *look
106
+ like* instead of where they *were*:
107
+
108
+ - **Clicks become image steps.** A patch is cropped around the click and grown
109
+ (48 → 192 px) until it is both textured enough and **unique on screen**, then
110
+ emitted as a `click_image` step — so the flow still works after the window
111
+ moves or the resolution changes. Identical patches are de-duplicated by
112
+ content hash.
113
+ - **Drags become `drag_image`.** The drag's *start* is anchored to an image and
114
+ the *displacement* is stored, so start and end move together.
115
+ - **Pauses become `wait_stable`.** A long pause is only converted if the
116
+ recorder actually **observed the screen settle** during it; on a permanently
117
+ animating screen it keeps the fixed wait (a `wait_stable` that never settles
118
+ would fail the run).
119
+
120
+ Anything that can't be made robust falls back to the plain coordinate/fixed-wait
121
+ step and says why in the log.
122
+
102
123
  ## Visual test framework (`autoclicker.testkit`)
103
124
 
104
125
  Turn automation into tests:
@@ -13,11 +13,13 @@ 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
19
20
  autoclicker/run.py
20
21
  autoclicker/runner.py
22
+ autoclicker/templatize.py
21
23
  autoclicker/windows.py
22
24
  autoclicker/gui/__init__.py
23
25
  autoclicker/gui/flow_connection.py
@@ -55,7 +57,9 @@ tests/test_adb.py
55
57
  tests/test_flow_branch.py
56
58
  tests/test_matcher.py
57
59
  tests/test_models.py
60
+ tests/test_recorder_gui.py
58
61
  tests/test_runner.py
62
+ tests/test_templatize.py
59
63
  tests/test_testkit.py
60
64
  tests/test_testkit_gui.py
61
65
  tests/test_web.py
@@ -44,7 +44,7 @@ autoclicker-web --suites-dir examples # web console at http://127.0.0.1
44
44
  - **Smart wait**: `wait_stable` waits until the screen (or an ROI) stops
45
45
  changing — replaces guessed `sleep`s (fast when ready, patient when slow).
46
46
  - **~30+ step types**: click image/text/position, click-all, relative/offset
47
- clicks, wait / random / wait-stable, drag / long-press / pinch / scroll,
47
+ clicks, wait / random / wait-stable, drag / **drag-from-image** / long-press / pinch / scroll,
48
48
  key / type / hotkey, loop / goto / while / do-until, if image/text/color/
49
49
  count/var with **true/false branching** (if-else), set-var, read-text→variable,
50
50
  activate-window, HTTP request, run-Python-script, call sub-task, groups, and a
@@ -55,12 +55,33 @@ autoclicker-web --suites-dir examples # web console at http://127.0.0.1
55
55
  Drag-and-drop **node flow graph** (condition nodes have **true/false branch
56
56
  ports** for visual if-else; a terminal **stop** node ends the flow) and an
57
57
  interoperable **step list** with a full
58
- per-step dialog; an action **recorder** (global hooks), snippet library, task
58
+ per-step dialog; snippet library, task
59
59
  packages (`.zip` with templates), on-preview coordinate/color picking, template
60
60
  crop, single-step test run; global hotkeys (F6 run/stop, ESC stop), scheduler,
61
61
  and an image/color **monitor/trigger** with tray icon; a **Test** tab to load and
62
62
  run visual test suites.
63
63
 
64
+ ### Smart recorder
65
+
66
+ The action **recorder** (global hooks) doesn't just replay coordinates and
67
+ sleeps — with **智能录制 / smart mode** on (default) it records what things *look
68
+ like* instead of where they *were*:
69
+
70
+ - **Clicks become image steps.** A patch is cropped around the click and grown
71
+ (48 → 192 px) until it is both textured enough and **unique on screen**, then
72
+ emitted as a `click_image` step — so the flow still works after the window
73
+ moves or the resolution changes. Identical patches are de-duplicated by
74
+ content hash.
75
+ - **Drags become `drag_image`.** The drag's *start* is anchored to an image and
76
+ the *displacement* is stored, so start and end move together.
77
+ - **Pauses become `wait_stable`.** A long pause is only converted if the
78
+ recorder actually **observed the screen settle** during it; on a permanently
79
+ animating screen it keeps the fixed wait (a `wait_stable` that never settles
80
+ would fail the run).
81
+
82
+ Anything that can't be made robust falls back to the plain coordinate/fixed-wait
83
+ step and says why in the log.
84
+
64
85
  ## Visual test framework (`autoclicker.testkit`)
65
86
 
66
87
  Turn automation into tests:
@@ -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("添加节点")
@@ -12,10 +12,10 @@ import tempfile
12
12
  import uuid
13
13
  import zipfile
14
14
 
15
- from PySide6.QtCore import Qt, QThread, Signal
15
+ from PySide6.QtCore import Qt, QSettings, QThread, Signal
16
16
  from PySide6.QtGui import QAction, QGuiApplication, QKeySequence
17
17
  from PySide6.QtWidgets import (
18
- QComboBox, QDockWidget, QFileDialog, QHBoxLayout, QInputDialog, QLabel,
18
+ QCheckBox, QComboBox, QDockWidget, QFileDialog, QHBoxLayout, QInputDialog, QLabel,
19
19
  QMainWindow, QMessageBox, QPushButton, QSplitter, QTableWidget,
20
20
  QTableWidgetItem, QTabWidget, QTextEdit, QVBoxLayout, QWidget,
21
21
  )
@@ -67,9 +67,13 @@ 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)
71
+
72
+ self._settings = QSettings("AutoClicker", "AutoClicker")
70
73
 
71
74
  self._recorder = Recorder(self)
72
75
  self._recorder.stepRecorded.connect(self._on_step_recorded)
76
+ self._recorder.note.connect(self._append_log)
73
77
 
74
78
  self._build_ui()
75
79
  self._build_menu()
@@ -228,6 +232,16 @@ class MainWindow(QMainWindow):
228
232
  capture_btn.clicked.connect(self.capture)
229
233
  self.record_btn = QPushButton("● 录制")
230
234
  self.record_btn.clicked.connect(self.toggle_record)
235
+ self.template_mode_chk = QCheckBox("智能录制")
236
+ self.template_mode_chk.setToolTip(
237
+ "录出来的任务尽量不依赖绝对坐标和绝对时间:\n"
238
+ "· 点击处裁一小块图当模板 → 位置无关的「找图点击」\n"
239
+ "· 长间隔改成「等画面稳定」→ 换台机器也不会等不够\n"
240
+ "抽不出好模板、或录制时画面一直在动,则各自退回坐标点击/固定等待。")
241
+ self.template_mode_chk.setChecked(
242
+ self._settings.value("recorder/templateMode", True, type=bool))
243
+ self.template_mode_chk.toggled.connect(
244
+ lambda on: self._settings.setValue("recorder/templateMode", on))
231
245
  self.run_btn = QPushButton("▶ 运行")
232
246
  self.run_btn.clicked.connect(self.run_task)
233
247
  self.pause_btn = QPushButton("⏸ 暂停")
@@ -236,15 +250,27 @@ class MainWindow(QMainWindow):
236
250
  self.stop_btn = QPushButton("■ 停止")
237
251
  self.stop_btn.clicked.connect(self.stop_task)
238
252
  self.stop_btn.setEnabled(False)
253
+ self.debug_btn = QPushButton("🐞 调试")
254
+ self.debug_btn.clicked.connect(self.debug_run)
255
+ self.step_btn = QPushButton("单步")
256
+ self.step_btn.clicked.connect(lambda: self._runner.debug_step())
257
+ self.step_btn.setEnabled(False)
258
+ self.continue_btn = QPushButton("继续")
259
+ self.continue_btn.clicked.connect(lambda: self._runner.debug_continue())
260
+ self.continue_btn.setEnabled(False)
239
261
  bar.addWidget(QLabel("目标窗口"))
240
262
  bar.addWidget(self.window_combo, 1)
241
263
  bar.addWidget(refresh_btn)
242
264
  bar.addWidget(capture_btn)
243
265
  bar.addStretch()
266
+ bar.addWidget(self.template_mode_chk)
244
267
  bar.addWidget(self.record_btn)
245
268
  bar.addWidget(self.run_btn)
246
269
  bar.addWidget(self.pause_btn)
247
270
  bar.addWidget(self.stop_btn)
271
+ bar.addWidget(self.debug_btn)
272
+ bar.addWidget(self.step_btn)
273
+ bar.addWidget(self.continue_btn)
248
274
  root.addLayout(bar)
249
275
 
250
276
  splitter = QSplitter(Qt.Horizontal)
@@ -327,6 +353,9 @@ class MainWindow(QMainWindow):
327
353
  e = self.menuBar().addMenu("编辑")
328
354
  e.addAction(QAction("选中步骤存为片段…", self, triggered=self.save_snippet))
329
355
  e.addSeparator()
356
+ e.addAction(QAction("导出选中步骤为文件(.json)…", self, triggered=self.export_steps_file))
357
+ e.addAction(QAction("从文件导入步骤(.json)…", self, triggered=self.import_steps_file))
358
+ e.addSeparator()
330
359
  e.addAction(QAction("复制步骤到剪贴板(JSON)", self, triggered=self.copy_steps_json))
331
360
  e.addAction(QAction("从剪贴板粘贴步骤(JSON)", self, triggered=self.paste_steps_json))
332
361
 
@@ -402,6 +431,29 @@ class MainWindow(QMainWindow):
402
431
  self._runner.start(task, target_hwnd=self._target_hwnd(),
403
432
  screenshot_on_fail=True, screenshot_dir=".failures")
404
433
 
434
+ def debug_run(self):
435
+ if self._runner.is_running():
436
+ return
437
+ self.tabs.setCurrentWidget(self.task_widget)
438
+ task = self._current_task()
439
+ if not task.steps:
440
+ self._append_log("任务为空")
441
+ return
442
+ self.log.clear()
443
+ self._clear_vars()
444
+ self.preview.clear_markers()
445
+ self._set_running(True)
446
+ self._append_log("调试模式:停在第 1 步,用「单步」/「继续」推进")
447
+ self._runner.start(task, target_hwnd=self._target_hwnd(),
448
+ screenshot_on_fail=True, screenshot_dir=".failures",
449
+ breakpoints=self.task_widget.breakpoints(), step_mode=True)
450
+
451
+ def _on_paused(self, index: int, desc: str):
452
+ self._append_log(f"⏸ 暂停于 [{index + 1}] {desc}")
453
+ self.task_widget.set_running_row(index)
454
+ self.step_btn.setEnabled(True)
455
+ self.continue_btn.setEnabled(True)
456
+
405
457
  def stop_task(self):
406
458
  self._runner.stop()
407
459
 
@@ -415,16 +467,20 @@ class MainWindow(QMainWindow):
415
467
 
416
468
  def _set_running(self, running: bool):
417
469
  self.run_btn.setEnabled(not running)
470
+ self.debug_btn.setEnabled(not running)
418
471
  self.stop_btn.setEnabled(running)
419
472
  self.pause_btn.setEnabled(running)
420
473
  if not running:
421
474
  self.pause_btn.setText("⏸ 暂停")
475
+ self.step_btn.setEnabled(False)
476
+ self.continue_btn.setEnabled(False)
422
477
 
423
478
  def _on_step_started(self, index: int, desc: str):
424
479
  self._append_log(f"[{index + 1}] {desc}")
425
480
 
426
481
  def _on_finished(self):
427
482
  self._set_running(False)
483
+ self.task_widget.clear_running_row()
428
484
 
429
485
  def _append_log(self, msg: str):
430
486
  self.log.append(msg)
@@ -471,10 +527,12 @@ class MainWindow(QMainWindow):
471
527
  self._append_log("录制结束")
472
528
  return
473
529
  hwnd = self._target_hwnd()
474
- self._recorder.start(hwnd)
530
+ tmpl = self.template_mode_chk.isChecked()
531
+ self._recorder.start(hwnd, template_mode=tmpl)
475
532
  self.record_btn.setText("■ 停止录制")
476
533
  self.tabs.setCurrentWidget(self.task_widget)
477
- self._append_log("开始录制(在目标窗口内操作;再次点击结束)")
534
+ self._append_log("开始录制(在目标窗口内操作;再次点击结束)"
535
+ + ("[智能录制]" if tmpl else ""))
478
536
 
479
537
  def _on_step_recorded(self, step: Step):
480
538
  self.task_widget.append_step(step)
@@ -503,10 +561,16 @@ class MainWindow(QMainWindow):
503
561
  self.tabs.setCurrentWidget(self.task_widget)
504
562
  self._append_log(f"插入片段: {len(steps)} 步")
505
563
 
564
+ def _selected_steps(self) -> list[Step]:
565
+ """Selected steps from whichever editor tab is active."""
566
+ if self.tabs.currentWidget() is self.flow_editor:
567
+ return self.flow_editor.get_selected_steps()
568
+ return self.task_widget.get_selected_steps()
569
+
506
570
  def save_snippet(self):
507
- steps = self.task_widget.get_selected_steps()
571
+ steps = self._selected_steps()
508
572
  if not steps:
509
- self._append_log("请先在「步骤列表」中选中要保存的步骤")
573
+ self._append_log("请先在「步骤列表」或「流程图」中选中要保存的步骤")
510
574
  return
511
575
  name, ok = QInputDialog.getText(self, "存为片段", "片段名称:")
512
576
  if ok and name:
@@ -516,13 +580,56 @@ class MainWindow(QMainWindow):
516
580
  # ---- step clipboard (JSON) ----
517
581
  def copy_steps_json(self):
518
582
  import json
519
- steps = self.task_widget.get_selected_steps()
583
+ steps = self._selected_steps()
520
584
  if not steps:
521
- self._append_log("请先在「步骤列表」中选中要复制的步骤")
585
+ self._append_log("请先在「步骤列表」或「流程图」中选中要复制的步骤")
522
586
  return
523
587
  QGuiApplication.clipboard().setText(json.dumps([s.to_json() for s in steps], ensure_ascii=False, indent=2))
524
588
  self._append_log(f"已复制 {len(steps)} 步到剪贴板(JSON)")
525
589
 
590
+ def export_steps_file(self):
591
+ import json
592
+ steps = self._selected_steps()
593
+ if not steps:
594
+ self._append_log("请先在「步骤列表」或「流程图」中选中要导出的步骤")
595
+ return
596
+ path, _ = QFileDialog.getSaveFileName(self, "导出选中步骤", "", "步骤文件 (*.json)")
597
+ if not path:
598
+ return
599
+ if not path.lower().endswith(".json"):
600
+ path += ".json"
601
+ try:
602
+ with open(path, "w", encoding="utf-8") as f:
603
+ json.dump([s.to_json() for s in steps], f, ensure_ascii=False, indent=4)
604
+ except OSError as ex:
605
+ QMessageBox.warning(self, "导出失败", str(ex))
606
+ return
607
+ self._append_log(f"已导出 {len(steps)} 步到: {path}")
608
+
609
+ def import_steps_file(self):
610
+ import json
611
+ path, _ = QFileDialog.getOpenFileName(self, "导入步骤", "", "步骤文件 (*.json)")
612
+ if not path:
613
+ return
614
+ try:
615
+ with open(path, "r", encoding="utf-8") as f:
616
+ data = json.load(f)
617
+ except (OSError, json.JSONDecodeError) as ex:
618
+ QMessageBox.warning(self, "导入失败", str(ex))
619
+ return
620
+ if isinstance(data, dict) and "steps" in data: # accept a full task file too
621
+ data = data["steps"]
622
+ if not isinstance(data, list):
623
+ QMessageBox.warning(self, "导入失败", "文件内容既不是步骤数组也不是任务")
624
+ return
625
+ steps = [Step.from_json(o) for o in data]
626
+ if not steps:
627
+ self._append_log("文件里没有步骤")
628
+ return
629
+ self.task_widget.insert_steps(steps)
630
+ self.tabs.setCurrentWidget(self.task_widget)
631
+ self._append_log(f"已从文件导入 {len(steps)} 步: {path}")
632
+
526
633
  def paste_steps_json(self):
527
634
  import json
528
635
  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()