kotonebot 0.5.0__py3-none-any.whl → 0.7.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.
Files changed (107) hide show
  1. kotonebot/__init__.py +39 -39
  2. kotonebot/backend/bot.py +312 -312
  3. kotonebot/backend/color.py +525 -525
  4. kotonebot/backend/context/__init__.py +3 -3
  5. kotonebot/backend/context/context.py +1002 -1002
  6. kotonebot/backend/context/task_action.py +183 -183
  7. kotonebot/backend/core.py +86 -129
  8. kotonebot/backend/debug/entry.py +89 -89
  9. kotonebot/backend/debug/mock.py +78 -78
  10. kotonebot/backend/debug/server.py +222 -222
  11. kotonebot/backend/debug/vars.py +351 -351
  12. kotonebot/backend/dispatch.py +227 -227
  13. kotonebot/backend/flow_controller.py +196 -196
  14. kotonebot/backend/image.py +36 -5
  15. kotonebot/backend/loop.py +222 -208
  16. kotonebot/backend/ocr.py +535 -535
  17. kotonebot/backend/preprocessor.py +103 -103
  18. kotonebot/client/__init__.py +9 -9
  19. kotonebot/client/device.py +369 -529
  20. kotonebot/client/fast_screenshot.py +377 -377
  21. kotonebot/client/host/__init__.py +43 -43
  22. kotonebot/client/host/adb_common.py +101 -107
  23. kotonebot/client/host/custom.py +118 -118
  24. kotonebot/client/host/leidian_host.py +196 -196
  25. kotonebot/client/host/mumu12_host.py +353 -353
  26. kotonebot/client/host/protocol.py +214 -214
  27. kotonebot/client/host/windows_common.py +73 -58
  28. kotonebot/client/implements/__init__.py +65 -70
  29. kotonebot/client/implements/adb.py +89 -89
  30. kotonebot/client/implements/nemu_ipc/__init__.py +11 -11
  31. kotonebot/client/implements/nemu_ipc/external_renderer_ipc.py +284 -284
  32. kotonebot/client/implements/nemu_ipc/nemu_ipc.py +327 -327
  33. kotonebot/client/implements/remote_windows.py +188 -188
  34. kotonebot/client/implements/uiautomator2.py +85 -85
  35. kotonebot/client/implements/windows/__init__.py +1 -0
  36. kotonebot/client/implements/windows/print_window.py +133 -0
  37. kotonebot/client/implements/windows/send_message.py +324 -0
  38. kotonebot/client/implements/{windows.py → windows/windows.py} +175 -176
  39. kotonebot/client/protocol.py +69 -69
  40. kotonebot/client/registration.py +24 -24
  41. kotonebot/client/scaler.py +467 -0
  42. kotonebot/config/base_config.py +103 -96
  43. kotonebot/config/config.py +61 -0
  44. kotonebot/config/manager.py +36 -36
  45. kotonebot/core/__init__.py +13 -0
  46. kotonebot/core/entities/base.py +182 -0
  47. kotonebot/core/entities/compound.py +75 -0
  48. kotonebot/core/entities/ocr.py +117 -0
  49. kotonebot/core/entities/template_match.py +198 -0
  50. kotonebot/devtools/__init__.py +42 -0
  51. kotonebot/devtools/cli/__init__.py +6 -0
  52. kotonebot/devtools/cli/main.py +53 -0
  53. kotonebot/{tools → devtools}/mirror.py +354 -354
  54. kotonebot/devtools/project/project.py +41 -0
  55. kotonebot/devtools/project/scanner.py +202 -0
  56. kotonebot/devtools/project/schema.py +99 -0
  57. kotonebot/devtools/resgen/__init__.py +42 -0
  58. kotonebot/devtools/resgen/codegen.py +331 -0
  59. kotonebot/devtools/resgen/core.py +94 -0
  60. kotonebot/devtools/resgen/parsers.py +360 -0
  61. kotonebot/devtools/resgen/utils.py +158 -0
  62. kotonebot/devtools/resgen/validation.py +115 -0
  63. kotonebot/devtools/web/dist/assets/bootstrap-icons-BOrJxbIo.woff +0 -0
  64. kotonebot/devtools/web/dist/assets/bootstrap-icons-BtvjY1KL.woff2 +0 -0
  65. kotonebot/devtools/web/dist/assets/ext-language_tools-CD021WJ2.js +2577 -0
  66. kotonebot/devtools/web/dist/assets/index-B_m5f2LF.js +2836 -0
  67. kotonebot/devtools/web/dist/assets/index-BlEDyGGa.css +9 -0
  68. kotonebot/devtools/web/dist/assets/language-client-C9muzqaq.js +128 -0
  69. kotonebot/devtools/web/dist/assets/mode-python-CtHp76XS.js +476 -0
  70. kotonebot/devtools/web/dist/icons/symbol-class.svg +3 -0
  71. kotonebot/devtools/web/dist/icons/symbol-file.svg +3 -0
  72. kotonebot/devtools/web/dist/icons/symbol-method.svg +3 -0
  73. kotonebot/devtools/web/dist/index.html +25 -0
  74. kotonebot/devtools/web/server/__init__.py +0 -0
  75. kotonebot/devtools/web/server/rest_api.py +217 -0
  76. kotonebot/devtools/web/server/server.py +85 -0
  77. kotonebot/errors.py +76 -76
  78. kotonebot/interop/win/__init__.py +13 -9
  79. kotonebot/interop/win/_mouse.py +310 -310
  80. kotonebot/interop/win/message_box.py +313 -313
  81. kotonebot/interop/win/reg.py +37 -37
  82. kotonebot/interop/win/shake_mouse.py +224 -0
  83. kotonebot/interop/win/shortcut.py +43 -43
  84. kotonebot/interop/win/task_dialog.py +513 -513
  85. kotonebot/interop/win/window.py +89 -0
  86. kotonebot/logging/__init__.py +2 -2
  87. kotonebot/logging/log.py +17 -17
  88. kotonebot/primitives/__init__.py +19 -17
  89. kotonebot/primitives/geometry.py +1067 -862
  90. kotonebot/primitives/visual.py +143 -63
  91. kotonebot/ui/file_host/sensio.py +36 -36
  92. kotonebot/ui/file_host/tmp_send.py +54 -54
  93. kotonebot/ui/pushkit/__init__.py +3 -3
  94. kotonebot/ui/pushkit/image_host.py +88 -88
  95. kotonebot/ui/pushkit/protocol.py +13 -13
  96. kotonebot/ui/pushkit/wxpusher.py +54 -54
  97. kotonebot/ui/user.py +148 -148
  98. kotonebot/util.py +436 -436
  99. {kotonebot-0.5.0.dist-info → kotonebot-0.7.0.dist-info}/METADATA +84 -82
  100. kotonebot-0.7.0.dist-info/RECORD +109 -0
  101. {kotonebot-0.5.0.dist-info → kotonebot-0.7.0.dist-info}/WHEEL +1 -1
  102. kotonebot-0.7.0.dist-info/entry_points.txt +2 -0
  103. {kotonebot-0.5.0.dist-info → kotonebot-0.7.0.dist-info}/licenses/LICENSE +673 -673
  104. kotonebot/client/implements/adb_raw.py +0 -163
  105. kotonebot-0.5.0.dist-info/RECORD +0 -71
  106. /kotonebot/{tools → devtools/project}/__init__.py +0 -0
  107. {kotonebot-0.5.0.dist-info → kotonebot-0.7.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,133 @@
1
+ # ruff: noqa: E402
2
+ from kotonebot.util import require_windows
3
+ require_windows('"WindowsImpl" implementation')
4
+
5
+ import ctypes
6
+ import ctypes.wintypes as wt
7
+ from typing import TYPE_CHECKING, Literal
8
+
9
+ import cv2
10
+ from cv2.typing import MatLike
11
+ import numpy as np
12
+ import win32ui
13
+ import win32con
14
+ import win32gui
15
+
16
+ from ...protocol import Screenshotable
17
+ from kotonebot.interop.win.window import Win32Window
18
+ if TYPE_CHECKING:
19
+ from ...device import Device
20
+
21
+ PW_CLIENTONLY = 0x1
22
+ PW_RENDERFULLCONTENT = 0x2
23
+
24
+ # TODO: 目前每次截图都会完整创建和销毁 GDI 对象,性能较差,后续可以考虑缓存这些对象以提升性能
25
+ # TODO: 需要先支持 Impl 的生命周期管理
26
+ def capture_printwindow(hwnd: int) -> MatLike:
27
+ # client rect size
28
+ left, top, right, bottom = win32gui.GetClientRect(hwnd)
29
+ width, height = right - left, bottom - top
30
+ if width <= 0 or height <= 0:
31
+ raise RuntimeError("invalid client size")
32
+
33
+ hdc = win32gui.GetDC(hwnd)
34
+ mfc_dc = win32ui.CreateDCFromHandle(hdc)
35
+ mem_dc = mfc_dc.CreateCompatibleDC()
36
+ bmp = win32ui.CreateBitmap()
37
+ bmp.CreateCompatibleBitmap(mfc_dc, width, height)
38
+ mem_dc.SelectObject(bmp)
39
+
40
+ flags = PW_CLIENTONLY | PW_RENDERFULLCONTENT
41
+ res = ctypes.windll.user32.PrintWindow(hwnd, mem_dc.GetSafeHdc(), flags)
42
+ if res != 1:
43
+ raise RuntimeError("PrintWindow failed")
44
+
45
+ # extract BGRA bits via GetDIBits (pywin32 does not expose BITMAPINFO)
46
+ class BITMAPINFOHEADER(ctypes.Structure):
47
+ _fields_ = [
48
+ ("biSize", wt.DWORD),
49
+ ("biWidth", wt.LONG),
50
+ ("biHeight", wt.LONG),
51
+ ("biPlanes", wt.WORD),
52
+ ("biBitCount", wt.WORD),
53
+ ("biCompression", wt.DWORD),
54
+ ("biSizeImage", wt.DWORD),
55
+ ("biXPelsPerMeter", wt.LONG),
56
+ ("biYPelsPerMeter", wt.LONG),
57
+ ("biClrUsed", wt.DWORD),
58
+ ("biClrImportant", wt.DWORD),
59
+ ]
60
+
61
+ class BITMAPINFO(ctypes.Structure):
62
+ _fields_ = [("bmiHeader", BITMAPINFOHEADER), ("bmiColors", wt.DWORD * 3)]
63
+
64
+ bmi = BITMAPINFO()
65
+ bmi.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER)
66
+ bmi.bmiHeader.biWidth = width
67
+ bmi.bmiHeader.biHeight = -height # top-down
68
+ bmi.bmiHeader.biPlanes = 1
69
+ bmi.bmiHeader.biBitCount = 32
70
+ bmi.bmiHeader.biCompression = win32con.BI_RGB
71
+
72
+ buf = bytearray(width * height * 4)
73
+ bits_ok = ctypes.windll.gdi32.GetDIBits(
74
+ mem_dc.GetSafeHdc(),
75
+ bmp.GetHandle(),
76
+ 0,
77
+ height,
78
+ ctypes.byref((ctypes.c_ubyte * len(buf)).from_buffer(buf)),
79
+ ctypes.byref(bmi),
80
+ win32con.DIB_RGB_COLORS,
81
+ )
82
+ if bits_ok == 0:
83
+ raise RuntimeError("GetDIBits failed")
84
+
85
+ img = np.frombuffer(buf, dtype=np.uint8).reshape((height, width, 4))
86
+ img = cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
87
+
88
+ # cleanup
89
+ win32gui.DeleteObject(bmp.GetHandle())
90
+ mem_dc.DeleteDC()
91
+ mfc_dc.DeleteDC()
92
+ win32gui.ReleaseDC(hwnd, hdc)
93
+ return img
94
+
95
+
96
+ class PrintWindowImpl(Screenshotable):
97
+ def __init__(self, device: 'Device', window_title: str):
98
+ self.window = Win32Window.require_window('title', window_title)
99
+ ctypes.windll.user32.SetProcessDPIAware()
100
+
101
+ def __client_rect(self) -> tuple[int, int, int, int]:
102
+ """获取 Client 区域屏幕坐标"""
103
+ hwnd = self.window.hwnd
104
+ client_left, client_top, client_right, client_bottom = win32gui.GetClientRect(hwnd)
105
+ client_left, client_top = win32gui.ClientToScreen(hwnd, (client_left, client_top))
106
+ client_right, client_bottom = win32gui.ClientToScreen(hwnd, (client_right, client_bottom))
107
+ return client_left, client_top, client_right, client_bottom
108
+
109
+ def detect_orientation(self) -> None | Literal['portrait'] | Literal['landscape']:
110
+ rect = self.window.get_rect()
111
+ if rect.w > rect.h:
112
+ return 'landscape'
113
+ else:
114
+ return 'portrait'
115
+
116
+ @property
117
+ def screen_size(self) -> tuple[int, int]:
118
+ left, top, right, bot = self.__client_rect()
119
+ w = right - left
120
+ h = bot - top
121
+ return w, h
122
+
123
+ def screenshot(self) -> MatLike:
124
+ if self.window.is_minimized():
125
+ self.window.restore()
126
+ return capture_printwindow(self.window.hwnd)
127
+
128
+ if __name__ == "__main__":
129
+ impl = PrintWindowImpl(None, "gakumas") # type: ignore
130
+ while True:
131
+ img = impl.screenshot()
132
+ cv2.imshow("screenshot", img)
133
+ cv2.waitKey(1)
@@ -0,0 +1,324 @@
1
+ # ruff: noqa: E402
2
+ from kotonebot.util import require_windows
3
+ require_windows('"WindowsImpl" implementation')
4
+
5
+ import time
6
+ from time import sleep
7
+ from typing_extensions import assert_never
8
+ from typing import Optional, Literal, TYPE_CHECKING
9
+
10
+ import win32gui
11
+ import win32con
12
+
13
+ from ...protocol import Touchable
14
+ from kotonebot.interop.win.window import Win32Window
15
+ if TYPE_CHECKING:
16
+ from ...device import Device
17
+
18
+ MouseButton = Literal['left', 'right', 'middle']
19
+
20
+ def _make_lparam(x: int, y: int) -> int:
21
+ """
22
+ 创建 LPARAM 参数,打包 x,y 坐标
23
+
24
+ :param x: X 坐标
25
+ :param y: Y 坐标
26
+ :returns: 打包的 LPARAM 值
27
+ """
28
+ return (y << 16) | (x & 0xFFFF)
29
+
30
+
31
+ def _make_wparam(button_data: int, wheel_delta: int = 0) -> int:
32
+ """
33
+ 创建 WPARAM 参数,用于滚轮消息
34
+
35
+ :param button_data: 按钮数据
36
+ :param wheel_delta: 滚轮增量
37
+ :returns: 打包的 WPARAM 值
38
+ """
39
+ return (wheel_delta << 16) | (button_data & 0xFFFF)
40
+
41
+ def _wait_cursor_idle(max_speed: float = 50):
42
+ if max_speed <= 0:
43
+ return
44
+ sample_interval = 0.05
45
+ prev_pos = win32gui.GetCursorPos()
46
+ prev_t = time.monotonic()
47
+
48
+ while True:
49
+ sleep(sample_interval)
50
+ cur_pos = win32gui.GetCursorPos()
51
+ cur_t = time.monotonic()
52
+
53
+ dx = cur_pos[0] - prev_pos[0]
54
+ dy = cur_pos[1] - prev_pos[1]
55
+ dist = (dx * dx + dy * dy) ** 0.5
56
+ dt = cur_t - prev_t
57
+ speed = dist / dt if dt > 0 else float('inf')
58
+ if speed <= max_speed:
59
+ return
60
+
61
+ prev_pos = cur_pos
62
+ prev_t = cur_t
63
+
64
+ class SendMessageWrapper:
65
+ def __init__(self, window: Win32Window, wait_cursor_idle: float = -1):
66
+ self.window = window
67
+ self.last_pos = (0, 0)
68
+ self.last_pos_set = False
69
+ if wait_cursor_idle == -1:
70
+ self.wait_cursor_idle_speed = 50 # 默认值
71
+ else:
72
+ self.wait_cursor_idle_speed = wait_cursor_idle
73
+
74
+ @property
75
+ def hwnd(self) -> int:
76
+ return self.window.hwnd
77
+
78
+ def _send_activate(self):
79
+ self.window.post_message(win32con.WM_ACTIVATE, win32con.WA_ACTIVE, 0)
80
+
81
+ def _align_window(self, target_client_x: int, target_client_y: int) -> bool:
82
+ """
83
+ 移动窗口,使得目标客户区坐标对齐到指定的光标位置。
84
+ 如果提供 `cursor_pos` 则使用该屏幕坐标(例如预测值),否则使用真实光标位置。
85
+ """
86
+ window_rect = self.window.get_rect()
87
+ if not window_rect:
88
+ return False
89
+
90
+ cursor_x, cursor_y = win32gui.GetCursorPos()
91
+ # 计算客户区偏移
92
+ client_left, client_top, client_right, client_bottom = win32gui.GetClientRect(self.hwnd)
93
+ client_screen_left, client_screen_top = win32gui.ClientToScreen(self.hwnd, (client_left, client_top))
94
+ offset_x = client_screen_left - window_rect.x1
95
+ offset_y = client_screen_top - window_rect.y1
96
+
97
+ new_window_x = cursor_x - target_client_x - offset_x
98
+ new_window_y = cursor_y - target_client_y - offset_y
99
+
100
+ win32gui.SetWindowPos(
101
+ self.hwnd,
102
+ None,
103
+ new_window_x,
104
+ new_window_y,
105
+ window_rect.w,
106
+ window_rect.h,
107
+ win32con.SWP_NOREDRAW | win32con.SWP_NOACTIVATE |
108
+ win32con.SWP_NOZORDER | win32con.SWP_NOCOPYBITS |
109
+ win32con.SWP_NOSENDCHANGING | win32con.SWP_NOOWNERZORDER
110
+ )
111
+
112
+ return True
113
+
114
+ def _send_mouse_button(self, x: int, y: int, button: MouseButton, down: bool) -> bool:
115
+ if down:
116
+ match button:
117
+ case 'left':
118
+ msg = win32con.WM_LBUTTONDOWN
119
+ w_param = win32con.MK_LBUTTON
120
+ case 'right':
121
+ msg = win32con.WM_RBUTTONDOWN
122
+ w_param = win32con.MK_RBUTTON
123
+ case 'middle':
124
+ msg = win32con.WM_MBUTTONDOWN
125
+ w_param = win32con.MK_MBUTTON
126
+ case _:
127
+ assert_never("Unknown mouse button")
128
+ else:
129
+ match button:
130
+ case 'left':
131
+ msg = win32con.WM_LBUTTONUP
132
+ w_param = 0
133
+ case 'right':
134
+ msg = win32con.WM_RBUTTONUP
135
+ w_param = 0
136
+ case 'middle':
137
+ msg = win32con.WM_MBUTTONUP
138
+ w_param = 0
139
+ case _:
140
+ assert_never("Unknown mouse button")
141
+
142
+ l_param = _make_lparam(x, y)
143
+ return self.window.post_message(msg, w_param, l_param)
144
+
145
+ def _send_mouse_move(self, x: int, y: int, button: Optional[MouseButton] = None) -> bool:
146
+ """
147
+ 发送鼠标移动消息,支持在按键按下时携带对应的 wParam 标志(例如拖拽时带 MK_LBUTTON)。
148
+ """
149
+
150
+ if button == 'left':
151
+ w_param = win32con.MK_LBUTTON
152
+ elif button == 'right':
153
+ w_param = win32con.MK_RBUTTON
154
+ elif button == 'middle':
155
+ w_param = win32con.MK_MBUTTON
156
+ else:
157
+ w_param = 0
158
+
159
+ l_param = _make_lparam(x, y)
160
+ return self.window.post_message(win32con.WM_MOUSEMOVE, w_param, l_param)
161
+
162
+ def mouse_down(self, x: int, y: int, button: MouseButton) -> bool:
163
+ """
164
+ 发送鼠标按下消息
165
+ button: 0=左键, 1=右键, 2=中键
166
+
167
+ :param x: X 坐标
168
+ :param y: Y 坐标
169
+ :param button: 按钮类型,0=左键, 1=右键, 2=中键
170
+ :returns: 操作是否成功
171
+ """
172
+ self._send_activate()
173
+ self._align_window(x, y)
174
+ return self._send_mouse_button(x, y, button, down=True)
175
+
176
+ def mouse_up(self, x: int, y: int, button: MouseButton) -> bool:
177
+ """
178
+ 发送鼠标释放消息
179
+ button: 0=左键, 1=右键, 2=中键
180
+
181
+ :param x: X 坐标
182
+ :param y: Y 坐标
183
+ :param button: 按钮类型,0=左键, 1=右键, 2=中键
184
+ :returns: 操作是否成功
185
+ """
186
+ self._send_activate()
187
+ self._align_window(x, y)
188
+ return self._send_mouse_button(x, y, button, down=False)
189
+
190
+ def click(self, x: int, y: int, *, button: MouseButton = 'left') -> bool:
191
+ """
192
+ 发送点击事件。
193
+
194
+ :param x: X 坐标
195
+ :param y: Y 坐标
196
+ :param button: 按钮类型,0=左键, 1=右键, 2=中键
197
+ :returns: 操作是否成功
198
+ """
199
+ # 为避免在一次点击操作中重复激活 -> 先激活一次,然后在 down/up 中禁用额外激活
200
+ self._send_activate()
201
+ _wait_cursor_idle(self.wait_cursor_idle_speed)
202
+ self._align_window(x, y)
203
+ if self._send_mouse_button(x, y, button, down=True):
204
+ return self._send_mouse_button(x, y, button, down=False)
205
+ return False
206
+
207
+ def keyboard_down(self, key_code: int) -> bool:
208
+ """
209
+ 发送键盘按下消息
210
+
211
+ :param key_code: 虚拟键码,例如 win32con.VK_RETURN 等
212
+ :returns: 操作是否成功
213
+ """
214
+ # 发送激活消息
215
+ self._send_activate()
216
+
217
+ # 发送 WM_KEYDOWN 消息
218
+ result = self.window.post_message(win32con.WM_KEYDOWN, key_code, 0)
219
+ return result
220
+
221
+ def keyboard_up(self, key_code: int) -> bool:
222
+ """
223
+ 发送键盘释放消息
224
+
225
+ :param key_code: 虚拟键码,例如 win32con.VK_RETURN 等
226
+ :returns: 操作是否成功
227
+ """
228
+ # 发送激活消息
229
+ self._send_activate()
230
+ return self.window.post_message(win32con.WM_KEYUP, key_code, 0)
231
+
232
+ def drag(
233
+ self,
234
+ x1: int, y1: int,
235
+ x2: int, y2: int,
236
+ *,
237
+ button: MouseButton = 'left',
238
+ duration: float | None = None
239
+ ) -> bool:
240
+ """
241
+ 从指定点拖拽到指定点。
242
+
243
+ :param x1: 起始点 X 坐标,相对于客户区。
244
+ :param y1: 起始点 Y 坐标,相对于客户区。
245
+ :param x2: 结束点 X 坐标,相对于客户区。
246
+ :param y2: 结束点 Y 坐标,相对于客户区。
247
+ :param button: 按钮类型,'left'、'right'、'middle'。
248
+ :returns: 操作是否成功
249
+ """
250
+ if duration is None:
251
+ duration = 0.5
252
+
253
+ self._send_activate()
254
+ # 将窗口对齐到起点,确保起始客户区坐标与当前光标对齐
255
+ self._align_window(x1, y1)
256
+
257
+ # 发送按下
258
+ if not self._send_mouse_button(x1, y1, button, down=True):
259
+ return False
260
+
261
+ # 如果 duration 为 0 或负数,直接跳到结束点并释放
262
+ if not duration or duration <= 0:
263
+ # 最后对齐到结束点以保证位置精确
264
+ self._align_window(x2, y2)
265
+ return self._send_mouse_button(x2, y2, button, down=False)
266
+
267
+ # 分段发送移动事件,避免直接跳到结束点
268
+ # 使用 60Hz 作为默认帧率,至少 1 步
269
+ fps = 60
270
+ steps = max(1, int(duration * fps))
271
+ interval = duration / steps - (13 / 1000) # 减去约 13ms 的消息处理时间
272
+
273
+ dx = x2 - x1
274
+ dy = y2 - y1
275
+
276
+ # 从 1 到 steps(包含终点)进行插值并发送 WM_MOUSEMOVE
277
+ for i in range(1, steps + 1):
278
+ t = i / steps
279
+ xi = int(x1 + dx * t)
280
+ yi = int(y1 + dy * t)
281
+ # 发送移动事件,保留当前按键状态
282
+ self._send_mouse_move(xi, yi, button=button)
283
+ self._align_window(xi, yi)
284
+ if interval > 0:
285
+ sleep(interval)
286
+
287
+ # 对齐到结束点并发送释放
288
+ self._align_window(x2, y2)
289
+ return self._send_mouse_button(x2, y2, button, down=False)
290
+
291
+ def drag_by(
292
+ self,
293
+ x: int, y: int,
294
+ dx: int, dy: int,
295
+ *,
296
+ button: MouseButton = 'left',
297
+ duration: float | None = None
298
+ ) -> bool:
299
+ end_x = x + dx
300
+ end_y = y + dy
301
+ return self.drag(x, y, end_x, end_y, button=button, duration=duration)
302
+
303
+ class SendMessageImpl(Touchable):
304
+ def __init__(self, device: 'Device', window_title: str, *, wait_cursor_idle: float = -1) -> None:
305
+ self.device = device
306
+ window = Win32Window.require_window('title', window_title)
307
+ self.wrapper = SendMessageWrapper(window, wait_cursor_idle)
308
+
309
+ def click(self, x: int, y: int) -> None:
310
+ self.wrapper.click(x, y, button='left')
311
+
312
+ def swipe(self, x1: int, y1: int, x2: int, y2: int, duration: float | None = None) -> None:
313
+ ret = self.wrapper.drag(x1, y1, x2, y2, button='left', duration=duration)
314
+ if not ret:
315
+ raise RuntimeError('Swipe operation failed')
316
+
317
+
318
+ if __name__ == '__main__':
319
+ # impl = SendMessageImpl(None, window_title='gakumas') # type: ignore
320
+ # impl.click(0, 0)
321
+
322
+ while True:
323
+ _wait_cursor_idle()
324
+ print("Cursor idle detected")