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,84 @@
1
+ """Trigger / monitor mode.
2
+
3
+ Polls the target window (or full screen) on an interval for a condition (an
4
+ image appearing, or a pixel matching a color) and fires ``triggered`` on the
5
+ rising edge (false -> true), so a task runs once per appearance rather than
6
+ every tick.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from PySide6.QtCore import QObject, QTimer, Signal
11
+
12
+
13
+ def _parse_hex(name: str):
14
+ s = (name or "").strip().lstrip("#")
15
+ if len(s) == 3:
16
+ s = "".join(c * 2 for c in s)
17
+ if len(s) != 6:
18
+ return None
19
+ try:
20
+ return int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16)
21
+ except ValueError:
22
+ return None
23
+
24
+
25
+ class MonitorController(QObject):
26
+ triggered = Signal()
27
+ status = Signal(str)
28
+
29
+ def __init__(self, parent=None):
30
+ super().__init__(parent)
31
+ self._timer = QTimer(self)
32
+ self._timer.timeout.connect(self._tick)
33
+ self._cond: dict | None = None
34
+ self._hwnd = None
35
+ self._prev = False
36
+
37
+ def start(self, hwnd, cond: dict):
38
+ self._hwnd = hwnd
39
+ self._cond = cond
40
+ self._prev = False
41
+ self._timer.setInterval(int(cond.get("interval_ms", 1000)))
42
+ self._timer.start()
43
+ self.status.emit("监视中…")
44
+
45
+ def stop(self):
46
+ self._timer.stop()
47
+ self.status.emit("已停止监视")
48
+
49
+ def is_active(self) -> bool:
50
+ return self._timer.isActive()
51
+
52
+ def _tick(self):
53
+ met = False
54
+ try:
55
+ met = self._check()
56
+ except Exception as e:
57
+ self.status.emit(f"监视出错: {e}")
58
+ if met and not self._prev:
59
+ self.triggered.emit()
60
+ self._prev = met
61
+
62
+ def _check(self) -> bool:
63
+ import cv2
64
+ from autoclicker import capture, matcher
65
+ screen = capture.capture_window(self._hwnd) if self._hwnd else capture.capture_screen()
66
+ if screen is None or screen.size == 0:
67
+ return False
68
+ c = self._cond or {}
69
+ if c.get("type") == "image":
70
+ templ = cv2.imread(c["image"]) if c.get("image") else None
71
+ if templ is None:
72
+ return False
73
+ return bool(matcher.find_image(screen, templ, c.get("threshold", 0.8)))
74
+ if c.get("type") == "color":
75
+ x, y = int(c.get("x", 0)), int(c.get("y", 0))
76
+ if x >= screen.shape[1] or y >= screen.shape[0]:
77
+ return False
78
+ rgb = _parse_hex(c.get("color", ""))
79
+ if rgb is None:
80
+ return False
81
+ b, g, r = (int(v) for v in screen[y, x][:3])
82
+ tol = int(c.get("tolerance", 30))
83
+ return abs(r - rgb[0]) <= tol and abs(g - rgb[1]) <= tol and abs(b - rgb[2]) <= tol
84
+ return False
@@ -0,0 +1,176 @@
1
+ """Screenshot preview with interactive picking — port of previewlabel.cpp.
2
+
3
+ Shows a BGR ndarray scaled-to-fit, maps mouse coordinates back to image space,
4
+ and supports four pick modes used by the toolbar:
5
+ - point : single click -> pointPicked(QPoint)
6
+ - region : drag rectangle -> regionSelected(QRect) (ROI / template crop)
7
+ - twopts : two clicks -> twoPointsPicked(start, end) (drag/swipe design)
8
+ - color : single click -> colorPicked(QPoint, QColor)
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from enum import Enum
13
+ from typing import Optional
14
+
15
+ import numpy as np
16
+ from PySide6.QtCore import QPoint, QRect, QSize, Qt, Signal
17
+ from PySide6.QtGui import QColor, QImage, QPixmap
18
+ from PySide6.QtWidgets import QLabel, QRubberBand
19
+
20
+
21
+ def ndarray_to_qimage(bgr: np.ndarray) -> QImage:
22
+ """Convert an (H, W, 3) BGR ndarray to a QImage (copied, RGB888)."""
23
+ if bgr is None or bgr.size == 0:
24
+ return QImage()
25
+ rgb = np.ascontiguousarray(bgr[:, :, ::-1]) # BGR -> RGB
26
+ h, w = rgb.shape[:2]
27
+ return QImage(rgb.data, w, h, 3 * w, QImage.Format_RGB888).copy()
28
+
29
+
30
+ class PickMode(Enum):
31
+ NONE = 0
32
+ POINT = 1
33
+ REGION = 2
34
+ TWO_POINTS = 3
35
+ COLOR = 4
36
+
37
+
38
+ class PreviewLabel(QLabel):
39
+ pointPicked = Signal(QPoint)
40
+ regionSelected = Signal(QRect)
41
+ twoPointsPicked = Signal(QPoint, QPoint)
42
+ colorPicked = Signal(QPoint, QColor)
43
+
44
+ def __init__(self, parent=None) -> None:
45
+ super().__init__(parent)
46
+ self.setAlignment(Qt.AlignCenter)
47
+ self.setMinimumSize(320, 240)
48
+ self.setStyleSheet("background:#1e1e1e; color:#888;")
49
+ self.setText("(截图预览)")
50
+ self._img: Optional[np.ndarray] = None
51
+ self._orig: QPixmap = QPixmap()
52
+ self._mode = PickMode.NONE
53
+ self._rubber: Optional[QRubberBand] = None
54
+ self._press_pos: Optional[QPoint] = None
55
+ self._first_point: Optional[QPoint] = None
56
+ self._markers: list[tuple[int, int]] = []
57
+
58
+ # ---- image ----
59
+ def set_image_ndarray(self, bgr: np.ndarray) -> None:
60
+ self._img = bgr
61
+ self._orig = QPixmap.fromImage(ndarray_to_qimage(bgr))
62
+ self._markers.clear()
63
+ self._rescale()
64
+
65
+ def set_image_qimage(self, img: QImage) -> None:
66
+ self._img = None
67
+ self._orig = QPixmap.fromImage(img)
68
+ self._markers.clear()
69
+ self._rescale()
70
+
71
+ def has_image(self) -> bool:
72
+ return not self._orig.isNull()
73
+
74
+ def add_marker(self, x: int, y: int) -> None:
75
+ """Mark a hit at image-space (x, y); shown as a green ring on the preview."""
76
+ self._markers.append((int(x), int(y)))
77
+ self._rescale()
78
+
79
+ def clear_markers(self) -> None:
80
+ self._markers.clear()
81
+ self._rescale()
82
+
83
+ def _rescale(self) -> None:
84
+ if self._orig.isNull():
85
+ return
86
+ scaled = self._orig.scaled(self.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation)
87
+ if self._markers:
88
+ from PySide6.QtGui import QPainter, QPen, QColor
89
+ sx = scaled.width() / self._orig.width()
90
+ sy = scaled.height() / self._orig.height()
91
+ painter = QPainter(scaled)
92
+ painter.setPen(QPen(QColor(0, 255, 0), 2))
93
+ for mx, my in self._markers:
94
+ cx, cy = int(mx * sx), int(my * sy)
95
+ painter.drawEllipse(cx - 7, cy - 7, 14, 14)
96
+ painter.drawLine(cx - 10, cy, cx + 10, cy)
97
+ painter.drawLine(cx, cy - 10, cx, cy + 10)
98
+ painter.end()
99
+ self.setPixmap(scaled)
100
+
101
+ def resizeEvent(self, event) -> None:
102
+ self._rescale()
103
+ super().resizeEvent(event)
104
+
105
+ # ---- coordinate mapping ----
106
+ def _displayed_rect(self) -> QRect:
107
+ """Rect (in widget coords) where the scaled pixmap is actually drawn."""
108
+ if self._orig.isNull():
109
+ return QRect()
110
+ iw, ih = self._orig.width(), self._orig.height()
111
+ lw, lh = self.width(), self.height()
112
+ scale = min(lw / iw, lh / ih)
113
+ dw, dh = int(iw * scale), int(ih * scale)
114
+ return QRect((lw - dw) // 2, (lh - dh) // 2, dw, dh)
115
+
116
+ def _to_image(self, pos: QPoint) -> Optional[QPoint]:
117
+ rect = self._displayed_rect()
118
+ if rect.isEmpty() or not rect.contains(pos):
119
+ return None
120
+ iw, ih = self._orig.width(), self._orig.height()
121
+ ix = int((pos.x() - rect.x()) / rect.width() * iw)
122
+ iy = int((pos.y() - rect.y()) / rect.height() * ih)
123
+ return QPoint(max(0, min(iw - 1, ix)), max(0, min(ih - 1, iy)))
124
+
125
+ # ---- pick modes ----
126
+ def set_pick_mode(self, mode: PickMode) -> None:
127
+ self._mode = mode
128
+ self._first_point = None
129
+ self.setCursor(Qt.CrossCursor if mode != PickMode.NONE else Qt.ArrowCursor)
130
+
131
+ def mousePressEvent(self, event) -> None:
132
+ if self._orig.isNull() or event.button() != Qt.LeftButton:
133
+ return
134
+ img_pt = self._to_image(event.position().toPoint())
135
+ if img_pt is None:
136
+ return
137
+
138
+ if self._mode == PickMode.POINT:
139
+ self.pointPicked.emit(img_pt)
140
+ elif self._mode == PickMode.COLOR:
141
+ self.colorPicked.emit(img_pt, self._color_at(img_pt))
142
+ elif self._mode == PickMode.TWO_POINTS:
143
+ if self._first_point is None:
144
+ self._first_point = img_pt
145
+ else:
146
+ self.twoPointsPicked.emit(self._first_point, img_pt)
147
+ self._first_point = None
148
+ self.set_pick_mode(PickMode.NONE)
149
+ elif self._mode == PickMode.REGION:
150
+ self._press_pos = event.position().toPoint()
151
+ if self._rubber is None:
152
+ self._rubber = QRubberBand(QRubberBand.Rectangle, self)
153
+ self._rubber.setGeometry(QRect(self._press_pos, QSize()))
154
+ self._rubber.show()
155
+
156
+ def mouseMoveEvent(self, event) -> None:
157
+ if self._mode == PickMode.REGION and self._press_pos is not None and self._rubber:
158
+ self._rubber.setGeometry(QRect(self._press_pos, event.position().toPoint()).normalized())
159
+
160
+ def mouseReleaseEvent(self, event) -> None:
161
+ if self._mode == PickMode.REGION and self._press_pos is not None and self._rubber:
162
+ self._rubber.hide()
163
+ start = self._to_image(self._press_pos)
164
+ end = self._to_image(event.position().toPoint())
165
+ self._press_pos = None
166
+ if start and end:
167
+ rect = QRect(start, end).normalized()
168
+ if rect.width() > 2 and rect.height() > 2:
169
+ self.regionSelected.emit(rect)
170
+ self.set_pick_mode(PickMode.NONE)
171
+
172
+ def _color_at(self, img_pt: QPoint) -> QColor:
173
+ if self._img is not None:
174
+ b, g, r = (int(v) for v in self._img[img_pt.y(), img_pt.x()][:3])
175
+ return QColor(r, g, b)
176
+ return QColor(self._orig.toImage().pixel(img_pt))
@@ -0,0 +1,71 @@
1
+ """Qt adapter around the Qt-free TaskRunner engine.
2
+
3
+ The engine runs on its own thread and invokes RunnerCallbacks; we re-emit those
4
+ as Qt signals. Emitting a signal from the worker thread to a slot living in the
5
+ GUI thread is delivered via a queued connection automatically, so the GUI stays
6
+ responsive and thread-safe.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from PySide6.QtCore import QObject, Signal
11
+
12
+ from autoclicker.models import Task
13
+ from autoclicker.runner import RunnerCallbacks, TaskRunner
14
+
15
+
16
+ class _SignalCallbacks(RunnerCallbacks):
17
+ def __init__(self, owner: "QtRunner") -> None:
18
+ self._o = owner
19
+
20
+ def step_started(self, index, description):
21
+ self._o.stepStarted.emit(index, description)
22
+
23
+ def step_completed(self, index, success):
24
+ self._o.stepCompleted.emit(index, success)
25
+
26
+ def match_found(self, x, y, confidence):
27
+ self._o.matchFound.emit(x, y, confidence)
28
+
29
+ def log(self, msg):
30
+ self._o.logMessage.emit(msg)
31
+
32
+ def var_changed(self, name, value):
33
+ self._o.varChanged.emit(name, value)
34
+
35
+ def finished(self):
36
+ self._o.runFinished.emit()
37
+
38
+
39
+ class QtRunner(QObject):
40
+ stepStarted = Signal(int, str)
41
+ stepCompleted = Signal(int, bool)
42
+ matchFound = Signal(int, int, float)
43
+ logMessage = Signal(str)
44
+ varChanged = Signal(str, str)
45
+ runFinished = Signal()
46
+
47
+ def __init__(self, parent=None) -> None:
48
+ super().__init__(parent)
49
+ self._runner: TaskRunner | None = None
50
+
51
+ def start(self, task: Task, target_hwnd=None, screenshot_on_fail=False, screenshot_dir=None) -> None:
52
+ self._runner = TaskRunner(task, target_hwnd=target_hwnd, callbacks=_SignalCallbacks(self))
53
+ self._runner.screenshot_on_fail = screenshot_on_fail
54
+ if screenshot_dir:
55
+ self._runner.screenshot_dir = screenshot_dir
56
+ self._runner.start()
57
+
58
+ def stop(self) -> None:
59
+ if self._runner:
60
+ self._runner.stop()
61
+
62
+ def pause(self) -> None:
63
+ if self._runner:
64
+ self._runner.pause()
65
+
66
+ def resume(self) -> None:
67
+ if self._runner:
68
+ self._runner.resume()
69
+
70
+ def is_running(self) -> bool:
71
+ return self._runner is not None and self._runner.is_alive()
@@ -0,0 +1,163 @@
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
+ from __future__ import annotations
8
+
9
+ import math
10
+ import time
11
+
12
+ from PySide6.QtCore import QObject, Signal
13
+
14
+ from autoclicker.models import Step, StepType
15
+
16
+ try:
17
+ import win32gui
18
+ _HAS_WIN32 = True
19
+ except ImportError:
20
+ _HAS_WIN32 = False
21
+
22
+ from pynput import mouse, keyboard
23
+
24
+ # pynput special key -> our key name (mirrors Recorder::vkToName specials)
25
+ _SPECIAL_KEYS = {
26
+ keyboard.Key.enter: "ENTER", keyboard.Key.esc: "ESC", keyboard.Key.tab: "TAB",
27
+ keyboard.Key.space: "SPACE", keyboard.Key.backspace: "BACKSPACE",
28
+ keyboard.Key.delete: "DELETE", keyboard.Key.up: "UP", keyboard.Key.down: "DOWN",
29
+ keyboard.Key.left: "LEFT", keyboard.Key.right: "RIGHT", keyboard.Key.home: "HOME",
30
+ keyboard.Key.end: "END", keyboard.Key.page_up: "PAGEUP", keyboard.Key.page_down: "PAGEDOWN",
31
+ }
32
+ _MODIFIERS = {
33
+ keyboard.Key.ctrl, keyboard.Key.ctrl_l, keyboard.Key.ctrl_r,
34
+ keyboard.Key.shift, keyboard.Key.shift_l, keyboard.Key.shift_r,
35
+ keyboard.Key.alt, keyboard.Key.alt_l, keyboard.Key.alt_r, keyboard.Key.alt_gr,
36
+ keyboard.Key.cmd, keyboard.Key.cmd_l, keyboard.Key.cmd_r,
37
+ }
38
+
39
+
40
+ class Recorder(QObject):
41
+ stepRecorded = Signal(object) # emits a Step
42
+ recordingStopped = Signal()
43
+
44
+ def __init__(self, parent=None):
45
+ super().__init__(parent)
46
+ self._hwnd = None
47
+ self._recording = False
48
+ self._mouse_listener = None
49
+ self._key_listener = None
50
+ self._t0 = 0.0
51
+ self._last_event = 0.0
52
+ self._down = False
53
+ self._down_pos = (0, 0)
54
+ self._down_time = 0.0
55
+
56
+ def is_recording(self) -> bool:
57
+ return self._recording
58
+
59
+ def start(self, hwnd):
60
+ if self._recording:
61
+ return
62
+ self._hwnd = hwnd
63
+ self._recording = True
64
+ self._t0 = time.monotonic()
65
+ self._last_event = 0.0
66
+ self._down = False
67
+ self._mouse_listener = mouse.Listener(on_click=self._on_click)
68
+ self._key_listener = keyboard.Listener(on_press=self._on_press)
69
+ self._mouse_listener.start()
70
+ self._key_listener.start()
71
+
72
+ def stop(self):
73
+ if not self._recording:
74
+ return
75
+ self._recording = False
76
+ if self._mouse_listener:
77
+ self._mouse_listener.stop()
78
+ self._mouse_listener = None
79
+ if self._key_listener:
80
+ self._key_listener.stop()
81
+ self._key_listener = None
82
+ self.recordingStopped.emit()
83
+
84
+ # ---- helpers ----
85
+ def _elapsed_ms(self) -> float:
86
+ return (time.monotonic() - self._t0) * 1000.0
87
+
88
+ def _inside(self, x, y) -> bool:
89
+ if not _HAS_WIN32 or not self._hwnd:
90
+ return True
91
+ try:
92
+ l, t, r, b = win32gui.GetWindowRect(self._hwnd)
93
+ except Exception:
94
+ return False
95
+ return l <= x <= r and t <= y <= b
96
+
97
+ def _to_client(self, x, y):
98
+ if _HAS_WIN32 and self._hwnd:
99
+ try:
100
+ return win32gui.ScreenToClient(self._hwnd, (x, y))
101
+ except Exception:
102
+ return x, y
103
+ return x, y
104
+
105
+ def _emit_wait_if_needed(self):
106
+ now = self._elapsed_ms()
107
+ if self._last_event > 0:
108
+ gap = int(now - self._last_event)
109
+ if gap >= 150:
110
+ self.stepRecorded.emit(Step(type=StepType.Wait, waitMs=gap))
111
+ self._last_event = now
112
+
113
+ # ---- pynput callbacks (run on listener thread) ----
114
+ def _on_click(self, x, y, button, pressed):
115
+ if not self._recording or button != mouse.Button.left:
116
+ return
117
+ if not self._inside(x, y):
118
+ return
119
+ cx, cy = self._to_client(x, y)
120
+ if pressed:
121
+ self._down = True
122
+ self._down_pos = (cx, cy)
123
+ self._down_time = self._elapsed_ms()
124
+ elif self._down:
125
+ self._down = False
126
+ dx, dy = cx - self._down_pos[0], cy - self._down_pos[1]
127
+ dist = math.hypot(dx, dy)
128
+ self._emit_wait_if_needed()
129
+ if dist > 10:
130
+ dur = int(self._elapsed_ms() - self._down_time)
131
+ self.stepRecorded.emit(Step(type=StepType.Drag,
132
+ x=self._down_pos[0], y=self._down_pos[1],
133
+ x2=cx, y2=cy, durationMs=dur if dur >= 50 else 200))
134
+ else:
135
+ self.stepRecorded.emit(Step(type=StepType.ClickPosition, x=cx, y=cy))
136
+
137
+ def _on_press(self, key):
138
+ if not self._recording or key in _MODIFIERS:
139
+ return
140
+ if _HAS_WIN32 and self._hwnd:
141
+ try:
142
+ if win32gui.GetForegroundWindow() != self._hwnd:
143
+ return
144
+ except Exception:
145
+ pass
146
+ name = self._key_name(key)
147
+ if name:
148
+ self._emit_wait_if_needed()
149
+ self.stepRecorded.emit(Step(type=StepType.KeyPress, keyName=name))
150
+
151
+ @staticmethod
152
+ def _key_name(key) -> str:
153
+ if key in _SPECIAL_KEYS:
154
+ return _SPECIAL_KEYS[key]
155
+ if isinstance(key, keyboard.Key):
156
+ k = str(key).replace("Key.", "")
157
+ if k.startswith("f") and k[1:].isdigit():
158
+ return k.upper()
159
+ return ""
160
+ ch = getattr(key, "char", None)
161
+ if ch and (ch.isalnum()):
162
+ return ch.upper()
163
+ return ""
@@ -0,0 +1,160 @@
1
+ """Scheduled-task list — port of schedulerwidget.cpp.
2
+
3
+ Runs saved .json task files on a fixed interval. Persists via QSettings under
4
+ the same ("AutoClicker","AutoClicker") key as the C++ app, so schedules are
5
+ shared between the two versions.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import time
11
+ from dataclasses import dataclass
12
+
13
+ from PySide6.QtCore import QTimer, Signal
14
+ from PySide6.QtWidgets import (
15
+ QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QHBoxLayout,
16
+ QLabel, QLineEdit, QListWidget, QPushButton, QSpinBox, QVBoxLayout, QWidget,
17
+ )
18
+ from PySide6.QtCore import QSettings
19
+
20
+
21
+ @dataclass
22
+ class ScheduledTask:
23
+ name: str = ""
24
+ task_path: str = ""
25
+ interval_ms: int = 600000
26
+ enabled: bool = True
27
+ next_run: float = 0.0 # monotonic seconds
28
+
29
+
30
+ class SchedulerWidget(QWidget):
31
+ runTaskRequested = Signal(str)
32
+
33
+ def __init__(self, parent=None):
34
+ super().__init__(parent)
35
+ self._tasks: list[ScheduledTask] = []
36
+
37
+ lay = QVBoxLayout(self)
38
+ hint = QLabel("定时任务列表 — 自动按间隔执行已保存的任务文件")
39
+ hint.setStyleSheet("color: gray;")
40
+ lay.addWidget(hint)
41
+ self.list = QListWidget()
42
+ lay.addWidget(self.list)
43
+ row = QHBoxLayout()
44
+ for text, slot in [("添加定时任务", self._add), ("启用/禁用", self._toggle), ("删除", self._remove)]:
45
+ b = QPushButton(text)
46
+ b.clicked.connect(slot)
47
+ row.addWidget(b)
48
+ lay.addLayout(row)
49
+
50
+ self._timer = QTimer(self)
51
+ self._timer.setInterval(1000)
52
+ self._timer.timeout.connect(self._tick)
53
+ self._timer.start()
54
+ self._load_settings()
55
+
56
+ def _add(self):
57
+ dlg = QDialog(self)
58
+ dlg.setWindowTitle("添加定时任务")
59
+ dlg.setMinimumWidth(400)
60
+ form = QFormLayout(dlg)
61
+ path_edit = QLineEdit()
62
+ browse = QPushButton("浏览…")
63
+ prow = QHBoxLayout()
64
+ prow.addWidget(path_edit)
65
+ prow.addWidget(browse)
66
+ browse.clicked.connect(lambda: self._pick_path(dlg, path_edit))
67
+ form.addRow("任务文件:", prow)
68
+ interval = QSpinBox()
69
+ interval.setRange(1, 9999)
70
+ interval.setValue(10)
71
+ form.addRow("间隔:", interval)
72
+ unit = QComboBox()
73
+ unit.addItem("秒", 1000)
74
+ unit.addItem("分钟", 60000)
75
+ unit.addItem("小时", 3600000)
76
+ unit.setCurrentIndex(1)
77
+ form.addRow("单位:", unit)
78
+ bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
79
+ bb.accepted.connect(dlg.accept)
80
+ bb.rejected.connect(dlg.reject)
81
+ form.addRow(bb)
82
+ if dlg.exec() != QDialog.Accepted or not path_edit.text():
83
+ return
84
+ interval_ms = interval.value() * unit.currentData()
85
+ t = ScheduledTask(name=os.path.splitext(os.path.basename(path_edit.text()))[0],
86
+ task_path=path_edit.text(), interval_ms=interval_ms, enabled=True,
87
+ next_run=time.monotonic() + interval_ms / 1000.0)
88
+ self._tasks.append(t)
89
+ self._save_settings()
90
+ self._refresh()
91
+
92
+ def _pick_path(self, dlg, edit):
93
+ p, _ = QFileDialog.getOpenFileName(dlg, "选择任务文件", "", "JSON (*.json)")
94
+ if p:
95
+ edit.setText(p)
96
+
97
+ def _remove(self):
98
+ row = self.list.currentRow()
99
+ if 0 <= row < len(self._tasks):
100
+ del self._tasks[row]
101
+ self._save_settings()
102
+ self._refresh()
103
+
104
+ def _toggle(self):
105
+ row = self.list.currentRow()
106
+ if 0 <= row < len(self._tasks):
107
+ t = self._tasks[row]
108
+ t.enabled = not t.enabled
109
+ if t.enabled:
110
+ t.next_run = time.monotonic() + t.interval_ms / 1000.0
111
+ self._save_settings()
112
+ self._refresh()
113
+
114
+ def _tick(self):
115
+ now = time.monotonic()
116
+ for t in self._tasks:
117
+ if t.enabled and now >= t.next_run:
118
+ self.runTaskRequested.emit(t.task_path)
119
+ t.next_run = now + t.interval_ms / 1000.0
120
+
121
+ def _refresh(self):
122
+ self.list.clear()
123
+ for t in self._tasks:
124
+ secs = t.interval_ms // 1000
125
+ interval = (f"{secs // 3600}小时" if secs >= 3600
126
+ else f"{secs // 60}分钟" if secs >= 60 else f"{secs}秒")
127
+ status = "[启用]" if t.enabled else "[禁用]"
128
+ self.list.addItem(f"{status} {t.name} — 每{interval}")
129
+
130
+ # ---- persistence (QSettings, shared with C++) ----
131
+ def _load_settings(self):
132
+ s = QSettings("AutoClicker", "AutoClicker")
133
+ n = s.beginReadArray("scheduledTasks")
134
+ self._tasks.clear()
135
+ for i in range(n):
136
+ s.setArrayIndex(i)
137
+ interval_ms = int(s.value("interval", 600000))
138
+ self._tasks.append(ScheduledTask(
139
+ name=s.value("name", ""), task_path=s.value("path", ""),
140
+ interval_ms=interval_ms, enabled=_as_bool(s.value("enabled", True)),
141
+ next_run=time.monotonic() + interval_ms / 1000.0))
142
+ s.endArray()
143
+ self._refresh()
144
+
145
+ def _save_settings(self):
146
+ s = QSettings("AutoClicker", "AutoClicker")
147
+ s.beginWriteArray("scheduledTasks", len(self._tasks))
148
+ for i, t in enumerate(self._tasks):
149
+ s.setArrayIndex(i)
150
+ s.setValue("name", t.name)
151
+ s.setValue("path", t.task_path)
152
+ s.setValue("interval", t.interval_ms)
153
+ s.setValue("enabled", t.enabled)
154
+ s.endArray()
155
+
156
+
157
+ def _as_bool(v) -> bool:
158
+ if isinstance(v, bool):
159
+ return v
160
+ return str(v).lower() in ("1", "true", "yes")