kotonebot 0.4.0__py3-none-any.whl → 0.6.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.
- kotonebot/backend/context/context.py +1002 -1002
- kotonebot/backend/core.py +6 -49
- kotonebot/backend/image.py +36 -5
- kotonebot/backend/loop.py +222 -208
- kotonebot/backend/ocr.py +7 -1
- kotonebot/client/device.py +108 -243
- kotonebot/client/host/__init__.py +34 -3
- kotonebot/client/host/adb_common.py +7 -9
- kotonebot/client/host/custom.py +6 -2
- kotonebot/client/host/leidian_host.py +2 -7
- kotonebot/client/host/mumu12_host.py +2 -7
- kotonebot/client/host/protocol.py +4 -3
- kotonebot/client/implements/__init__.py +62 -11
- kotonebot/client/implements/adb.py +5 -1
- kotonebot/client/implements/nemu_ipc/__init__.py +4 -0
- kotonebot/client/implements/uiautomator2.py +6 -2
- kotonebot/client/implements/windows.py +7 -3
- kotonebot/client/registration.py +1 -1
- kotonebot/client/scaler.py +467 -0
- kotonebot/config/base_config.py +1 -1
- kotonebot/config/config.py +61 -0
- kotonebot/core/__init__.py +13 -0
- kotonebot/core/entities/base.py +182 -0
- kotonebot/core/entities/compound.py +75 -0
- kotonebot/core/entities/ocr.py +117 -0
- kotonebot/core/entities/template_match.py +198 -0
- kotonebot/devtools/__init__.py +42 -0
- kotonebot/devtools/cli/__init__.py +6 -0
- kotonebot/devtools/cli/main.py +53 -0
- kotonebot/devtools/project/project.py +41 -0
- kotonebot/devtools/project/scanner.py +202 -0
- kotonebot/devtools/project/schema.py +99 -0
- kotonebot/devtools/resgen/__init__.py +42 -0
- kotonebot/devtools/resgen/codegen.py +331 -0
- kotonebot/devtools/resgen/core.py +94 -0
- kotonebot/devtools/resgen/parsers.py +360 -0
- kotonebot/devtools/resgen/utils.py +158 -0
- kotonebot/devtools/resgen/validation.py +115 -0
- kotonebot/devtools/web/dist/assets/bootstrap-icons-BOrJxbIo.woff +0 -0
- kotonebot/devtools/web/dist/assets/bootstrap-icons-BtvjY1KL.woff2 +0 -0
- kotonebot/devtools/web/dist/assets/ext-language_tools-CD021WJ2.js +2577 -0
- kotonebot/devtools/web/dist/assets/index-B_m5f2LF.js +2836 -0
- kotonebot/devtools/web/dist/assets/index-BlEDyGGa.css +9 -0
- kotonebot/devtools/web/dist/assets/language-client-C9muzqaq.js +128 -0
- kotonebot/devtools/web/dist/assets/mode-python-CtHp76XS.js +476 -0
- kotonebot/devtools/web/dist/icons/symbol-class.svg +3 -0
- kotonebot/devtools/web/dist/icons/symbol-file.svg +3 -0
- kotonebot/devtools/web/dist/icons/symbol-method.svg +3 -0
- kotonebot/devtools/web/dist/index.html +25 -0
- kotonebot/devtools/web/server/__init__.py +0 -0
- kotonebot/devtools/web/server/rest_api.py +217 -0
- kotonebot/devtools/web/server/server.py +85 -0
- kotonebot/errors.py +7 -2
- kotonebot/interop/win/__init__.py +10 -1
- kotonebot/interop/win/_mouse.py +311 -0
- kotonebot/interop/win/shake_mouse.py +224 -0
- kotonebot/primitives/__init__.py +3 -1
- kotonebot/primitives/geometry.py +817 -40
- kotonebot/primitives/visual.py +81 -1
- kotonebot/ui/pushkit/image_host.py +2 -1
- kotonebot/ui/pushkit/wxpusher.py +2 -1
- {kotonebot-0.4.0.dist-info → kotonebot-0.6.0.dist-info}/METADATA +4 -1
- kotonebot-0.6.0.dist-info/RECORD +105 -0
- kotonebot-0.6.0.dist-info/entry_points.txt +2 -0
- kotonebot/client/implements/adb_raw.py +0 -159
- kotonebot-0.4.0.dist-info/RECORD +0 -70
- /kotonebot/{tools → devtools}/mirror.py +0 -0
- /kotonebot/{tools → devtools/project}/__init__.py +0 -0
- {kotonebot-0.4.0.dist-info → kotonebot-0.6.0.dist-info}/WHEEL +0 -0
- {kotonebot-0.4.0.dist-info → kotonebot-0.6.0.dist-info}/licenses/LICENSE +0 -0
- {kotonebot-0.4.0.dist-info → kotonebot-0.6.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import logging
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, TypeVar, Generic, Optional
|
|
5
|
+
|
|
6
|
+
import cv2
|
|
7
|
+
from fastapi import APIRouter, HTTPException, Query, Body
|
|
8
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
from pydantic.generics import GenericModel
|
|
11
|
+
|
|
12
|
+
from kotonebot.devtools.project.project import Project
|
|
13
|
+
from kotonebot.devtools.project.scanner import scan_prefabs
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ResponseModel(GenericModel, Generic[T]):
|
|
20
|
+
success: bool
|
|
21
|
+
message: Optional[str] = None
|
|
22
|
+
data: Optional[T] = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class WriteTextRequest(BaseModel):
|
|
26
|
+
content: str
|
|
27
|
+
|
|
28
|
+
def create_rest_router(project: Project) -> APIRouter:
|
|
29
|
+
router = APIRouter(prefix="/api")
|
|
30
|
+
_prefabs_cache = None
|
|
31
|
+
|
|
32
|
+
project_root = Path(project.conf_path).parent.resolve()
|
|
33
|
+
thumbnail_cache_root = project_root / ".kotonebot" / "cache" / "thumbnails"
|
|
34
|
+
image_suffixes = {".png", ".jpg", ".jpeg", ".bmp", ".webp"}
|
|
35
|
+
|
|
36
|
+
def _is_image_file(path: Path) -> bool:
|
|
37
|
+
return path.suffix.lower() in image_suffixes
|
|
38
|
+
|
|
39
|
+
def _get_thumbnail_path(source: Path, size: int) -> Path:
|
|
40
|
+
if size <= 0:
|
|
41
|
+
raise ValueError("size must be positive")
|
|
42
|
+
try:
|
|
43
|
+
rel = source.resolve().relative_to(project_root)
|
|
44
|
+
except Exception as e:
|
|
45
|
+
raise ValueError(str(e))
|
|
46
|
+
size_dir = thumbnail_cache_root / str(size)
|
|
47
|
+
target_dir = size_dir / rel.parent
|
|
48
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
return target_dir / rel.name
|
|
50
|
+
|
|
51
|
+
def _ensure_thumbnail(source: Path, size: int) -> Path:
|
|
52
|
+
cache_path = _get_thumbnail_path(source, size)
|
|
53
|
+
regenerate = True
|
|
54
|
+
if cache_path.exists():
|
|
55
|
+
src_stat = source.stat()
|
|
56
|
+
cache_stat = cache_path.stat()
|
|
57
|
+
if cache_stat.st_mtime >= src_stat.st_mtime and cache_stat.st_size > 0:
|
|
58
|
+
regenerate = False
|
|
59
|
+
if regenerate:
|
|
60
|
+
img = cv2.imread(str(source))
|
|
61
|
+
if img is None:
|
|
62
|
+
raise ValueError(f"Could not read image: {source}")
|
|
63
|
+
height, width = img.shape[:2]
|
|
64
|
+
longest = max(width, height)
|
|
65
|
+
if longest <= 0:
|
|
66
|
+
raise ValueError("invalid image size")
|
|
67
|
+
scale = size / float(longest)
|
|
68
|
+
new_width = max(1, int(round(width * scale)))
|
|
69
|
+
new_height = max(1, int(round(height * scale)))
|
|
70
|
+
resized = cv2.resize(img, (new_width, new_height), interpolation=cv2.INTER_AREA)
|
|
71
|
+
cv2.imwrite(str(cache_path), resized)
|
|
72
|
+
return cache_path
|
|
73
|
+
|
|
74
|
+
def _get_safe_path(path_str: str) -> Path:
|
|
75
|
+
p = Path(path_str)
|
|
76
|
+
if not p.is_absolute():
|
|
77
|
+
p = project_root / p
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
p = p.resolve()
|
|
81
|
+
if not str(p).startswith(str(project_root)):
|
|
82
|
+
raise ValueError(f"Access denied: Path {p} is outside project root {project_root}")
|
|
83
|
+
except Exception as e:
|
|
84
|
+
raise ValueError(f"Invalid path: {e}")
|
|
85
|
+
|
|
86
|
+
return p
|
|
87
|
+
|
|
88
|
+
def _ok(data: Any = None, message: Optional[str] = None) -> JSONResponse:
|
|
89
|
+
return JSONResponse(ResponseModel[Any](success=True, message=message, data=data).dict())
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _err(message: str) -> JSONResponse:
|
|
93
|
+
return JSONResponse(ResponseModel[Any](success=False, message=message, data=None).dict())
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@router.get("/project/root")
|
|
97
|
+
async def get_project_root():
|
|
98
|
+
try:
|
|
99
|
+
data: dict = {"resource_root": str(project_root)}
|
|
100
|
+
# include editor configuration if available (prefabs_module, resource_path)
|
|
101
|
+
try:
|
|
102
|
+
if project.conf and project.conf.editor:
|
|
103
|
+
data["editor"] = project.conf.editor.model_dump()
|
|
104
|
+
except Exception:
|
|
105
|
+
logging.exception("Failed to include editor config in /project/root response")
|
|
106
|
+
|
|
107
|
+
return _ok(data)
|
|
108
|
+
except Exception as e:
|
|
109
|
+
logging.exception("Error while handling /project/root")
|
|
110
|
+
return _err(str(e))
|
|
111
|
+
|
|
112
|
+
@router.get("/fs/list_dir")
|
|
113
|
+
async def list_dir(path: str = Query(..., description="Path relative to project root or absolute path")):
|
|
114
|
+
try:
|
|
115
|
+
safe_path = _get_safe_path(path)
|
|
116
|
+
if not safe_path.exists():
|
|
117
|
+
return _err("Path not found")
|
|
118
|
+
if not safe_path.is_dir():
|
|
119
|
+
return _err("Not a directory")
|
|
120
|
+
|
|
121
|
+
items = []
|
|
122
|
+
entries = sorted(list(safe_path.iterdir()), key=lambda x: (not x.is_dir(), x.name.lower()))
|
|
123
|
+
for item in entries:
|
|
124
|
+
is_image = _is_image_file(item) if item.is_file() else False
|
|
125
|
+
thumbnail_url: Optional[str]
|
|
126
|
+
if is_image:
|
|
127
|
+
thumbnail_url = f"/api/image/thumbnail?path={item}&size=128"
|
|
128
|
+
else:
|
|
129
|
+
thumbnail_url = None
|
|
130
|
+
items.append({
|
|
131
|
+
"name": item.name,
|
|
132
|
+
"isDirectory": item.is_dir(),
|
|
133
|
+
"path": str(item),
|
|
134
|
+
"isImage": is_image,
|
|
135
|
+
"thumbnailUrl": thumbnail_url,
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
return _ok({"items": items})
|
|
139
|
+
except PermissionError:
|
|
140
|
+
return _err("Permission denied")
|
|
141
|
+
except Exception as e:
|
|
142
|
+
return _err(str(e))
|
|
143
|
+
|
|
144
|
+
@router.get("/fs/read_text")
|
|
145
|
+
async def read_text(path: str = Query(...)):
|
|
146
|
+
try:
|
|
147
|
+
safe_path = _get_safe_path(path)
|
|
148
|
+
if not safe_path.exists():
|
|
149
|
+
return _err("File not found")
|
|
150
|
+
if not safe_path.is_file():
|
|
151
|
+
return _err("Not a file")
|
|
152
|
+
|
|
153
|
+
content = safe_path.read_text(encoding="utf-8")
|
|
154
|
+
return _ok({"content": content})
|
|
155
|
+
except Exception as e:
|
|
156
|
+
return _err(str(e))
|
|
157
|
+
|
|
158
|
+
@router.put("/fs/write_text")
|
|
159
|
+
async def write_text(path: str = Query(...), body: WriteTextRequest = Body(...)):
|
|
160
|
+
try:
|
|
161
|
+
safe_path = _get_safe_path(path)
|
|
162
|
+
if not safe_path.parent.exists():
|
|
163
|
+
return _err("Parent directory does not exist")
|
|
164
|
+
|
|
165
|
+
temp_path = safe_path.with_suffix(safe_path.suffix + ".tmp")
|
|
166
|
+
temp_path.write_text(body.content, encoding="utf-8")
|
|
167
|
+
os.replace(temp_path, safe_path)
|
|
168
|
+
return _ok({"status": "ok"})
|
|
169
|
+
except Exception as e:
|
|
170
|
+
return _err(str(e))
|
|
171
|
+
|
|
172
|
+
@router.get("/image")
|
|
173
|
+
async def get_image(path: str = Query(...)):
|
|
174
|
+
safe_path = _get_safe_path(path)
|
|
175
|
+
if not safe_path.exists():
|
|
176
|
+
raise HTTPException(status_code=404, detail="Image not found")
|
|
177
|
+
|
|
178
|
+
if not _is_image_file(safe_path):
|
|
179
|
+
raise HTTPException(status_code=400, detail="Not an image file")
|
|
180
|
+
|
|
181
|
+
return FileResponse(safe_path)
|
|
182
|
+
|
|
183
|
+
@router.get("/image/thumbnail")
|
|
184
|
+
async def get_image_thumbnail(path: str = Query(...), size: int = Query(128, ge=1, le=2048)):
|
|
185
|
+
safe_path = _get_safe_path(path)
|
|
186
|
+
if not safe_path.exists():
|
|
187
|
+
raise HTTPException(status_code=404, detail="Image not found")
|
|
188
|
+
if not _is_image_file(safe_path):
|
|
189
|
+
raise HTTPException(status_code=400, detail="Not an image file")
|
|
190
|
+
try:
|
|
191
|
+
cache_path = _ensure_thumbnail(safe_path, size)
|
|
192
|
+
except ValueError as e:
|
|
193
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
194
|
+
return FileResponse(cache_path)
|
|
195
|
+
|
|
196
|
+
@router.get("/prefabs/schema")
|
|
197
|
+
async def get_prefabs_schema():
|
|
198
|
+
nonlocal _prefabs_cache
|
|
199
|
+
try:
|
|
200
|
+
if _prefabs_cache is not None:
|
|
201
|
+
return _ok(_prefabs_cache)
|
|
202
|
+
|
|
203
|
+
if not project.conf or not project.conf.editor or not project.conf.editor.prefabs_module:
|
|
204
|
+
return _ok({"version": 1, "prefabs": {}})
|
|
205
|
+
|
|
206
|
+
schema = scan_prefabs(project.conf.editor.prefabs_module)
|
|
207
|
+
_prefabs_cache = schema
|
|
208
|
+
return _ok(schema)
|
|
209
|
+
except Exception as e:
|
|
210
|
+
return _err(str(e))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@router.get("/health")
|
|
214
|
+
async def health_check():
|
|
215
|
+
return _ok({"status": "ok", "service": "kotonebot-devtools"})
|
|
216
|
+
|
|
217
|
+
return router
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import webbrowser
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from fastapi import FastAPI
|
|
5
|
+
from fastapi.staticfiles import StaticFiles
|
|
6
|
+
from fastapi.responses import JSONResponse, HTMLResponse
|
|
7
|
+
import uvicorn
|
|
8
|
+
|
|
9
|
+
from kotonebot.devtools.project.project import Project
|
|
10
|
+
from .rest_api import create_rest_router
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def create_app():
|
|
14
|
+
"""Create and configure the FastAPI application."""
|
|
15
|
+
app = FastAPI(title="KotoneBot DevTools")
|
|
16
|
+
|
|
17
|
+
project = Project()
|
|
18
|
+
|
|
19
|
+
# REST API for DevTools2 (file IO, images, prefab schema)
|
|
20
|
+
app.include_router(create_rest_router(project))
|
|
21
|
+
|
|
22
|
+
# Get the dist directory path
|
|
23
|
+
dist_dir = Path(__file__).parent.parent / "web" / "dist"
|
|
24
|
+
|
|
25
|
+
# Mount static files if dist directory exists
|
|
26
|
+
if dist_dir.exists():
|
|
27
|
+
# 优先将打包好的静态资源挂载到 /assets(如果构建将资源放在 dist/assets)
|
|
28
|
+
assets_dir = dist_dir / "assets"
|
|
29
|
+
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
|
|
30
|
+
|
|
31
|
+
# SPA: 直接将除 /api/* 之外的所有路径映射到 index.html
|
|
32
|
+
@app.get("/{_path:path}")
|
|
33
|
+
async def spa_catchall(_path: str):
|
|
34
|
+
# 保留 API 路由的行为
|
|
35
|
+
if _path.startswith("api/"):
|
|
36
|
+
return JSONResponse({"detail": "Not Found"}, status_code=404)
|
|
37
|
+
|
|
38
|
+
index_file = dist_dir / "index.html"
|
|
39
|
+
if index_file.exists():
|
|
40
|
+
return HTMLResponse(index_file.read_text(encoding="utf-8"))
|
|
41
|
+
|
|
42
|
+
return JSONResponse({"detail": "Not Found"}, status_code=404)
|
|
43
|
+
else:
|
|
44
|
+
# If dist doesn't exist, provide a helpful message
|
|
45
|
+
@app.get("/")
|
|
46
|
+
async def missing_dist():
|
|
47
|
+
return JSONResponse({
|
|
48
|
+
"error": "DevTools frontend not found",
|
|
49
|
+
"message": f"Expected frontend dist at {dist_dir}",
|
|
50
|
+
"info": "Build the frontend using: npm run build in kotonebot-devtool directory"
|
|
51
|
+
}, status_code=503)
|
|
52
|
+
|
|
53
|
+
return app
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def start_devtools(
|
|
57
|
+
host: str = "127.0.0.1",
|
|
58
|
+
port: int = 1178,
|
|
59
|
+
open_browser: bool = False
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Start the DevTools web server.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
host: Host to listen on (default: 127.0.0.1)
|
|
65
|
+
port: Port to listen on (default: 1178)
|
|
66
|
+
open_browser: Automatically open browser (default: False)
|
|
67
|
+
"""
|
|
68
|
+
app = create_app()
|
|
69
|
+
|
|
70
|
+
# Open browser before starting server
|
|
71
|
+
if open_browser:
|
|
72
|
+
url = f"http://{host}:{port}"
|
|
73
|
+
webbrowser.open(url)
|
|
74
|
+
|
|
75
|
+
# Start server
|
|
76
|
+
uvicorn.run(
|
|
77
|
+
app,
|
|
78
|
+
host=host,
|
|
79
|
+
port=port,
|
|
80
|
+
log_level="info"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
start_devtools()
|
kotonebot/errors.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from typing import Callable
|
|
1
|
+
from typing import Callable, Sequence
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
class KotonebotError(Exception):
|
|
@@ -7,6 +7,11 @@ class KotonebotError(Exception):
|
|
|
7
7
|
class KotonebotWarning(Warning):
|
|
8
8
|
pass
|
|
9
9
|
|
|
10
|
+
class MissingDependencyError(KotonebotError, ImportError):
|
|
11
|
+
def __init__(self, e: ImportError, group_name: str) -> None:
|
|
12
|
+
self.original_error = e
|
|
13
|
+
super().__init__(f'Cannot import module "{e.name}". Did you forget to run "pip install kotonebot[{group_name}]"?')
|
|
14
|
+
|
|
10
15
|
class UserFriendlyError(KotonebotError):
|
|
11
16
|
def __init__(
|
|
12
17
|
self,
|
|
@@ -58,7 +63,7 @@ class TaskNotFoundError(KotonebotError):
|
|
|
58
63
|
super().__init__(f'Task "{task_id}" not found.')
|
|
59
64
|
|
|
60
65
|
class UnscalableResolutionError(KotonebotError):
|
|
61
|
-
def __init__(self, target_resolution:
|
|
66
|
+
def __init__(self, target_resolution: Sequence[int], screen_size: Sequence[int]):
|
|
62
67
|
self.target_resolution = target_resolution
|
|
63
68
|
self.screen_size = screen_size
|
|
64
69
|
super().__init__(f'Cannot scale to target resolution {target_resolution}. '
|
|
@@ -1,3 +1,12 @@
|
|
|
1
1
|
# ruff: noqa: E402
|
|
2
2
|
from kotonebot.util import require_windows
|
|
3
|
-
require_windows('kotonebot.interop.win module')
|
|
3
|
+
require_windows('kotonebot.interop.win module')
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
from . import _mouse as mouse
|
|
7
|
+
from .shake_mouse import ShakeMouse
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
'mouse',
|
|
11
|
+
'ShakeMouse',
|
|
12
|
+
]
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import time
|
|
3
|
+
from typing import Callable, Literal, TypedDict, overload, Tuple
|
|
4
|
+
|
|
5
|
+
import mouse
|
|
6
|
+
from typing_extensions import Unpack
|
|
7
|
+
|
|
8
|
+
from kotonebot.primitives import Point
|
|
9
|
+
|
|
10
|
+
MouseButton = Literal['left', 'right', 'middle']
|
|
11
|
+
TweenFunc = Callable[[float], float]
|
|
12
|
+
Tween = Literal['linear', 'ease_in', 'ease_out', 'ease_in_out'] | TweenFunc
|
|
13
|
+
|
|
14
|
+
# https://stackoverflow.com/a/76554895
|
|
15
|
+
def high_precision_sleep(duration):
|
|
16
|
+
start_time = time.perf_counter()
|
|
17
|
+
while True:
|
|
18
|
+
elapsed_time = time.perf_counter() - start_time
|
|
19
|
+
remaining_time = duration - elapsed_time
|
|
20
|
+
if remaining_time <= 0:
|
|
21
|
+
break
|
|
22
|
+
if remaining_time > 0.02: # Sleep for 5ms if remaining time is greater
|
|
23
|
+
time.sleep(max(remaining_time/2, 0.0001)) # Sleep for the remaining time or minimum sleep interval
|
|
24
|
+
else:
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
class AnimationParams(TypedDict, total=False):
|
|
28
|
+
duration: float
|
|
29
|
+
"""动画持续时间,单位为秒。
|
|
30
|
+
|
|
31
|
+
动画实际持续时间可能会略大于此值,具体取决于系统以及 delay_func 的实现。
|
|
32
|
+
"""
|
|
33
|
+
speed: float
|
|
34
|
+
"""动画速度,单位为像素/秒。"""
|
|
35
|
+
steps: int
|
|
36
|
+
"""动画步数。"""
|
|
37
|
+
tween: Tween
|
|
38
|
+
"""插值函数。
|
|
39
|
+
|
|
40
|
+
可选 'linear', 'ease_in', 'ease_out', 'ease_in_out',默认为 'ease_in_out'。
|
|
41
|
+
也可以是一个函数,其输入为动画进度,输出为动画值。
|
|
42
|
+
"""
|
|
43
|
+
delay_func: Callable[[float], None] | None
|
|
44
|
+
"""延时函数。默认为 time.sleep。
|
|
45
|
+
|
|
46
|
+
可选,如果提供了此参数,则会在每个动画点之间调用此函数。
|
|
47
|
+
"""
|
|
48
|
+
user_interrupt: Callable[[], bool | None] | Literal[True] | None
|
|
49
|
+
"""可选,用户中断函数,默认为 None。
|
|
50
|
+
|
|
51
|
+
若提供此参数,那么在动画执行时会检测用户输入,如果用户尝试移动鼠标,
|
|
52
|
+
会自动终止动画(传入 True)或调用此函数(传入 Callable,根据返回值决定是否继续)。
|
|
53
|
+
"""
|
|
54
|
+
user_interrupt_threshold: float | None
|
|
55
|
+
"""可选,用户中断阈值,单位为像素,默认为 None,表示使用全局参数。
|
|
56
|
+
|
|
57
|
+
如果提供此参数,则在检测用户中断时,仅当移动鼠标的距离大于此值时才会触发终止。
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
default_speed = 3000
|
|
61
|
+
animation_args: AnimationParams = {
|
|
62
|
+
'steps': 100,
|
|
63
|
+
'tween': 'ease_in_out',
|
|
64
|
+
'delay_func': high_precision_sleep,
|
|
65
|
+
'user_interrupt': None,
|
|
66
|
+
'user_interrupt_threshold': 30,
|
|
67
|
+
}
|
|
68
|
+
"""全局动画参数。"""
|
|
69
|
+
|
|
70
|
+
# https://easings.net
|
|
71
|
+
def _tween_linear(t: float) -> float:
|
|
72
|
+
return t
|
|
73
|
+
|
|
74
|
+
def _tween_ease_in(t: float) -> float:
|
|
75
|
+
return t * t
|
|
76
|
+
|
|
77
|
+
def _tween_ease_out(t: float) -> float:
|
|
78
|
+
return t * (2 - t)
|
|
79
|
+
|
|
80
|
+
def _tween_ease_in_out(t: float) -> float:
|
|
81
|
+
return t * t * (3 - 2 * t)
|
|
82
|
+
|
|
83
|
+
_TWEEN_FUNCTIONS = {
|
|
84
|
+
'linear': _tween_linear,
|
|
85
|
+
'ease_in': _tween_ease_in,
|
|
86
|
+
'ease_out': _tween_ease_out,
|
|
87
|
+
'ease_in_out': _tween_ease_in_out,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
def _get_animated_points(start_point: Point, end_point: Point, steps: int, tween: Tween):
|
|
91
|
+
if isinstance(tween, str):
|
|
92
|
+
tween_func = _TWEEN_FUNCTIONS.get(tween, _tween_linear)
|
|
93
|
+
else:
|
|
94
|
+
tween_func = tween
|
|
95
|
+
for i in range(steps + 1):
|
|
96
|
+
progress = i / steps
|
|
97
|
+
eased_progress = tween_func(progress)
|
|
98
|
+
x = start_point.x + (end_point.x - start_point.x) * eased_progress
|
|
99
|
+
y = start_point.y + (end_point.y - start_point.y) * eased_progress
|
|
100
|
+
yield Point(int(x), int(y))
|
|
101
|
+
|
|
102
|
+
def do_tween(start: Point, end: Point, args: AnimationParams, *, skip_first: bool = True):
|
|
103
|
+
"""从起点到终点根据输入的动画参数进行插值,并自动进行延时。
|
|
104
|
+
|
|
105
|
+
:param start: 开始位置。
|
|
106
|
+
:param end: 结束位置。
|
|
107
|
+
:param args: 动画参数,详见 :class:`.AnimationParams`。
|
|
108
|
+
:param skip_first: 是否跳过第一个点。默认为 True。
|
|
109
|
+
:raises ValueError: 输入的 speed 为非正数时抛出。
|
|
110
|
+
:raises ValueError: 同时提供 speed 与 duration 参数时抛出。
|
|
111
|
+
:return: 一个迭代器,包含所有插值的中间点。
|
|
112
|
+
"""
|
|
113
|
+
duration = args.get('duration')
|
|
114
|
+
speed = args.get('speed')
|
|
115
|
+
steps = args.get('steps', animation_args.get('steps'))
|
|
116
|
+
tween = args.get('tween', animation_args.get('tween'))
|
|
117
|
+
delay_func = args.get('delay_func', animation_args.get('delay_func'))
|
|
118
|
+
user_interrupt = args.get('user_interrupt', animation_args.get('user_interrupt'))
|
|
119
|
+
user_interrupt_threshold = args.get('user_interrupt_threshold', animation_args.get('user_interrupt_threshold'))
|
|
120
|
+
assert steps is not None and tween is not None and delay_func is not None
|
|
121
|
+
|
|
122
|
+
def _speed_to_duration(speed: float) -> float:
|
|
123
|
+
return math.sqrt((end.x - start.x)**2 + (end.y - start.y)**2) / speed
|
|
124
|
+
|
|
125
|
+
if duration is None and speed is not None:
|
|
126
|
+
# 设置了速度,但没有设置时长,则计算时长
|
|
127
|
+
if speed <= 0:
|
|
128
|
+
raise ValueError('speed must be positive')
|
|
129
|
+
duration = _speed_to_duration(speed)
|
|
130
|
+
elif duration is not None and speed is None:
|
|
131
|
+
# 设置了时长,但没有设置速度,则计算速度
|
|
132
|
+
pass
|
|
133
|
+
elif duration is not None and speed is not None:
|
|
134
|
+
# 两个都设置
|
|
135
|
+
raise ValueError('duration and speed cannot be set at the same time')
|
|
136
|
+
else:
|
|
137
|
+
# 两个都没有设置,使用默认速度
|
|
138
|
+
duration = _speed_to_duration(default_speed)
|
|
139
|
+
|
|
140
|
+
delay = duration / steps if steps > 0 else 0
|
|
141
|
+
print(duration, steps, delay)
|
|
142
|
+
|
|
143
|
+
point_iterator = _get_animated_points(start, end, steps, tween)
|
|
144
|
+
if skip_first:
|
|
145
|
+
next(point_iterator, None)
|
|
146
|
+
|
|
147
|
+
# 调用函数 (drag/move) 负责设置鼠标位置。
|
|
148
|
+
# 在每次迭代中,我们检查当前鼠标位置是否与我们在“上一次”迭代中生成的位置匹配。
|
|
149
|
+
# 我们无法在此处知道初始鼠标位置,因此我们从第二个点开始检查。
|
|
150
|
+
|
|
151
|
+
iterator = iter(point_iterator)
|
|
152
|
+
try:
|
|
153
|
+
prev_pos = next(iterator)
|
|
154
|
+
yield prev_pos
|
|
155
|
+
delay_func(delay)
|
|
156
|
+
except StopIteration:
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
for pt in iterator:
|
|
160
|
+
# 检测用户中断
|
|
161
|
+
if user_interrupt:
|
|
162
|
+
pos = get_pos()
|
|
163
|
+
should_interrupt = False
|
|
164
|
+
if user_interrupt_threshold is not None:
|
|
165
|
+
if pos.distance_to(prev_pos) > user_interrupt_threshold:
|
|
166
|
+
should_interrupt = True
|
|
167
|
+
else:
|
|
168
|
+
if pos != prev_pos:
|
|
169
|
+
should_interrupt = True
|
|
170
|
+
|
|
171
|
+
if should_interrupt:
|
|
172
|
+
# 鼠标被用户移动,中断动画。
|
|
173
|
+
if user_interrupt is True:
|
|
174
|
+
return
|
|
175
|
+
if callable(user_interrupt):
|
|
176
|
+
if not user_interrupt():
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
yield pt
|
|
180
|
+
prev_pos = pt
|
|
181
|
+
delay_func(delay)
|
|
182
|
+
|
|
183
|
+
@overload
|
|
184
|
+
def set_pos(p: Point) -> None:
|
|
185
|
+
"""移动光标到指定位置。
|
|
186
|
+
|
|
187
|
+
:param p: 坐标,Point 实例。
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
@overload
|
|
191
|
+
def set_pos(p: Tuple[int, int]) -> None:
|
|
192
|
+
"""移动光标到指定位置。
|
|
193
|
+
|
|
194
|
+
:param p: 坐标,二元 tuple[int, int]。
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
@overload
|
|
198
|
+
def set_pos(p: int, y: int) -> None:
|
|
199
|
+
"""移动光标到指定位置。
|
|
200
|
+
|
|
201
|
+
:param p: 坐标,x 坐标。
|
|
202
|
+
:param y: 坐标,y 坐标。
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
def set_pos(p, y: int | None = None):
|
|
206
|
+
if y is None:
|
|
207
|
+
# 可能是 Point 或二元 tuple
|
|
208
|
+
if isinstance(p, Point):
|
|
209
|
+
x = int(p.x)
|
|
210
|
+
y = int(p.y)
|
|
211
|
+
else:
|
|
212
|
+
# 假定为 (x, y)
|
|
213
|
+
_x, _y = p
|
|
214
|
+
x = int(_x)
|
|
215
|
+
y = int(_y)
|
|
216
|
+
else:
|
|
217
|
+
x = int(p)
|
|
218
|
+
y = int(y)
|
|
219
|
+
|
|
220
|
+
mouse.move(x, y)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def get_pos() -> Point:
|
|
224
|
+
x, y = mouse.get_position()
|
|
225
|
+
return Point(x, y)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def down(button: MouseButton):
|
|
229
|
+
mouse.press(button)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def up(button: MouseButton):
|
|
233
|
+
mouse.release(button)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def click(button: MouseButton, *, duration: float = 0.1):
|
|
237
|
+
"""模拟鼠标点击。
|
|
238
|
+
|
|
239
|
+
:param button: 必填,鼠标按钮。
|
|
240
|
+
:param duration: 可选,点击持续时间。默认为 0.1。
|
|
241
|
+
"""
|
|
242
|
+
down(button)
|
|
243
|
+
time.sleep(duration)
|
|
244
|
+
up(button)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def drag(
|
|
248
|
+
start: Point,
|
|
249
|
+
end: Point,
|
|
250
|
+
*,
|
|
251
|
+
button: MouseButton | None = 'left',
|
|
252
|
+
**kargs: Unpack[AnimationParams],
|
|
253
|
+
):
|
|
254
|
+
"""模拟鼠标拖拽。
|
|
255
|
+
参数中与动画相关的部分详见 :class:`.AnimationParams`。
|
|
256
|
+
|
|
257
|
+
:param start: 必填,起点。
|
|
258
|
+
:param end: 必填,终点。
|
|
259
|
+
:param button: 可选,拖拽使用的鼠标按钮。默认为 `left`。None 表示不按下任何鼠标按钮,相当于只移动光标。
|
|
260
|
+
:param duration: 可选,动画持续时间。
|
|
261
|
+
:param speed: 可选,动画速度。
|
|
262
|
+
:param steps: 可选,动画步数。
|
|
263
|
+
:param tween: 可选,动画曲线。
|
|
264
|
+
:param delay_func: 可选,延时函数。详见 :class:`.AnimationParams`。
|
|
265
|
+
:param user_interrupt: 可选,用户中断函数。详见 :class:`.AnimationParams`。
|
|
266
|
+
:param user_interrupt_threshold: 可选,用户中断阈值。详见 :class:`.AnimationParams`。
|
|
267
|
+
"""
|
|
268
|
+
if button:
|
|
269
|
+
set_pos(start)
|
|
270
|
+
time.sleep(0.02)
|
|
271
|
+
down(button)
|
|
272
|
+
time.sleep(0.02)
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
for p in do_tween(start, end, kargs):
|
|
276
|
+
set_pos(p)
|
|
277
|
+
pass
|
|
278
|
+
finally:
|
|
279
|
+
if button:
|
|
280
|
+
up(button)
|
|
281
|
+
|
|
282
|
+
def move(
|
|
283
|
+
start: Point,
|
|
284
|
+
end: Point,
|
|
285
|
+
/,
|
|
286
|
+
**kargs: Unpack[AnimationParams],
|
|
287
|
+
):
|
|
288
|
+
"""模拟鼠标移动。
|
|
289
|
+
参数中与动画相关的部分详见 :class:`kotonebot.interop.win.AnimationParams`。
|
|
290
|
+
|
|
291
|
+
:param start: 必填,起点。
|
|
292
|
+
:param end: 必填,终点。
|
|
293
|
+
:param duration: 可选,动画持续时间。
|
|
294
|
+
:param speed: 可选,动画速度。
|
|
295
|
+
:param steps: 可选,动画步数。
|
|
296
|
+
:param tween: 可选,动画曲线。
|
|
297
|
+
:param delay_func: 可选,延时函数。详见 :class:`.AnimationParams`。
|
|
298
|
+
:param user_interrupt: 可选,用户中断函数。详见 :class:`.AnimationParams`。
|
|
299
|
+
:param user_interrupt_threshold: 可选,用户中断阈值。详见 :class:`.AnimationParams`。
|
|
300
|
+
"""
|
|
301
|
+
drag(start, end, button=None, **kargs)
|
|
302
|
+
|
|
303
|
+
__all__ = [
|
|
304
|
+
'set_pos',
|
|
305
|
+
'get_pos',
|
|
306
|
+
'down',
|
|
307
|
+
'up',
|
|
308
|
+
'click',
|
|
309
|
+
'drag',
|
|
310
|
+
'move',
|
|
311
|
+
]
|