diff --git a/WHATS_NEW.md b/WHATS_NEW.md index e9e0adad..a0be99cc 100644 --- a/WHATS_NEW.md +++ b/WHATS_NEW.md @@ -1,5 +1,11 @@ # What's New — AutoControl +## What's new (2026-06-24) — Drop Files onto a Window (WM_DROPFILES) + +Complete a drag-and-drop programmatically — drop files onto a target window. Full reference: [`docs/source/Eng/doc/new_features/v187_features_doc.rst`](docs/source/Eng/doc/new_features/v187_features_doc.rst). + +- **`plan_file_drop` / `drop_files`** (`AC_plan_file_drop`, `AC_drop_files`): `clipboard_files` *stages* a file list on the clipboard for `Ctrl+V`; this actively **drops** files onto a target window by posting a `WM_DROPFILES` message. It reuses `clipboard_files.build_dropfiles` to pack the `DROPFILES` blob (shared byte layout, not re-implemented) and dispatches through an injectable driver seam, so the build-and-dispatch logic is unit-testable with a fake driver; the real `GlobalAlloc` + `PostMessage` lives in the default Win32 driver. `plan_file_drop` is a pure dry-run returning `{message, paths, point, wide, blob_size}`. No `PySide6`. + ## What's new (2026-06-24) — Clipboard Format Inspection (classify / diff available formats) See which formats are on the clipboard, and detect when its shape changes. Full reference: [`docs/source/Eng/doc/new_features/v186_features_doc.rst`](docs/source/Eng/doc/new_features/v186_features_doc.rst). diff --git a/docs/source/Eng/doc/new_features/v187_features_doc.rst b/docs/source/Eng/doc/new_features/v187_features_doc.rst new file mode 100644 index 00000000..2cdec972 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v187_features_doc.rst @@ -0,0 +1,42 @@ +Drop Files onto a Window (WM_DROPFILES) +======================================= + +``clipboard_files`` *stages* a file-drop list on the clipboard so a user can +``Ctrl+V`` it; ``file_drop`` actively **drops** files onto a target window — the +completion of a drag-and-drop — by posting a ``WM_DROPFILES`` message carrying a +``DROPFILES`` blob. It reuses ``clipboard_files.build_dropfiles`` to pack that +blob (the byte layout is shared, not re-implemented) and dispatches it through an +injectable *driver* seam, so the build-and-dispatch logic is unit-testable on any +platform with a fake driver; the real ``GlobalAlloc`` + ``PostMessage`` lives in +the default Win32 driver. Imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import plan_file_drop, drop_files + + # Pure dry-run — inspect the payload without sending: + plan_file_drop(["C:\\a\\one.txt"], point=(10, 20)) + # {"message": 0x233, "paths": [...], "point": [10, 20], "wide": True, + # "blob_size": ...} + + # Real drop onto a window handle (Windows): + drop_files(hwnd, ["C:\\a\\one.txt", "C:\\b\\two.png"], point=(10, 20)) + + # Inject a driver to intercept the send (e.g. in tests): + drop_files(hwnd, ["x.txt"], driver=lambda hwnd, blob, point: True) + +``point`` is the drop coordinate in the window's client area. ``drop_files`` +returns ``bool``; the default driver posts the real ``WM_DROPFILES`` (the +receiving window then owns and frees the memory via ``DragFinish``) and raises +``RuntimeError`` off Windows. + +Executor commands +----------------- + +``AC_drop_files`` (``hwnd`` / ``paths`` / ``point``) performs the drop; +``AC_plan_file_drop`` (``paths`` / ``point``) is the pure dry-run. They are +exposed as the matching ``ac_*`` MCP tools (drop side-effect-only, plan +read-only) and as Script Builder commands under **Window**. diff --git a/docs/source/Zh/doc/new_features/v187_features_doc.rst b/docs/source/Zh/doc/new_features/v187_features_doc.rst new file mode 100644 index 00000000..0bea02d7 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v187_features_doc.rst @@ -0,0 +1,38 @@ +將檔案拖放到視窗(WM_DROPFILES) +============================== + +``clipboard_files`` 只是把檔案拖放清單*放上*剪貼簿,讓使用者可以 ``Ctrl+V``;``file_drop`` 則 +主動把檔案**拖放**到目標視窗——也就是拖放動作的完成——透過送出帶有 ``DROPFILES`` 位元組區塊的 +``WM_DROPFILES`` 訊息達成。它重用 ``clipboard_files.build_dropfiles`` 來打包該區塊(位元組配置 +共用,不重新實作),並透過可注入的 *driver* 接縫分派,因此「打包 + 分派」邏輯可在任何平台以 +假 driver 單元測試;真正的 ``GlobalAlloc`` + ``PostMessage`` 位於預設的 Win32 driver。不匯入 +``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import plan_file_drop, drop_files + + # 純試跑——檢視 payload 但不送出: + plan_file_drop(["C:\\a\\one.txt"], point=(10, 20)) + # {"message": 0x233, "paths": [...], "point": [10, 20], "wide": True, + # "blob_size": ...} + + # 對視窗 handle 真正拖放(Windows): + drop_files(hwnd, ["C:\\a\\one.txt", "C:\\b\\two.png"], point=(10, 20)) + + # 注入 driver 以攔截送出(例如在測試中): + drop_files(hwnd, ["x.txt"], driver=lambda hwnd, blob, point: True) + +``point`` 是視窗工作區(client area)內的拖放座標。``drop_files`` 回傳 ``bool``;預設 driver 送出 +真正的 ``WM_DROPFILES``(接收視窗隨後擁有該記憶體並透過 ``DragFinish`` 釋放),在非 Windows 平台 +拋出 ``RuntimeError``。 + +執行器指令 +---------- + +``AC_drop_files``(``hwnd`` / ``paths`` / ``point``)執行拖放;``AC_plan_file_drop`` +(``paths`` / ``point``)為純試跑。皆以對應的 ``ac_*`` MCP 工具(drop 為僅副作用、plan 為唯讀) +及 Script Builder 指令(位於 **Window** 分類下)形式提供。 diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index e9c9e926..895a182d 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -76,6 +76,8 @@ classify_format, classify_formats, clipboard_formats, diff_formats, list_clipboard_formats, ) +# Drop files onto a window (WM_DROPFILES sender) +from je_auto_control.utils.file_drop import drop_files, plan_file_drop # VLM element locator (headless) from je_auto_control.utils.vision import ( VLMNotAvailableError, click_by_description, locate_by_description, @@ -1649,6 +1651,7 @@ def start_autocontrol_gui(*args, **kwargs): "set_clipboard_csv", "get_clipboard_csv", "classify_format", "classify_formats", "diff_formats", "list_clipboard_formats", "clipboard_formats", + "plan_file_drop", "drop_files", # VLM locator "VLMNotAvailableError", "locate_by_description", "click_by_description", "verify_description", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index dc6d60eb..60844736 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -991,6 +991,27 @@ def _add_window_specs(specs: List[CommandSpec]) -> None: "AC_close_window", "Window", "Close Window", fields=(FieldSpec("title_substring", FieldType.STRING),), )) + specs.append(CommandSpec( + "AC_drop_files", "Window", "Drop Files onto Window", + fields=( + FieldSpec("hwnd", FieldType.INT), + FieldSpec("paths", FieldType.STRING, + placeholder='["C:\\\\a\\\\one.txt"]'), + FieldSpec("point", FieldType.STRING, optional=True, + placeholder="[10, 20]"), + ), + description="Drop files onto a window via WM_DROPFILES (Windows).", + )) + specs.append(CommandSpec( + "AC_plan_file_drop", "Window", "Plan File Drop", + fields=( + FieldSpec("paths", FieldType.STRING, + placeholder='["C:\\\\a\\\\one.txt"]'), + FieldSpec("point", FieldType.STRING, optional=True, + placeholder="[10, 20]"), + ), + description="Build the WM_DROPFILES payload without sending (pure).", + )) specs.append(CommandSpec( "AC_snap_window", "Window", "Snap / Tile Window", fields=( diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 150fb5f7..dff38212 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -4242,6 +4242,38 @@ def _diff_formats(before: Any, after: Any) -> Dict[str, Any]: return diff_formats(before, after) +def _coerce_paths(paths: Any) -> list: + """Normalise a paths argument (JSON list string / single path / list).""" + import json + if isinstance(paths, str): + paths = json.loads(paths) if paths.strip().startswith("[") else [paths] + return [str(p) for p in paths] + + +def _coerce_point(point: Any) -> tuple: + """Normalise a point argument (JSON '[x,y]' / list / default origin).""" + import json + if isinstance(point, str): + point = json.loads(point) if point.strip().startswith("[") else (0, 0) + if not point: + return (0, 0) + return (int(point[0]), int(point[1])) + + +def _plan_file_drop(paths: Any, point: Any = None) -> Dict[str, Any]: + """Adapter: build the WM_DROPFILES payload without sending (pure).""" + from je_auto_control.utils.file_drop import plan_file_drop + return plan_file_drop(_coerce_paths(paths), point=_coerce_point(point)) + + +def _drop_files(hwnd: Any, paths: Any, point: Any = None) -> Dict[str, Any]: + """Adapter: drop files onto a window via WM_DROPFILES (Windows).""" + from je_auto_control.utils.file_drop import drop_files + coerced = _coerce_paths(paths) + dropped = drop_files(int(hwnd), coerced, point=_coerce_point(point)) + return {"dropped": bool(dropped), "count": len(coerced)} + + def _image_histogram(source: Any = None, bins: Any = 32, space: str = "hsv", region: Any = None) -> Dict[str, Any]: """Adapter: per-channel colour histogram of an image / the screen.""" @@ -6462,6 +6494,8 @@ def __init__(self): "AC_clipboard_formats": _clipboard_formats, "AC_classify_formats": _classify_formats, "AC_diff_formats": _diff_formats, + "AC_plan_file_drop": _plan_file_drop, + "AC_drop_files": _drop_files, "AC_image_histogram": _image_histogram, "AC_histogram_changed": _histogram_changed, "AC_changed_regions": _changed_regions, diff --git a/je_auto_control/utils/file_drop/__init__.py b/je_auto_control/utils/file_drop/__init__.py new file mode 100644 index 00000000..e37026c0 --- /dev/null +++ b/je_auto_control/utils/file_drop/__init__.py @@ -0,0 +1,4 @@ +"""Drop files onto a window via WM_DROPFILES (reuses clipboard_files packing).""" +from je_auto_control.utils.file_drop.file_drop import drop_files, plan_file_drop + +__all__ = ["plan_file_drop", "drop_files"] diff --git a/je_auto_control/utils/file_drop/file_drop.py b/je_auto_control/utils/file_drop/file_drop.py new file mode 100644 index 00000000..d8fda491 --- /dev/null +++ b/je_auto_control/utils/file_drop/file_drop.py @@ -0,0 +1,71 @@ +"""Drop files onto a window (Windows ``WM_DROPFILES``). + +``clipboard_files`` *stages* a file-drop list on the clipboard so a user can +``Ctrl+V`` it; this module actively **drops** files onto a target window — the +completion of a drag-and-drop — by posting a ``WM_DROPFILES`` message carrying a +``DROPFILES`` blob. It reuses ``clipboard_files.build_dropfiles`` to pack that +blob (so the byte layout is shared, not re-implemented) and dispatches it through +an injectable *driver* seam, so the build-and-dispatch logic is unit-testable on +any platform by passing a fake driver; the real ``GlobalAlloc`` + ``PostMessage`` +lives in the default Win32 driver. Imports no ``PySide6``. +""" +from typing import Any, Callable, Dict, Optional, Sequence, Tuple + +from je_auto_control.utils.clipboard_files import build_dropfiles + +_WM_DROPFILES = 0x0233 +_GMEM_MOVEABLE = 0x0002 + +# A driver dispatches a packed DROPFILES blob to a window: (hwnd, blob, point) -> bool. +DropDriver = Callable[[int, bytes, Tuple[int, int]], bool] + + +def plan_file_drop(paths: Sequence[str], *, point: Tuple[int, int] = (0, 0), + wide: bool = True) -> Dict[str, Any]: + """Build the ``WM_DROPFILES`` payload for dropping ``paths`` (pure, no send). + + Returns ``{message, paths, point, wide, blob_size}`` — a dry-run description + that reuses the same :func:`build_dropfiles` packing the real drop sends. + """ + blob = build_dropfiles(paths, point=point, wide=wide) + return {"message": _WM_DROPFILES, "paths": [str(p) for p in paths], + "point": [int(point[0]), int(point[1])], "wide": bool(wide), + "blob_size": len(blob)} + + +def _default_driver(hwnd: int, blob: bytes, point: Tuple[int, int]) -> bool: + """Post a real ``WM_DROPFILES`` to ``hwnd`` (Windows only).""" + import sys + if not sys.platform.startswith("win"): + raise RuntimeError("drop_files is only supported on Windows") + import ctypes + from ctypes import wintypes + kernel32, user32 = ctypes.windll.kernel32, ctypes.windll.user32 + kernel32.GlobalAlloc.restype = wintypes.HGLOBAL + kernel32.GlobalLock.restype = ctypes.c_void_p + handle = kernel32.GlobalAlloc(_GMEM_MOVEABLE, len(blob)) + if not handle: + raise RuntimeError("GlobalAlloc failed") + pointer = kernel32.GlobalLock(handle) + ctypes.memmove(pointer, blob, len(blob)) + kernel32.GlobalUnlock(handle) + # The receiving window owns the memory and frees it via DragFinish. + if not user32.PostMessageW(int(hwnd), _WM_DROPFILES, handle, 0): + raise RuntimeError("PostMessage(WM_DROPFILES) failed") + return True + + +def drop_files(hwnd: int, paths: Sequence[str], *, + point: Tuple[int, int] = (0, 0), wide: bool = True, + driver: Optional[DropDriver] = None) -> bool: + """Drop ``paths`` onto window ``hwnd`` via ``WM_DROPFILES``; True on success. + + ``point`` is the drop coordinate in the window's client area. Pass a + ``driver`` ``(hwnd, blob, point) -> bool`` to intercept the send (e.g. in + tests); the default driver posts the real Windows message. + """ + if not paths: + raise ValueError("at least one path is required") + blob = build_dropfiles(paths, point=point, wide=wide) + send = driver if driver is not None else _default_driver + return bool(send(int(hwnd), blob, (int(point[0]), int(point[1])))) diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index f9899647..c3e2401d 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -3323,6 +3323,31 @@ def clipboard_files_tools() -> List[MCPTool]: handler=h.diff_formats, annotations=READ_ONLY, ), + MCPTool( + name="ac_plan_file_drop", + description=("Build the WM_DROPFILES payload for dropping 'paths' at " + "'point' without sending it (pure dry-run). Returns " + "{message, paths, point, wide, blob_size}."), + input_schema=schema({ + "paths": {"type": "array", "items": {"type": "string"}}, + "point": {"type": "array", "items": {"type": "integer"}}}, + required=["paths"]), + handler=h.plan_file_drop, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_drop_files", + description=("Drop 'paths' onto window 'hwnd' via WM_DROPFILES — the " + "completion of a drag-and-drop (Windows). 'point' is the " + "client-area drop coordinate. Returns {dropped, count}."), + input_schema=schema({ + "hwnd": {"type": "integer"}, + "paths": {"type": "array", "items": {"type": "string"}}, + "point": {"type": "array", "items": {"type": "integer"}}}, + required=["hwnd", "paths"]), + handler=h.drop_files, + annotations=SIDE_EFFECT_ONLY, + ), ] diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index f45dcdc5..2bb64e51 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -2499,6 +2499,16 @@ def diff_formats(before, after): return _diff_formats(before, after) +def plan_file_drop(paths, point=None): + from je_auto_control.utils.executor.action_executor import _plan_file_drop + return _plan_file_drop(paths, point) + + +def drop_files(hwnd, paths, point=None): + from je_auto_control.utils.executor.action_executor import _drop_files + return _drop_files(hwnd, paths, point) + + def image_histogram(source=None, bins=32, space="hsv", region=None): from je_auto_control.utils.executor.action_executor import _image_histogram return _image_histogram(source, bins, space, region) diff --git a/test/unit_test/headless/test_file_drop_batch.py b/test/unit_test/headless/test_file_drop_batch.py new file mode 100644 index 00000000..a353804f --- /dev/null +++ b/test/unit_test/headless/test_file_drop_batch.py @@ -0,0 +1,72 @@ +"""Headless tests for WM_DROPFILES file drop (injected driver; no Win32).""" +import pytest + +import je_auto_control as ac +from je_auto_control.utils.clipboard_files import parse_dropfiles +from je_auto_control.utils.file_drop import drop_files, plan_file_drop + +_WM_DROPFILES = 0x0233 + + +def test_plan_file_drop_reuses_dropfiles_packing(): + plan = plan_file_drop(["C:\\a\\one.txt", "C:\\b\\two.png"], point=(12, 34)) + assert plan["message"] == _WM_DROPFILES + assert plan["paths"] == ["C:\\a\\one.txt", "C:\\b\\two.png"] + assert plan["point"] == [12, 34] + assert plan["wide"] is True + assert plan["blob_size"] > 20 # header + path list + + +def test_drop_files_dispatches_packed_blob_to_driver(): + captured = {} + + def fake_driver(hwnd, blob, point): + captured["hwnd"] = hwnd + captured["blob"] = blob + captured["point"] = point + return True + + ok = drop_files(0xABCD, ["C:\\docs\\report.pdf"], point=(5, 9), + driver=fake_driver) + assert ok is True + assert captured["hwnd"] == 0xABCD + assert captured["point"] == (5, 9) + # the blob the driver receives is a real DROPFILES the receiver could parse + parsed = parse_dropfiles(captured["blob"]) + assert parsed["paths"] == ["C:\\docs\\report.pdf"] + assert parsed["point"] == [5, 9] + + +def test_drop_files_returns_driver_result(): + assert drop_files(1, ["x"], driver=lambda *_: False) is False + + +def test_empty_paths_raise(): + with pytest.raises(ValueError): + drop_files(1, [], driver=lambda *_: True) + with pytest.raises(ValueError): + plan_file_drop([]) + + +# --- wiring (real Win32 PostMessage not executed in CI) -------------------- + +def test_executor_plan_path_is_pure(): + from je_auto_control.utils.executor.action_executor import _plan_file_drop + plan = _plan_file_drop('["C:\\\\a\\\\one.txt"]', "[3, 4]") + assert plan["point"] == [3, 4] and plan["paths"] == ["C:\\a\\one.txt"] + + +def test_wiring(): + known = set(ac.executor.known_commands()) + assert {"AC_drop_files", "AC_plan_file_drop"} <= known + from je_auto_control.utils.mcp_server.tools import build_default_tool_registry + names = {t.name for t in build_default_tool_registry()} + assert {"ac_drop_files", "ac_plan_file_drop"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + specs = {s.command for s in _build_specs()} + assert {"AC_drop_files", "AC_plan_file_drop"} <= specs + + +def test_facade_exports(): + for name in ("plan_file_drop", "drop_files"): + assert hasattr(ac, name) and name in ac.__all__