PyClickerRPA 0.3.1__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.1 → pyclickerrpa-0.4.0}/PKG-INFO +25 -4
  2. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/PyClickerRPA.egg-info/PKG-INFO +25 -4
  3. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/PyClickerRPA.egg-info/SOURCES.txt +3 -0
  4. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/README.md +23 -2
  5. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/gui/main_window.py +20 -4
  6. pyclickerrpa-0.4.0/autoclicker/gui/recorder.py +290 -0
  7. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/gui/task_widget.py +13 -4
  8. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/models.py +3 -0
  9. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/runner.py +38 -0
  10. pyclickerrpa-0.4.0/autoclicker/templatize.py +119 -0
  11. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/pyproject.toml +2 -2
  12. pyclickerrpa-0.4.0/tests/test_recorder_gui.py +321 -0
  13. pyclickerrpa-0.4.0/tests/test_templatize.py +133 -0
  14. pyclickerrpa-0.3.1/autoclicker/gui/recorder.py +0 -163
  15. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/LICENSE +0 -0
  16. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/PyClickerRPA.egg-info/dependency_links.txt +0 -0
  17. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/PyClickerRPA.egg-info/entry_points.txt +0 -0
  18. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/PyClickerRPA.egg-info/requires.txt +0 -0
  19. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/PyClickerRPA.egg-info/top_level.txt +0 -0
  20. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/__init__.py +0 -0
  21. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/adb.py +0 -0
  22. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/app.py +0 -0
  23. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/capture.py +0 -0
  24. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/clicker.py +0 -0
  25. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/dpi.py +0 -0
  26. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/expr.py +0 -0
  27. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/gui/__init__.py +0 -0
  28. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/gui/flow_connection.py +0 -0
  29. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/gui/flow_editor.py +0 -0
  30. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/gui/flow_node.py +0 -0
  31. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/gui/hotkeys.py +0 -0
  32. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/gui/monitor.py +0 -0
  33. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/gui/preview_label.py +0 -0
  34. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/gui/qt_runner.py +0 -0
  35. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/gui/scheduler_widget.py +0 -0
  36. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/gui/snippet_library.py +0 -0
  37. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/gui/testkit_widget.py +0 -0
  38. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/matcher.py +0 -0
  39. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/ocr.py +0 -0
  40. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/run.py +0 -0
  41. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/testkit/__init__.py +0 -0
  42. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/testkit/__main__.py +0 -0
  43. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/testkit/assertions.py +0 -0
  44. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/testkit/case.py +0 -0
  45. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/testkit/loader.py +0 -0
  46. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/testkit/pytest_plugin.py +0 -0
  47. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/testkit/report.py +0 -0
  48. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/testkit/runner.py +0 -0
  49. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/testkit/visual.py +0 -0
  50. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/web/__init__.py +0 -0
  51. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/web/__main__.py +0 -0
  52. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/web/run_manager.py +0 -0
  53. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/web/scheduler.py +0 -0
  54. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/web/serialize.py +0 -0
  55. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/web/server.py +0 -0
  56. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/web/static/app.js +0 -0
  57. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/web/static/index.html +0 -0
  58. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/web/static/style.css +0 -0
  59. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/autoclicker/windows.py +0 -0
  60. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/setup.cfg +0 -0
  61. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/tests/test_adb.py +0 -0
  62. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/tests/test_flow_branch.py +0 -0
  63. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/tests/test_matcher.py +0 -0
  64. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/tests/test_models.py +0 -0
  65. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/tests/test_runner.py +0 -0
  66. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/tests/test_testkit.py +0 -0
  67. {pyclickerrpa-0.3.1 → pyclickerrpa-0.4.0}/tests/test_testkit_gui.py +0 -0
  68. {pyclickerrpa-0.3.1 → 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.1
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.1
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:
@@ -19,6 +19,7 @@ autoclicker/models.py
19
19
  autoclicker/ocr.py
20
20
  autoclicker/run.py
21
21
  autoclicker/runner.py
22
+ autoclicker/templatize.py
22
23
  autoclicker/windows.py
23
24
  autoclicker/gui/__init__.py
24
25
  autoclicker/gui/flow_connection.py
@@ -56,7 +57,9 @@ tests/test_adb.py
56
57
  tests/test_flow_branch.py
57
58
  tests/test_matcher.py
58
59
  tests/test_models.py
60
+ tests/test_recorder_gui.py
59
61
  tests/test_runner.py
62
+ tests/test_templatize.py
60
63
  tests/test_testkit.py
61
64
  tests/test_testkit_gui.py
62
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:
@@ -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
  )
@@ -69,8 +69,11 @@ class MainWindow(QMainWindow):
69
69
  self._runner.runFinished.connect(self._on_finished)
70
70
  self._runner.pausedAt.connect(self._on_paused)
71
71
 
72
+ self._settings = QSettings("AutoClicker", "AutoClicker")
73
+
72
74
  self._recorder = Recorder(self)
73
75
  self._recorder.stepRecorded.connect(self._on_step_recorded)
76
+ self._recorder.note.connect(self._append_log)
74
77
 
75
78
  self._build_ui()
76
79
  self._build_menu()
@@ -229,6 +232,16 @@ class MainWindow(QMainWindow):
229
232
  capture_btn.clicked.connect(self.capture)
230
233
  self.record_btn = QPushButton("● 录制")
231
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))
232
245
  self.run_btn = QPushButton("▶ 运行")
233
246
  self.run_btn.clicked.connect(self.run_task)
234
247
  self.pause_btn = QPushButton("⏸ 暂停")
@@ -250,6 +263,7 @@ class MainWindow(QMainWindow):
250
263
  bar.addWidget(refresh_btn)
251
264
  bar.addWidget(capture_btn)
252
265
  bar.addStretch()
266
+ bar.addWidget(self.template_mode_chk)
253
267
  bar.addWidget(self.record_btn)
254
268
  bar.addWidget(self.run_btn)
255
269
  bar.addWidget(self.pause_btn)
@@ -513,10 +527,12 @@ class MainWindow(QMainWindow):
513
527
  self._append_log("录制结束")
514
528
  return
515
529
  hwnd = self._target_hwnd()
516
- self._recorder.start(hwnd)
530
+ tmpl = self.template_mode_chk.isChecked()
531
+ self._recorder.start(hwnd, template_mode=tmpl)
517
532
  self.record_btn.setText("■ 停止录制")
518
533
  self.tabs.setCurrentWidget(self.task_widget)
519
- self._append_log("开始录制(在目标窗口内操作;再次点击结束)")
534
+ self._append_log("开始录制(在目标窗口内操作;再次点击结束)"
535
+ + ("[智能录制]" if tmpl else ""))
520
536
 
521
537
  def _on_step_recorded(self, step: Step):
522
538
  self.task_widget.append_step(step)
@@ -0,0 +1,290 @@
1
+ """Action recorder — port of recorder.cpp using pynput global hooks.
2
+
3
+ Records left clicks / drags (client-relative to the target window) and key
4
+ presses into Steps, inserting Wait steps for gaps >= 150ms. Emits stepRecorded
5
+ from the listener thread; the signal is delivered to the GUI thread by Qt.
6
+
7
+ In *template mode* a click additionally gets a small image patch cropped around
8
+ it and becomes a position-independent ClickImage step (see `templatize`); when no
9
+ usable template can be cut it falls back to the plain ClickPosition step.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ import time
15
+
16
+ from PySide6.QtCore import QObject, QTimer, Signal
17
+
18
+ from autoclicker.models import Step, StepType
19
+
20
+ try:
21
+ import win32gui
22
+ _HAS_WIN32 = True
23
+ except ImportError:
24
+ _HAS_WIN32 = False
25
+
26
+ from pynput import mouse, keyboard
27
+
28
+ # pynput special key -> our key name (mirrors Recorder::vkToName specials)
29
+ _SPECIAL_KEYS = {
30
+ keyboard.Key.enter: "ENTER", keyboard.Key.esc: "ESC", keyboard.Key.tab: "TAB",
31
+ keyboard.Key.space: "SPACE", keyboard.Key.backspace: "BACKSPACE",
32
+ keyboard.Key.delete: "DELETE", keyboard.Key.up: "UP", keyboard.Key.down: "DOWN",
33
+ keyboard.Key.left: "LEFT", keyboard.Key.right: "RIGHT", keyboard.Key.home: "HOME",
34
+ keyboard.Key.end: "END", keyboard.Key.page_up: "PAGEUP", keyboard.Key.page_down: "PAGEDOWN",
35
+ }
36
+ _MODIFIERS = {
37
+ keyboard.Key.ctrl, keyboard.Key.ctrl_l, keyboard.Key.ctrl_r,
38
+ keyboard.Key.shift, keyboard.Key.shift_l, keyboard.Key.shift_r,
39
+ keyboard.Key.alt, keyboard.Key.alt_l, keyboard.Key.alt_r, keyboard.Key.alt_gr,
40
+ keyboard.Key.cmd, keyboard.Key.cmd_l, keyboard.Key.cmd_r,
41
+ }
42
+
43
+
44
+ class Recorder(QObject):
45
+ stepRecorded = Signal(object) # emits a Step
46
+ recordingStopped = Signal()
47
+ note = Signal(str) # 图像模式的取模板结果/回退原因,接到主窗口日志
48
+
49
+ # 抓帧间隔。只是为了在点击时手里有一张"刚刚"的画面,不需要很快
50
+ GRAB_INTERVAL_MS = 120
51
+
52
+ # 智能等待:只有间隔够长(人是真在等界面)、且录制时**观察到画面确实静下来过**,
53
+ # 才把固定 Wait 换成 WaitStable。有持续动画的画面永远稳不下来,
54
+ # 那种地方换成 WaitStable 会超时 Fail,比固定等待更糟。
55
+ STABLE_TOL = 0.005 # 和 runner._exec_wait_stable 的默认容差一致
56
+ STABLE_MIN_GAP_MS = 700 # 短于此的间隔只是手速,不是在等界面
57
+ STABLE_MIN_QUIET_MS = 250 # 至少观察到这么久的静止,才敢认为它能稳
58
+ STABLE_MAX_MS = 500 # 运行时要求的稳定时长上限(= Step.stableMs 默认值)
59
+
60
+ def __init__(self, parent=None):
61
+ super().__init__(parent)
62
+ self._hwnd = None
63
+ self._recording = False
64
+ self._mouse_listener = None
65
+ self._key_listener = None
66
+ self._t0 = 0.0
67
+ self._last_event = None
68
+ self._down = False
69
+ self._down_pos = (0, 0)
70
+ self._down_time = 0.0
71
+ self._template_mode = False
72
+ self._grab = None
73
+ self._frame = None # 最近抓到的一帧
74
+ self._down_frame = None # 按下那一刻的帧(点击前的画面)
75
+ self._quiet_since = None # 画面连续静止的起点(elapsed ms),动了就清空
76
+ self._timer = QTimer(self)
77
+ self._timer.timeout.connect(self._grab_frame)
78
+
79
+ def is_recording(self) -> bool:
80
+ return self._recording
81
+
82
+ def start(self, hwnd, *, template_mode: bool = False, grab=None):
83
+ """grab 可注入(默认抓目标窗口客户区/全屏),为的是离屏测试能塞假帧。"""
84
+ if self._recording:
85
+ return
86
+ self._hwnd = hwnd
87
+ self._recording = True
88
+ self._template_mode = template_mode
89
+ self._grab = grab or self._default_grab
90
+ self._frame = None
91
+ self._down_frame = None
92
+ self._quiet_since = None
93
+ self._t0 = time.monotonic()
94
+ self._last_event = None
95
+ self._down = False
96
+ self._mouse_listener = mouse.Listener(on_click=self._on_click)
97
+ self._key_listener = keyboard.Listener(on_press=self._on_press)
98
+ self._mouse_listener.start()
99
+ self._key_listener.start()
100
+ if template_mode:
101
+ self._grab_frame() # 先垫一帧,别让第一次点击没图可用
102
+ self._timer.start(self.GRAB_INTERVAL_MS)
103
+
104
+ def stop(self):
105
+ if not self._recording:
106
+ return
107
+ self._recording = False
108
+ self._timer.stop()
109
+ if self._mouse_listener:
110
+ self._mouse_listener.stop()
111
+ self._mouse_listener = None
112
+ if self._key_listener:
113
+ self._key_listener.stop()
114
+ self._key_listener = None
115
+ self._frame = None
116
+ self._down_frame = None
117
+ self.recordingStopped.emit()
118
+
119
+ # ---- 抓帧(跑在 Qt 线程的 QTimer 上) ----
120
+ def _default_grab(self):
121
+ from autoclicker import capture
122
+ return capture.capture_window(self._hwnd) if self._hwnd else capture.capture_screen()
123
+
124
+ def _grab_frame(self):
125
+ """故意不在 pynput 回调里抓图:回调跑在低级钩子线程上,PrintWindow 一次要几十毫秒,
126
+ 阻塞太久 Windows 会直接把钩子摘掉,录制就莫名失灵了。"""
127
+ try:
128
+ frame = self._grab()
129
+ except Exception as e:
130
+ self._frame = None
131
+ self._quiet_since = None
132
+ self.note.emit(f"图像模式抓屏失败: {e}")
133
+ return
134
+ frame = frame if frame is not None and frame.size else None
135
+ prev, self._frame = self._frame, frame
136
+ if frame is None:
137
+ self._quiet_since = None
138
+ return
139
+ # 顺手记录画面有没有静下来 —— 这是后面决定能不能用 WaitStable 的依据
140
+ from autoclicker import matcher
141
+ if prev is None or matcher.diff_ratio(prev, frame) > self.STABLE_TOL:
142
+ self._quiet_since = None
143
+ elif self._quiet_since is None:
144
+ self._quiet_since = self._elapsed_ms()
145
+
146
+ def _click_step(self, cx: int, cy: int) -> Step:
147
+ """图像模式下尽量产出位置无关的 ClickImage,抽不出好模板就退回坐标点击。"""
148
+ if not self._template_mode:
149
+ return Step(type=StepType.ClickPosition, x=cx, y=cy)
150
+ frame = self._down_frame
151
+ if frame is None:
152
+ self.note.emit(f"({cx},{cy}) 没抓到画面 → 退回坐标点击")
153
+ return Step(type=StepType.ClickPosition, x=cx, y=cy)
154
+ from autoclicker import templatize
155
+ try:
156
+ choice = templatize.pick_template(frame, cx, cy)
157
+ if choice is None:
158
+ self.note.emit(f"({cx},{cy}) 该处不够独特(太平淡或全屏有多处相同) → "
159
+ f"退回坐标点击;可事后用「裁剪模板」手工指定")
160
+ return Step(type=StepType.ClickPosition, x=cx, y=cy)
161
+ path = templatize.save_template(choice.patch)
162
+ step = templatize.build_click_image_step(path, choice.off_x, choice.off_y)
163
+ except Exception as e:
164
+ self.note.emit(f"({cx},{cy}) 取模板失败({e}) → 退回坐标点击")
165
+ return Step(type=StepType.ClickPosition, x=cx, y=cy)
166
+ self.note.emit(f"({cx},{cy}) 已取模板 {choice.size}px 纹理{choice.score:.0f} → {path}")
167
+ return step
168
+
169
+ def _drag_step(self, cx: int, cy: int, duration_ms: int) -> Step:
170
+ """智能录制下把拖拽的**起点**图像化(DragImage 存的是位移,所以终点跟着起点走);
171
+ 起点抽不出模板就退回绝对坐标的 Drag。"""
172
+ sx, sy = self._down_pos
173
+ plain = Step(type=StepType.Drag, x=sx, y=sy, x2=cx, y2=cy, durationMs=duration_ms)
174
+ if not self._template_mode or self._down_frame is None:
175
+ return plain
176
+ from autoclicker import templatize
177
+ try:
178
+ choice = templatize.pick_template(self._down_frame, sx, sy)
179
+ if choice is None:
180
+ self.note.emit(f"拖拽起点({sx},{sy})不够独特 → 退回坐标拖拽")
181
+ return plain
182
+ path = templatize.save_template(choice.patch)
183
+ except Exception as e:
184
+ self.note.emit(f"拖拽起点取模板失败({e}) → 退回坐标拖拽")
185
+ return plain
186
+ self.note.emit(f"拖拽起点({sx},{sy}) 已取模板 {choice.size}px → 位移({cx - sx},{cy - sy})")
187
+ return Step(type=StepType.DragImage, imagePath=path,
188
+ x=choice.off_x, y=choice.off_y,
189
+ x2=cx - sx, y2=cy - sy, durationMs=duration_ms)
190
+
191
+ # ---- helpers ----
192
+ def _elapsed_ms(self) -> float:
193
+ return (time.monotonic() - self._t0) * 1000.0
194
+
195
+ def _inside(self, x, y) -> bool:
196
+ if not _HAS_WIN32 or not self._hwnd:
197
+ return True
198
+ try:
199
+ l, t, r, b = win32gui.GetWindowRect(self._hwnd)
200
+ except Exception:
201
+ return False
202
+ return l <= x <= r and t <= y <= b
203
+
204
+ def _to_client(self, x, y):
205
+ if _HAS_WIN32 and self._hwnd:
206
+ try:
207
+ return win32gui.ScreenToClient(self._hwnd, (x, y))
208
+ except Exception:
209
+ return x, y
210
+ return x, y
211
+
212
+ def _emit_wait_if_needed(self):
213
+ now = self._elapsed_ms()
214
+ # 用 None 而不是 0 表示"还没有上一个事件":Windows 的 monotonic 精度约 15.6ms,
215
+ # 第一次操作和 start() 落在同一刻度时 elapsed 恰好是 0.0,用 >0 判断会把
216
+ # 第一个间隔静默丢掉。
217
+ if self._last_event is not None:
218
+ gap = int(now - self._last_event)
219
+ if gap >= 150:
220
+ self.stepRecorded.emit(self._wait_step(gap, now))
221
+ self._last_event = now
222
+
223
+ def _wait_step(self, gap: int, now: float) -> Step:
224
+ """长间隔 + 录制时确实看到画面静下来过 → 等画面稳定(换台机器也不会等不够);
225
+ 否则照旧记录固定间隔。"""
226
+ quiet = self._quiet_since
227
+ if not self._template_mode or gap < self.STABLE_MIN_GAP_MS or quiet is None:
228
+ return Step(type=StepType.Wait, waitMs=gap)
229
+ quiet_ms = int(now - quiet)
230
+ if quiet_ms < self.STABLE_MIN_QUIET_MS:
231
+ return Step(type=StepType.Wait, waitMs=gap)
232
+ # 运行时要求的稳定时长不超过我们实际观察到的,免得它永远等不到
233
+ stable_ms = max(200, min(self.STABLE_MAX_MS, quiet_ms))
234
+ timeout_ms = min(15000, max(3000, gap * 3))
235
+ self.note.emit(f"间隔 {gap}ms 且画面静止过 {quiet_ms}ms → 改为等画面稳定"
236
+ f"(稳定 {stable_ms}ms / 超时 {timeout_ms}ms)")
237
+ return Step(type=StepType.WaitStable, stableMs=stable_ms, timeoutMs=timeout_ms)
238
+
239
+ # ---- pynput callbacks (run on listener thread) ----
240
+ def _on_click(self, x, y, button, pressed):
241
+ if not self._recording or button != mouse.Button.left:
242
+ return
243
+ if not self._inside(x, y):
244
+ return
245
+ cx, cy = self._to_client(x, y)
246
+ if pressed:
247
+ self._down = True
248
+ self._down_pos = (cx, cy)
249
+ self._down_time = self._elapsed_ms()
250
+ # 按下这一刻的画面才是"点击前"的样子;用释放时的帧会抓到已经按下去
251
+ # 或者菜单已经弹出来的状态,裁出来的模板就没用了
252
+ self._down_frame = self._frame
253
+ elif self._down:
254
+ self._down = False
255
+ dx, dy = cx - self._down_pos[0], cy - self._down_pos[1]
256
+ dist = math.hypot(dx, dy)
257
+ self._emit_wait_if_needed()
258
+ if dist > 10:
259
+ dur = int(self._elapsed_ms() - self._down_time)
260
+ self.stepRecorded.emit(self._drag_step(cx, cy, dur if dur >= 50 else 200))
261
+ else:
262
+ self.stepRecorded.emit(self._click_step(cx, cy))
263
+
264
+ def _on_press(self, key):
265
+ if not self._recording or key in _MODIFIERS:
266
+ return
267
+ if _HAS_WIN32 and self._hwnd:
268
+ try:
269
+ if win32gui.GetForegroundWindow() != self._hwnd:
270
+ return
271
+ except Exception:
272
+ pass
273
+ name = self._key_name(key)
274
+ if name:
275
+ self._emit_wait_if_needed()
276
+ self.stepRecorded.emit(Step(type=StepType.KeyPress, keyName=name))
277
+
278
+ @staticmethod
279
+ def _key_name(key) -> str:
280
+ if key in _SPECIAL_KEYS:
281
+ return _SPECIAL_KEYS[key]
282
+ if isinstance(key, keyboard.Key):
283
+ k = str(key).replace("Key.", "")
284
+ if k.startswith("f") and k[1:].isdigit():
285
+ return k.upper()
286
+ return ""
287
+ ch = getattr(key, "char", None)
288
+ if ch and (ch.isalnum()):
289
+ return ch.upper()
290
+ return ""
@@ -50,6 +50,7 @@ TYPE_LABELS = [
50
50
  (StepType.WaitImage, "等待图片出现"),
51
51
  (StepType.WaitImageGone, "等待图片消失"),
52
52
  (StepType.MergeImage, "合成拖拽"),
53
+ (StepType.DragImage, "找图拖拽"),
53
54
  (StepType.ClickAll, "点击所有匹配"),
54
55
  (StepType.ClickRelative, "相对点击(last+偏移)"),
55
56
  (StepType.ReadText, "读取文字→变量"),
@@ -65,13 +66,13 @@ TYPE_LABELS = [
65
66
  ]
66
67
 
67
68
  _IMAGE_TYPES = {StepType.ClickImage, StepType.WaitImage, StepType.WaitImageGone,
68
- StepType.MergeImage, StepType.ClickAll, StepType.IfImageFound,
69
- StepType.IfImageCount}
69
+ StepType.MergeImage, StepType.DragImage, StepType.ClickAll,
70
+ StepType.IfImageFound, StepType.IfImageCount}
70
71
  _IF_TYPES = {StepType.IfImageFound, StepType.IfTextFound, StepType.IfElse,
71
72
  StepType.IfColorMatch, StepType.IfImageCount, StepType.IfVar}
72
73
  _ACTION_TYPES = {StepType.ClickImage, StepType.ClickText, StepType.ClickPosition,
73
74
  StepType.CallSub, StepType.WaitImage, StepType.WaitImageGone,
74
- StepType.MergeImage, StepType.ClickAll}
75
+ StepType.MergeImage, StepType.DragImage, StepType.ClickAll}
75
76
  _UNTIL_TYPES = {StepType.DoUntil, StepType.WhileCondition}
76
77
 
77
78
  # 变量比较运算符 (显示标签, 存储 token)。前 6 个 label==token 兼容旧任务。
@@ -174,8 +175,16 @@ class StepEditorDialog(QDialog):
174
175
  mm.setCurrentIndex({"robust": 0, "template": 1, "feature": 2}.get(s.matchMode, 0))
175
176
  self.w["matchMode"] = mm
176
177
  self.form.addRow("匹配方式", mm)
177
- if t in (StepType.ClickImage, StepType.WaitImage, StepType.WaitImageGone, StepType.MergeImage):
178
+ if t in (StepType.ClickImage, StepType.WaitImage, StepType.WaitImageGone,
179
+ StepType.MergeImage, StepType.DragImage):
178
180
  self.form.addRow("超时(ms)", self._spin("timeoutMs", s.timeoutMs, 0, 3600000))
181
+ if t == StepType.DragImage:
182
+ # 存的是相对命中点的位移,不是终点绝对坐标 —— 窗口移位时终点会跟着走
183
+ self.form.addRow("起点微调 X", self._spin("x", s.x))
184
+ self.form.addRow("起点微调 Y", self._spin("y", s.y))
185
+ self.form.addRow("拖拽位移 X2", self._spin("x2", s.x2))
186
+ self.form.addRow("拖拽位移 Y2", self._spin("y2", s.y2))
187
+ self.form.addRow("拖拽时长(ms)", self._spin("durationMs", s.durationMs, 0, 60000))
179
188
  if t == StepType.IfImageCount:
180
189
  self.form.addRow("最少匹配数", self._spin("minMatchCount", s.minMatchCount, 1, 100))
181
190
  if t == StepType.ClickAll:
@@ -51,6 +51,7 @@ class StepType(str, Enum):
51
51
  ClickRelative = "click_relative"
52
52
  HttpRequest = "http_request"
53
53
  RunScript = "run_script"
54
+ DragImage = "drag_image" # 找到起点模板后按 x2/y2 位移拖拽(位置无关的拖拽)
54
55
  WaitStable = "wait_stable" # 等画面(或 ROI)稳定后继续
55
56
  ActivateWindow = "activate_window" # 刷新/激活目标窗口(点击会切前台窗口时用)
56
57
  Stop = "stop" # 结束/停止任务(终止循环)
@@ -433,6 +434,8 @@ class Step:
433
434
  elif t == StepType.ReadText:
434
435
  mode = "数字" if self.varValue == "number" else "文字"
435
436
  base = f"读取{mode}→变量: {self.varName}"
437
+ elif t == StepType.DragImage:
438
+ base = f"找图拖拽: {fname(self.imagePath)} →偏移({self.x2},{self.y2})"
436
439
  elif t == StepType.ClickRelative:
437
440
  base = f"相对点击: last+({self.x},{self.y})"
438
441
  elif t == StepType.HttpRequest:
@@ -504,6 +504,8 @@ class TaskRunner(threading.Thread):
504
504
  self.cb.log(f" 相对点击 last+({step.x},{step.y}) = ({cx}, {cy})")
505
505
  self.clicker.click(self.target_hwnd, cx, cy)
506
506
  return StepResult.Success
507
+ if t == StepType.DragImage:
508
+ return self._exec_drag_image(step)
507
509
  if t == StepType.WaitStable:
508
510
  return self._exec_wait_stable(step)
509
511
  if t == StepType.ActivateWindow:
@@ -729,6 +731,42 @@ class TaskRunner(threading.Thread):
729
731
  self._sleep_ms(200) # 给前台切换一点时间
730
732
  return StepResult.Success
731
733
 
734
+ def _exec_drag_image(self, step: Step) -> StepResult:
735
+ """找到起点模板,再从命中点按 x2/y2 的**位移**拖拽。
736
+
737
+ 存位移而不是终点绝对坐标,这样窗口整体移位时起点和终点一起跟着走。
738
+ """
739
+ templates = self._template_list(step)
740
+ if not templates:
741
+ self.cb.log(" 错误: 未指定图片路径")
742
+ return StepResult.Fail
743
+ templs = [(img, mask) for (img, mask) in
744
+ (self._load_template(p, step.display_name()) for p in templates) if img is not None]
745
+ if not templs:
746
+ return StepResult.Fail
747
+
748
+ start = _now_ms()
749
+ while self._running and _now_ms() - start < step.timeoutMs:
750
+ screen = self._screenshot()
751
+ for img, mask in templs:
752
+ results = self.matcher.find_image_roi(screen, img, step.threshold, step.roiRect,
753
+ step.multiScale, mask=mask, mode=step.matchMode)
754
+ if not results:
755
+ continue
756
+ best = results[0]
757
+ sx = self._rand_offset(best.cx + step.x, step.randomOffset)
758
+ sy = self._rand_offset(best.cy + step.y, step.randomOffset)
759
+ ex, ey = sx + step.x2, sy + step.y2
760
+ self.cb.match_found(sx, sy, best.confidence)
761
+ self.cb.log(f" 找图拖拽 ({sx},{sy}) → ({ex},{ey}) 置信度: {best.confidence:.3f}")
762
+ self.clicker.drag(self.target_hwnd, sx, sy, ex, ey, step.durationMs)
763
+ self._vars["last_x"] = str(ex)
764
+ self._vars["last_y"] = str(ey)
765
+ return StepResult.Success
766
+ self._sleep_ms(500)
767
+ self.cb.log(" 超时: 未找到拖拽起点图片")
768
+ return StepResult.Fail
769
+
732
770
  def _exec_wait_stable(self, step: Step, tol: float = 0.005, interval_ms: int = 150) -> StepResult:
733
771
  """轮询截图,直到监视区域连续稳定 step.stableMs(相邻帧变化比例 <= tol)。
734
772
  超时(step.timeoutMs)仍未稳定则 Fail。"""