Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions WHATS_NEW.md
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
42 changes: 42 additions & 0 deletions docs/source/Eng/doc/new_features/v187_features_doc.rst
Original file line number Diff line number Diff line change
@@ -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**.
38 changes: 38 additions & 0 deletions docs/source/Zh/doc/new_features/v187_features_doc.rst
Original file line number Diff line number Diff line change
@@ -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** 分類下)形式提供。
3 changes: 3 additions & 0 deletions je_auto_control/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
21 changes: 21 additions & 0 deletions je_auto_control/gui/script_builder/command_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -991,6 +991,27 @@
"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]"),

Check failure on line 1001 in je_auto_control/gui/script_builder/command_schema.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "[10, 20]" 3 times.

See more on https://sonarcloud.io/project/issues?id=Integration-Automation_AutoControlGUI&issues=AZ74PwBba10azXA84zrp&open=AZ74PwBba10azXA84zrp&pullRequest=404
),
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=(
Expand Down
34 changes: 34 additions & 0 deletions je_auto_control/utils/executor/action_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions je_auto_control/utils/file_drop/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
71 changes: 71 additions & 0 deletions je_auto_control/utils/file_drop/file_drop.py
Original file line number Diff line number Diff line change
@@ -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]))))
25 changes: 25 additions & 0 deletions je_auto_control/utils/mcp_server/tools/_factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
]


Expand Down
10 changes: 10 additions & 0 deletions je_auto_control/utils/mcp_server/tools/_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
72 changes: 72 additions & 0 deletions test/unit_test/headless/test_file_drop_batch.py
Original file line number Diff line number Diff line change
@@ -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__
Loading