PyClickerRPA 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,117 @@
1
+ """Reusable step-snippet library — port of snippetlibrary.cpp.
2
+
3
+ Snippets are JSON files under <AppDataLocation>/snippets (same folder as the C++
4
+ app, so they are shared). Each holds a named list of Steps that can be inserted
5
+ into the current task.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import re
12
+
13
+ from PySide6.QtCore import Qt, Signal, QStandardPaths
14
+ from PySide6.QtWidgets import (
15
+ QHBoxLayout, QInputDialog, QLabel, QListWidget, QListWidgetItem,
16
+ QPushButton, QVBoxLayout, QWidget,
17
+ )
18
+
19
+ from autoclicker.models import Step
20
+
21
+
22
+ class SnippetLibrary(QWidget):
23
+ insertRequested = Signal(object) # emits list[Step]
24
+
25
+ def __init__(self, parent=None):
26
+ super().__init__(parent)
27
+ lay = QVBoxLayout(self)
28
+ hint = QLabel("步骤模板库 — 保存常用步骤组合,一键插入复用")
29
+ hint.setStyleSheet("color: gray;")
30
+ lay.addWidget(hint)
31
+ self.list = QListWidget()
32
+ lay.addWidget(self.list)
33
+ row = QHBoxLayout()
34
+ for text, slot in [("插入到任务", self._insert), ("重命名", self._rename), ("删除", self._delete)]:
35
+ b = QPushButton(text)
36
+ b.clicked.connect(slot)
37
+ row.addWidget(b)
38
+ lay.addLayout(row)
39
+
40
+ os.makedirs(self._dir(), exist_ok=True)
41
+ self.refresh()
42
+
43
+ def _dir(self) -> str:
44
+ base = QStandardPaths.writableLocation(QStandardPaths.AppDataLocation)
45
+ return os.path.join(base, "snippets")
46
+
47
+ def save_snippet(self, name: str, steps: list[Step]):
48
+ if not steps:
49
+ return
50
+ filename = re.sub(r"[^\w\-]", "_", name) + ".json"
51
+ root = {"name": name, "step_count": len(steps), "steps": [s.to_json() for s in steps]}
52
+ with open(os.path.join(self._dir(), filename), "w", encoding="utf-8") as f:
53
+ json.dump(root, f, ensure_ascii=False, indent=4)
54
+ self.refresh()
55
+
56
+ def _load(self, filename: str) -> list[Step]:
57
+ try:
58
+ with open(os.path.join(self._dir(), filename), "r", encoding="utf-8") as f:
59
+ doc = json.load(f)
60
+ except (OSError, json.JSONDecodeError):
61
+ return []
62
+ return [Step.from_json(o) for o in doc.get("steps", [])]
63
+
64
+ def refresh(self):
65
+ self.list.clear()
66
+ d = self._dir()
67
+ if not os.path.isdir(d):
68
+ return
69
+ for fn in sorted(f for f in os.listdir(d) if f.endswith(".json")):
70
+ try:
71
+ with open(os.path.join(d, fn), "r", encoding="utf-8") as f:
72
+ doc = json.load(f)
73
+ except (OSError, json.JSONDecodeError):
74
+ continue
75
+ name = doc.get("name", os.path.splitext(fn)[0])
76
+ count = doc.get("step_count", len(doc.get("steps", [])))
77
+ item = QListWidgetItem(f"{name} ({count} 步)")
78
+ item.setData(Qt.UserRole, fn)
79
+ self.list.addItem(item)
80
+
81
+ def _insert(self):
82
+ item = self.list.currentItem()
83
+ if not item:
84
+ return
85
+ steps = self._load(item.data(Qt.UserRole))
86
+ if steps:
87
+ self.insertRequested.emit(steps)
88
+
89
+ def _delete(self):
90
+ item = self.list.currentItem()
91
+ if not item:
92
+ return
93
+ try:
94
+ os.remove(os.path.join(self._dir(), item.data(Qt.UserRole)))
95
+ except OSError:
96
+ pass
97
+ self.refresh()
98
+
99
+ def _rename(self):
100
+ item = self.list.currentItem()
101
+ if not item:
102
+ return
103
+ fn = item.data(Qt.UserRole)
104
+ path = os.path.join(self._dir(), fn)
105
+ try:
106
+ with open(path, "r", encoding="utf-8") as f:
107
+ doc = json.load(f)
108
+ except (OSError, json.JSONDecodeError):
109
+ return
110
+ old = doc.get("name", "")
111
+ new, ok = QInputDialog.getText(self, "重命名", "新名称:", text=old)
112
+ if not ok or not new or new == old:
113
+ return
114
+ doc["name"] = new
115
+ with open(path, "w", encoding="utf-8") as f:
116
+ json.dump(doc, f, ensure_ascii=False, indent=4)
117
+ self.refresh()
@@ -0,0 +1,547 @@
1
+ """Linear step-list editor — port of taskwidget.cpp.
2
+
3
+ A QListWidget of steps with add/remove/move/copy/paste, plus a dynamic step
4
+ editor dialog that shows only the fields relevant to the chosen step type
5
+ (mirroring the conditional field building in the C++ TaskWidget). Advanced
6
+ retry / repeat / until sections are shown for action-type steps.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import copy
11
+
12
+ from PySide6.QtCore import Qt, Signal
13
+ from PySide6.QtWidgets import (
14
+ QCheckBox, QComboBox, QDialog, QDialogButtonBox, QDoubleSpinBox, QFileDialog,
15
+ QFormLayout, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem,
16
+ QPushButton, QSpinBox, QVBoxLayout, QWidget,
17
+ )
18
+
19
+ from autoclicker.models import (
20
+ RetryConfig, RepeatConfig, UntilConfig, Step, StepType, Task,
21
+ RetryPolicy, UntilCondition,
22
+ )
23
+
24
+ # Human labels for the type picker (add order roughly matches C++ menu)
25
+ TYPE_LABELS = [
26
+ (StepType.ClickImage, "找图点击"),
27
+ (StepType.ClickText, "找字点击"),
28
+ (StepType.ClickPosition, "坐标点击"),
29
+ (StepType.Wait, "等待"),
30
+ (StepType.RandomWait, "随机等待"),
31
+ (StepType.Loop, "循环"),
32
+ (StepType.Goto, "跳转"),
33
+ (StepType.IfImageFound, "条件(图片)"),
34
+ (StepType.IfTextFound, "条件(文字)"),
35
+ (StepType.IfColorMatch, "条件(颜色)"),
36
+ (StepType.IfImageCount, "条件(图片数量)"),
37
+ (StepType.IfVar, "条件(变量)"),
38
+ (StepType.IfElse, "If/Else"),
39
+ (StepType.CallSub, "调用子任务"),
40
+ (StepType.SetVar, "设置变量"),
41
+ (StepType.KeyPress, "按键"),
42
+ (StepType.TypeText, "输入文字"),
43
+ (StepType.Hotkey, "组合键"),
44
+ (StepType.LongPress, "长按"),
45
+ (StepType.Drag, "拖拽"),
46
+ (StepType.MultiDrag, "多点拖拽"),
47
+ (StepType.Pinch, "捏合"),
48
+ (StepType.Scroll, "滚轮"),
49
+ (StepType.WaitImage, "等待图片出现"),
50
+ (StepType.WaitImageGone, "等待图片消失"),
51
+ (StepType.MergeImage, "合成拖拽"),
52
+ (StepType.ClickAll, "点击所有匹配"),
53
+ (StepType.ClickRelative, "相对点击(last+偏移)"),
54
+ (StepType.ReadText, "读取文字→变量"),
55
+ (StepType.DoUntil, "循环直到"),
56
+ (StepType.WhileCondition, "While 条件"),
57
+ (StepType.GroupBegin, "分组开始"),
58
+ (StepType.GroupEnd, "分组结束"),
59
+ ]
60
+
61
+ _IMAGE_TYPES = {StepType.ClickImage, StepType.WaitImage, StepType.WaitImageGone,
62
+ StepType.MergeImage, StepType.ClickAll, StepType.IfImageFound,
63
+ StepType.IfImageCount}
64
+ _IF_TYPES = {StepType.IfImageFound, StepType.IfTextFound, StepType.IfElse,
65
+ StepType.IfColorMatch, StepType.IfImageCount, StepType.IfVar}
66
+ _ACTION_TYPES = {StepType.ClickImage, StepType.ClickText, StepType.ClickPosition,
67
+ StepType.CallSub, StepType.WaitImage, StepType.WaitImageGone,
68
+ StepType.MergeImage, StepType.ClickAll}
69
+ _UNTIL_TYPES = {StepType.DoUntil, StepType.WhileCondition}
70
+
71
+
72
+ class ImageListEditor(QWidget):
73
+ """Edit one-or-more template image paths."""
74
+ def __init__(self, paths: list[str], parent=None):
75
+ super().__init__(parent)
76
+ lay = QVBoxLayout(self)
77
+ lay.setContentsMargins(0, 0, 0, 0)
78
+ self.list = QListWidget()
79
+ self.list.setMaximumHeight(90)
80
+ for p in paths:
81
+ self.list.addItem(p)
82
+ row = QHBoxLayout()
83
+ add = QPushButton("添加图片…")
84
+ rm = QPushButton("移除")
85
+ add.clicked.connect(self._add)
86
+ rm.clicked.connect(self._remove)
87
+ row.addWidget(add)
88
+ row.addWidget(rm)
89
+ lay.addWidget(self.list)
90
+ lay.addLayout(row)
91
+
92
+ def _add(self):
93
+ files, _ = QFileDialog.getOpenFileNames(self, "选择模板图片", "", "图片 (*.png *.jpg *.jpeg *.bmp)")
94
+ for f in files:
95
+ self.list.addItem(f)
96
+
97
+ def _remove(self):
98
+ for it in self.list.selectedItems():
99
+ self.list.takeItem(self.list.row(it))
100
+
101
+ def paths(self) -> list[str]:
102
+ return [self.list.item(i).text() for i in range(self.list.count())]
103
+
104
+
105
+ class StepEditorDialog(QDialog):
106
+ def __init__(self, step: Step, parent=None):
107
+ super().__init__(parent)
108
+ self.setWindowTitle("编辑步骤")
109
+ self.setMinimumWidth(420)
110
+ self._step = copy.deepcopy(step)
111
+ self.w: dict = {}
112
+
113
+ outer = QVBoxLayout(self)
114
+ self.form = QFormLayout()
115
+ outer.addLayout(self.form)
116
+ self._build_fields(self._step.type)
117
+
118
+ buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
119
+ buttons.accepted.connect(self.accept)
120
+ buttons.rejected.connect(self.reject)
121
+ outer.addWidget(buttons)
122
+
123
+ # -- field builders -------------------------------------------------
124
+ def _spin(self, key, val, lo=-100000, hi=100000):
125
+ s = QSpinBox()
126
+ s.setRange(lo, hi)
127
+ s.setValue(val)
128
+ self.w[key] = s
129
+ return s
130
+
131
+ def _dspin(self, key, val):
132
+ s = QDoubleSpinBox()
133
+ s.setRange(0.0, 1.0)
134
+ s.setSingleStep(0.05)
135
+ s.setValue(val)
136
+ self.w[key] = s
137
+ return s
138
+
139
+ def _line(self, key, val):
140
+ e = QLineEdit(val)
141
+ self.w[key] = e
142
+ return e
143
+
144
+ def _build_fields(self, t: StepType):
145
+ s = self._step
146
+ if t in _IMAGE_TYPES:
147
+ paths = list(s.imagePaths) if s.imagePaths else ([s.imagePath] if s.imagePath else [])
148
+ self.w["images"] = ImageListEditor(paths)
149
+ self.form.addRow("模板图片", self.w["images"])
150
+ self.form.addRow("匹配阈值", self._dspin("threshold", s.threshold))
151
+ self.form.addRow("ROI (x,y,w,h)", self._line("roi", _roi_to_str(s.roiRect)))
152
+ mc = QCheckBox("多尺度匹配")
153
+ mc.setChecked(s.multiScale)
154
+ self.w["multiScale"] = mc
155
+ self.form.addRow("", mc)
156
+ if t in (StepType.ClickImage, StepType.WaitImage, StepType.WaitImageGone, StepType.MergeImage):
157
+ self.form.addRow("超时(ms)", self._spin("timeoutMs", s.timeoutMs, 0, 3600000))
158
+ if t == StepType.IfImageCount:
159
+ self.form.addRow("最少匹配数", self._spin("minMatchCount", s.minMatchCount, 1, 100))
160
+ if t == StepType.ClickAll:
161
+ self.form.addRow("点击间隔(ms)", self._spin("clickDelayMs", s.clickDelayMs, 0, 60000))
162
+ if t == StepType.MergeImage:
163
+ self.form.addRow("合成间隔(ms)", self._spin("mergeDelayMs", s.mergeDelayMs, 0, 60000))
164
+ self.form.addRow("拖拽时长(ms)", self._spin("durationMs", s.durationMs, 0, 60000))
165
+
166
+ if t in (StepType.ClickText, StepType.IfTextFound):
167
+ self.form.addRow("目标文字", self._line("text", s.text))
168
+ if t == StepType.ClickText:
169
+ self.form.addRow("超时(ms)", self._spin("timeoutMs", s.timeoutMs, 0, 3600000))
170
+ if t == StepType.TypeText:
171
+ self.form.addRow("输入文字", self._line("text", s.text))
172
+
173
+ if t in (StepType.ClickPosition, StepType.LongPress, StepType.Drag,
174
+ StepType.Pinch, StepType.Scroll, StepType.IfColorMatch):
175
+ self.form.addRow("X", self._spin("x", s.x))
176
+ self.form.addRow("Y", self._spin("y", s.y))
177
+ if t == StepType.ClickPosition:
178
+ uv = QCheckBox("使用 last_x/last_y 变量")
179
+ uv.setChecked(s.varName == "use_var")
180
+ self.w["use_var"] = uv
181
+ self.form.addRow("", uv)
182
+ if t == StepType.Drag:
183
+ self.form.addRow("X2", self._spin("x2", s.x2))
184
+ self.form.addRow("Y2", self._spin("y2", s.y2))
185
+ self.form.addRow("时长(ms)", self._spin("durationMs", s.durationMs, 0, 60000))
186
+ if t == StepType.LongPress:
187
+ self.form.addRow("按住(ms)", self._spin("holdMs", s.holdMs, 0, 600000))
188
+ if t == StepType.Pinch:
189
+ self.form.addRow("起始距离", self._spin("pinchStartDist", s.pinchStartDist, 0, 5000))
190
+ self.form.addRow("结束距离", self._spin("pinchEndDist", s.pinchEndDist, 0, 5000))
191
+ self.form.addRow("时长(ms)", self._spin("durationMs", s.durationMs, 0, 60000))
192
+ if t == StepType.Scroll:
193
+ self.form.addRow("滚动格数", self._spin("scrollAmount", s.scrollAmount, -100, 100))
194
+
195
+ # Click offset from the matched image center (0,0 = center)
196
+ if t in (StepType.ClickImage, StepType.ClickText, StepType.ClickAll):
197
+ self.form.addRow("点击偏移X", self._spin("x", s.x))
198
+ self.form.addRow("点击偏移Y", self._spin("y", s.y))
199
+
200
+ if t == StepType.ClickRelative:
201
+ self.form.addRow("偏移X (相对 last_x)", self._spin("x", s.x))
202
+ self.form.addRow("偏移Y (相对 last_y)", self._spin("y", s.y))
203
+
204
+ if t == StepType.ReadText:
205
+ self.form.addRow("目标变量", self._line("varName", s.varName))
206
+ mode = QComboBox()
207
+ mode.addItem("文字", "text")
208
+ mode.addItem("数字", "number")
209
+ mode.setCurrentIndex(1 if s.varValue == "number" else 0)
210
+ self.w["readmode"] = mode
211
+ self.form.addRow("读取模式", mode)
212
+ self.form.addRow("过滤子串(可选)", self._line("text", s.text))
213
+ self.form.addRow("ROI (x,y,w,h)", self._line("roi", _roi_to_str(s.roiRect)))
214
+
215
+ if t in (StepType.ClickImage, StepType.ClickText, StepType.ClickPosition,
216
+ StepType.ClickAll, StepType.ClickRelative):
217
+ self.form.addRow("随机偏移(px)", self._spin("randomOffset", s.randomOffset, 0, 500))
218
+
219
+ if t == StepType.Wait:
220
+ self.form.addRow("等待(ms)", self._spin("waitMs", s.waitMs, 0, 3600000))
221
+ if t == StepType.RandomWait:
222
+ self.form.addRow("最小(ms)", self._spin("waitMs", s.waitMs, 0, 3600000))
223
+ self.form.addRow("最大(ms)", self._spin("waitMsMax", s.waitMsMax, 0, 3600000))
224
+
225
+ if t in (StepType.Loop, StepType.DoUntil, StepType.WhileCondition):
226
+ self.form.addRow("回到步骤(从1)", self._spin("loopTarget1", s.loopTarget + 1, 1, 100000))
227
+ if t == StepType.Loop:
228
+ self.form.addRow("循环次数(-1=无限)", self._spin("loopCount", s.loopCount, -1, 1000000))
229
+ if t == StepType.Goto:
230
+ self.form.addRow("跳转到步骤(从1)", self._spin("gotoTarget1", s.gotoTarget + 1, 1, 100000))
231
+
232
+ if t in _IF_TYPES:
233
+ self.form.addRow("否则跳过步数", self._spin("elseSkip", s.elseSkip, 0, 1000))
234
+
235
+ if t == StepType.CallSub:
236
+ self.form.addRow("子任务路径", self._line("subTaskPath", s.subTaskPath))
237
+ if t == StepType.SetVar:
238
+ self.form.addRow("变量名", self._line("varName", s.varName))
239
+ self.form.addRow("值 (++/-- 支持)", self._line("varValue", s.varValue))
240
+ if t in (StepType.IfVar, StepType.WhileCondition):
241
+ self.form.addRow("变量名", self._line("varName", s.varName))
242
+ op = QComboBox()
243
+ op.addItems(["==", "!=", ">", "<", ">=", "<="])
244
+ if s.compareOp:
245
+ op.setCurrentText(s.compareOp)
246
+ self.w["compareOp"] = op
247
+ self.form.addRow("比较", op)
248
+ self.form.addRow("比较值", self._line("compareValue", s.compareValue))
249
+ if t == StepType.KeyPress:
250
+ self.form.addRow("按键", self._line("keyName", s.keyName))
251
+ if t == StepType.Hotkey:
252
+ self.form.addRow("修饰键", self._line("modifierKey", s.modifierKey))
253
+ self.form.addRow("按键", self._line("keyName", s.keyName))
254
+ if t == StepType.IfColorMatch:
255
+ self.form.addRow("目标颜色(#RRGGBB)", self._line("targetColor", s.targetColor))
256
+ self.form.addRow("容差", self._spin("colorTolerance", s.colorTolerance, 0, 255))
257
+ if t == StepType.MultiDrag:
258
+ self.form.addRow("拖拽点 (x,y;x,y…)", self._line("dragPoints", _points_to_str(s.dragPoints)))
259
+ self.form.addRow("每段时长(ms)", self._spin("durationMs", s.durationMs, 0, 60000))
260
+ self.form.addRow("段间延迟(ms)", self._spin("dragStepDelay", s.dragStepDelay, 0, 60000))
261
+ if t == StepType.GroupBegin:
262
+ self.form.addRow("分组名", self._line("groupName", s.groupName))
263
+ dc = QCheckBox("禁用该分组")
264
+ dc.setChecked(s.disabled)
265
+ self.w["disabled"] = dc
266
+ self.form.addRow("", dc)
267
+
268
+ if t in _UNTIL_TYPES:
269
+ self._build_until(s.until)
270
+ if t in _ACTION_TYPES:
271
+ self._build_retry(s.retry)
272
+ self._build_repeat(s.repeat)
273
+
274
+ def _build_until(self, u: UntilConfig):
275
+ self.form.addRow(QLabel("── 条件 ──"))
276
+ combo = QComboBox()
277
+ for uc, name in [(UntilCondition.NoneCond, "无"), (UntilCondition.ImageFound, "图片出现"),
278
+ (UntilCondition.ImageGone, "图片消失"), (UntilCondition.TextFound, "文字出现"),
279
+ (UntilCondition.ColorMatch, "颜色匹配")]:
280
+ combo.addItem(name, uc)
281
+ idx = combo.findData(u.type)
282
+ combo.setCurrentIndex(max(0, idx))
283
+ self.w["until_type"] = combo
284
+ self.form.addRow("条件类型", combo)
285
+ self.form.addRow("条件图片", self._line("until_image", u.image))
286
+ self.form.addRow("条件文字", self._line("until_text", u.text))
287
+ self.form.addRow("条件颜色", self._line("until_color", u.color))
288
+ self.form.addRow("条件超时(ms)", self._spin("until_timeoutMs", u.timeoutMs, 0, 3600000))
289
+
290
+ def _build_retry(self, r: RetryConfig):
291
+ self.form.addRow(QLabel("── 失败处理 ──"))
292
+ combo = QComboBox()
293
+ for rp, name in [(RetryPolicy.NoneP, "无"), (RetryPolicy.Retry, "重试"),
294
+ (RetryPolicy.Skip, "跳过"), (RetryPolicy.Abort, "中止任务")]:
295
+ combo.addItem(name, rp)
296
+ combo.setCurrentIndex(max(0, combo.findData(r.policy)))
297
+ self.w["retry_policy"] = combo
298
+ self.form.addRow("策略", combo)
299
+ self.form.addRow("重试次数", self._spin("retry_count", r.count, 0, 1000))
300
+ self.form.addRow("重试间隔(ms)", self._spin("retry_delayMs", r.delayMs, 0, 600000))
301
+
302
+ def _build_repeat(self, rp: RepeatConfig):
303
+ self.form.addRow(QLabel("── 重复 ──"))
304
+ self.form.addRow("重复次数(1=一次)", self._spin("repeat_times", rp.times, -1, 100000))
305
+ self.form.addRow("重复间隔(ms)", self._spin("repeat_delayMs", rp.delayMs, 0, 600000))
306
+
307
+ # -- collect --------------------------------------------------------
308
+ def result_step(self) -> Step:
309
+ s = self._step
310
+ t = s.type
311
+ w = self.w
312
+
313
+ def gi(key):
314
+ return w[key].value()
315
+
316
+ def gt(key):
317
+ return w[key].text()
318
+
319
+ if "images" in w:
320
+ paths = w["images"].paths()
321
+ if len(paths) == 1:
322
+ s.imagePath, s.imagePaths = paths[0], []
323
+ else:
324
+ s.imagePath, s.imagePaths = "", paths
325
+ if "threshold" in w:
326
+ s.threshold = w["threshold"].value()
327
+ if "roi" in w:
328
+ s.roiRect = _str_to_roi(gt("roi"))
329
+ if "multiScale" in w:
330
+ s.multiScale = w["multiScale"].isChecked()
331
+ for key in ("timeoutMs", "minMatchCount", "clickDelayMs", "mergeDelayMs",
332
+ "x", "y", "x2", "y2", "durationMs", "holdMs", "pinchStartDist",
333
+ "pinchEndDist", "scrollAmount", "randomOffset", "waitMs", "waitMsMax",
334
+ "loopCount", "elseSkip", "colorTolerance", "dragStepDelay"):
335
+ if key in w:
336
+ setattr(s, key, gi(key))
337
+ if "loopTarget1" in w:
338
+ s.loopTarget = gi("loopTarget1") - 1
339
+ if "gotoTarget1" in w:
340
+ s.gotoTarget = gi("gotoTarget1") - 1
341
+ for key in ("text", "subTaskPath", "varName", "varValue", "keyName",
342
+ "modifierKey", "targetColor", "groupName", "compareValue"):
343
+ if key in w:
344
+ setattr(s, key, gt(key))
345
+ if "compareOp" in w:
346
+ s.compareOp = w["compareOp"].currentText()
347
+ if "readmode" in w:
348
+ s.varValue = w["readmode"].currentData()
349
+ if "use_var" in w:
350
+ s.varName = "use_var" if w["use_var"].isChecked() else ""
351
+ if "disabled" in w:
352
+ s.disabled = w["disabled"].isChecked()
353
+ if "dragPoints" in w:
354
+ s.dragPoints = _str_to_points(gt("dragPoints"))
355
+ if "until_type" in w:
356
+ s.until.type = w["until_type"].currentData()
357
+ s.until.image = gt("until_image")
358
+ s.until.text = gt("until_text")
359
+ s.until.color = gt("until_color")
360
+ s.until.timeoutMs = gi("until_timeoutMs")
361
+ if "retry_policy" in w:
362
+ s.retry.policy = w["retry_policy"].currentData()
363
+ s.retry.count = gi("retry_count")
364
+ s.retry.delayMs = gi("retry_delayMs")
365
+ if "repeat_times" in w:
366
+ s.repeat.times = gi("repeat_times")
367
+ s.repeat.delayMs = gi("repeat_delayMs")
368
+ return s
369
+
370
+
371
+ class TaskWidget(QWidget):
372
+ taskChanged = Signal()
373
+ testStepRequested = Signal(object) # emits a single Step to test-run
374
+
375
+ def __init__(self, parent=None):
376
+ super().__init__(parent)
377
+ self._task = Task()
378
+ self._clipboard: list[Step] = []
379
+
380
+ lay = QVBoxLayout(self)
381
+ top = QHBoxLayout()
382
+ self.type_combo = QComboBox()
383
+ for st, label in TYPE_LABELS:
384
+ self.type_combo.addItem(label, st)
385
+ add_btn = QPushButton("添加")
386
+ add_btn.clicked.connect(self._add)
387
+ top.addWidget(self.type_combo, 1)
388
+ top.addWidget(add_btn)
389
+ lay.addLayout(top)
390
+
391
+ self.list = QListWidget()
392
+ self.list.itemDoubleClicked.connect(self._edit_item)
393
+ lay.addWidget(self.list, 1)
394
+
395
+ row = QHBoxLayout()
396
+ for text, slot in [("↑", self._move_up), ("↓", self._move_down),
397
+ ("删除", self._remove), ("复制", self._copy), ("粘贴", self._paste),
398
+ ("试跑选中步", self._test_step)]:
399
+ b = QPushButton(text)
400
+ b.clicked.connect(slot)
401
+ row.addWidget(b)
402
+ lay.addLayout(row)
403
+
404
+ # -- task <-> ui ----
405
+ def get_task(self) -> Task:
406
+ return self._task
407
+
408
+ def set_task(self, task: Task):
409
+ self._task = task
410
+ self._refresh()
411
+
412
+ def _refresh(self):
413
+ self.list.clear()
414
+ for i, step in enumerate(self._task.steps):
415
+ self.list.addItem(QListWidgetItem(f"{i + 1}. {step.display_name()}"))
416
+
417
+ def get_selected_steps(self) -> list[Step]:
418
+ rows = sorted(self.list.row(it) for it in self.list.selectedItems())
419
+ return [copy.deepcopy(self._task.steps[r]) for r in rows if 0 <= r < len(self._task.steps)]
420
+
421
+ def current_index(self) -> int:
422
+ return self.list.currentRow()
423
+
424
+ def step_at(self, i: int) -> Step | None:
425
+ return self._task.steps[i] if 0 <= i < len(self._task.steps) else None
426
+
427
+ def replace_step(self, i: int, step: Step):
428
+ if 0 <= i < len(self._task.steps):
429
+ self._task.steps[i] = step
430
+ self._refresh()
431
+ self.list.setCurrentRow(i)
432
+ self.taskChanged.emit()
433
+
434
+ def insert_steps(self, steps: list[Step]):
435
+ row = self._selected_row()
436
+ at = row + 1 if row >= 0 else len(self._task.steps)
437
+ for i, s in enumerate(steps):
438
+ self._task.steps.insert(at + i, copy.deepcopy(s))
439
+ self._refresh()
440
+ self.taskChanged.emit()
441
+
442
+ def append_step(self, step: Step):
443
+ self._task.steps.append(step)
444
+ self._refresh()
445
+ self.taskChanged.emit()
446
+
447
+ def _selected_row(self) -> int:
448
+ return self.list.currentRow()
449
+
450
+ # -- actions ----
451
+ def _add(self):
452
+ st = self.type_combo.currentData()
453
+ step = Step(type=st)
454
+ dlg = StepEditorDialog(step, self)
455
+ if dlg.exec() == QDialog.Accepted:
456
+ self._task.steps.append(dlg.result_step())
457
+ self._refresh()
458
+ self.taskChanged.emit()
459
+
460
+ def _edit_item(self, item):
461
+ row = self.list.row(item)
462
+ if 0 <= row < len(self._task.steps):
463
+ dlg = StepEditorDialog(self._task.steps[row], self)
464
+ if dlg.exec() == QDialog.Accepted:
465
+ self._task.steps[row] = dlg.result_step()
466
+ self._refresh()
467
+ self.list.setCurrentRow(row)
468
+ self.taskChanged.emit()
469
+
470
+ def _remove(self):
471
+ row = self._selected_row()
472
+ if 0 <= row < len(self._task.steps):
473
+ del self._task.steps[row]
474
+ self._refresh()
475
+ self.taskChanged.emit()
476
+
477
+ def _move_up(self):
478
+ row = self._selected_row()
479
+ if row > 0:
480
+ self._task.steps[row - 1], self._task.steps[row] = self._task.steps[row], self._task.steps[row - 1]
481
+ self._refresh()
482
+ self.list.setCurrentRow(row - 1)
483
+ self.taskChanged.emit()
484
+
485
+ def _move_down(self):
486
+ row = self._selected_row()
487
+ if 0 <= row < len(self._task.steps) - 1:
488
+ self._task.steps[row + 1], self._task.steps[row] = self._task.steps[row], self._task.steps[row + 1]
489
+ self._refresh()
490
+ self.list.setCurrentRow(row + 1)
491
+ self.taskChanged.emit()
492
+
493
+ def _copy(self):
494
+ row = self._selected_row()
495
+ if 0 <= row < len(self._task.steps):
496
+ self._clipboard = [copy.deepcopy(self._task.steps[row])]
497
+
498
+ def _test_step(self):
499
+ step = self.step_at(self._selected_row())
500
+ if step is not None:
501
+ self.testStepRequested.emit(copy.deepcopy(step))
502
+
503
+ def _paste(self):
504
+ if not self._clipboard:
505
+ return
506
+ row = self._selected_row()
507
+ insert_at = row + 1 if row >= 0 else len(self._task.steps)
508
+ for i, s in enumerate(self._clipboard):
509
+ self._task.steps.insert(insert_at + i, copy.deepcopy(s))
510
+ self._refresh()
511
+ self.taskChanged.emit()
512
+
513
+
514
+ # -- small parse helpers --------------------------------------------------------
515
+ def _roi_to_str(roi):
516
+ return "" if not roi else f"{roi[0]},{roi[1]},{roi[2]},{roi[3]}"
517
+
518
+
519
+ def _str_to_roi(text):
520
+ text = text.strip()
521
+ if not text:
522
+ return None
523
+ try:
524
+ parts = [int(p) for p in text.replace(",", ",").split(",")]
525
+ if len(parts) == 4:
526
+ return tuple(parts)
527
+ except ValueError:
528
+ pass
529
+ return None
530
+
531
+
532
+ def _points_to_str(points):
533
+ return ";".join(f"{x},{y}" for x, y in points)
534
+
535
+
536
+ def _str_to_points(text):
537
+ pts = []
538
+ for chunk in text.replace(";", ";").split(";"):
539
+ chunk = chunk.strip()
540
+ if not chunk:
541
+ continue
542
+ try:
543
+ x, y = (int(v) for v in chunk.replace(",", ",").split(","))
544
+ pts.append((x, y))
545
+ except ValueError:
546
+ pass
547
+ return pts