Skip to content

Commit ba2b1c9

Browse files
authored
Merge pull request #425 from Integration-Automation/dev
Release: native-UIA depth pt.3 — reactive focus event (v202); native-UIA lane complete
2 parents 5fad422 + 0736593 commit ba2b1c9

13 files changed

Lines changed: 272 additions & 0 deletions

File tree

WHATS_NEW.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# What's New — AutoControl
22

3+
## What's new (2026-06-25) — Reactive UIA Event Wait (focus change)
4+
5+
Wait until focus lands on the dialog — a real, zero-latency UIA event, not polling. Full reference: [`docs/source/Eng/doc/new_features/v202_features_doc.rst`](docs/source/Eng/doc/new_features/v202_features_doc.rst).
6+
7+
- **`wait_for_focus_change`** (`AC_wait_for_focus_change`): the accessibility recorder *polls* focus every ~250 ms, so it can miss a fast transition and reacts late. This blocks on the native `AddFocusChangedEventHandler` and returns the moment focus moves — the zero-latency, miss-free "wait until focus lands on the dialog" primitive, the accessibility-tree analogue of `wait_for_window` / `wait_for_image`. Returns the newly-focused element (or `None` on timeout). The real event subscription is registered/unregistered under a lock on the calling thread; dispatched through the injectable accessibility backend seam (headless-testable via a fake backend; real UIA in the Windows backend). No `PySide6`.
8+
39
## What's new (2026-06-25) — Container Selection + View Switching (Selection / MultipleView)
410

511
Read what's selected in a listbox/grid, and switch Explorer-style views. Full reference: [`docs/source/Eng/doc/new_features/v201_features_doc.rst`](docs/source/Eng/doc/new_features/v201_features_doc.rst).
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
Reactive UIA Event Wait — Focus Change
2+
======================================
3+
4+
The accessibility recorder *polls* the focused element every ~250 ms, so it can
5+
miss a fast focus transition and reacts a quarter-second late. UIA exposes real
6+
events: ``wait_for_focus_change`` blocks on the native
7+
``AddFocusChangedEventHandler`` and returns the moment focus moves — the
8+
zero-latency, miss-free "wait until focus lands on the dialog" primitive, the
9+
accessibility-tree analogue of ``wait_for_window`` / ``wait_for_image``.
10+
11+
It is a thin dispatch onto the injectable ``accessibility.backends.get_backend()``
12+
seam — headless-testable on any platform by injecting a fake backend; the real
13+
event subscription (registered / unregistered under a lock, on the calling
14+
thread) lives in the Windows backend. Imports no ``PySide6``.
15+
16+
Headless API
17+
------------
18+
19+
.. code-block:: python
20+
21+
from je_auto_control import wait_for_focus_change, click_text
22+
23+
click_text("Settings")
24+
focused = wait_for_focus_change(timeout=3)
25+
# {"name": "Search", "role": "ControlType_50004", "app_name": "app.exe", ...}
26+
if focused is not None:
27+
... # focus has moved — the dialog / next field is ready
28+
29+
Returns the newly-focused element as ``{name, role, app_name, bounds, …}``, or
30+
``None`` if no focus change occurs within ``timeout`` seconds (default ``5``).
31+
32+
Executor commands
33+
-----------------
34+
35+
``AC_wait_for_focus_change`` (``timeout``) returns ``{changed, element}``. It is
36+
exposed as the read-only ``ac_wait_for_focus_change`` MCP tool and as a Script
37+
Builder command under **Native UI**.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
反應式 UIA 事件等待——焦點變化
2+
==============================
3+
4+
無障礙錄製器每約 250 毫秒*輪詢*一次焦點元素,因此可能錯過快速的焦點轉換,且慢上四分之一秒才反應。
5+
UIA 提供真正的事件:``wait_for_focus_change`` 阻塞於原生的 ``AddFocusChangedEventHandler``,並在
6+
焦點移動的當下回傳——這是零延遲、不漏失的「等到焦點落在對話框上」原語,是 ``wait_for_window`` /
7+
``wait_for_image`` 在無障礙樹上的對應。
8+
9+
它是對可注入的 ``accessibility.backends.get_backend()`` 接縫的薄分派——可在任何平台透過注入 fake
10+
backend 進行無頭測試;真正的事件訂閱(在呼叫執行緒上、以鎖註冊 / 取消註冊)位於 Windows 後端。
11+
不匯入 ``PySide6``。
12+
13+
無頭 API
14+
--------
15+
16+
.. code-block:: python
17+
18+
from je_auto_control import wait_for_focus_change, click_text
19+
20+
click_text("Settings")
21+
focused = wait_for_focus_change(timeout=3)
22+
# {"name": "Search", "role": "ControlType_50004", "app_name": "app.exe", ...}
23+
if focused is not None:
24+
... # 焦點已移動——對話框 / 下一個欄位已就緒
25+
26+
回傳新獲得焦點的元素 ``{name, role, app_name, bounds, …}``,若在 ``timeout`` 秒內(預設 ``5``)
27+
沒有焦點變化則回傳 ``None``。
28+
29+
執行器指令
30+
----------
31+
32+
``AC_wait_for_focus_change``(``timeout``)回傳 ``{changed, element}``。以唯讀
33+
``ac_wait_for_focus_change`` MCP 工具及 Script Builder 指令(位於 **Native UI** 分類下)
34+
形式提供。

je_auto_control/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@
8989
from je_auto_control.utils.selection_view import (
9090
get_selection, list_views, set_view,
9191
)
92+
# Reactive UIA event waits (focus-changed)
93+
from je_auto_control.utils.ax_events import wait_for_focus_change
9294
# Rich clipboard formats — RTF + CSV/TSV codecs and Windows get / set
9395
from je_auto_control.utils.clipboard_rich_formats import (
9496
build_rtf, csv_to_rows, get_clipboard_csv, get_clipboard_rtf, rows_to_csv,
@@ -1697,6 +1699,7 @@ def start_autocontrol_gui(*args, **kwargs):
16971699
"window_interaction_state",
16981700
"legacy_info", "legacy_default_action",
16991701
"get_selection", "list_views", "set_view",
1702+
"wait_for_focus_change",
17001703
"build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows",
17011704
"set_clipboard_rtf", "get_clipboard_rtf",
17021705
"set_clipboard_csv", "get_clipboard_csv",

je_auto_control/gui/script_builder/command_schema.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1691,6 +1691,12 @@ def _add_native_control_specs(specs: List[CommandSpec]) -> None:
16911691
fields=(FieldSpec("view", FieldType.STRING),) + fields,
16921692
description="Switch a control to the named view (MultipleViewPattern).",
16931693
))
1694+
specs.append(CommandSpec(
1695+
"AC_wait_for_focus_change", "Native UI", "Wait for Focus Change",
1696+
fields=(FieldSpec("timeout", FieldType.FLOAT, optional=True,
1697+
default=5.0),),
1698+
description="Block until keyboard focus moves (real UIA focus event).",
1699+
))
16941700
specs.append(CommandSpec(
16951701
"AC_get_control_text", "Native UI", "Get Control Text",
16961702
fields=fields,

je_auto_control/utils/accessibility/backends/base.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,18 @@ def set_view(self, view: str = "", name: Optional[str] = None,
281281
"""Switch a control to the named view (MultipleViewPattern); True on success."""
282282
self._unsupported("set_view")
283283

284+
# --- reactive events (UIA event subscription) --------------------------
285+
286+
def wait_for_focus_change(self, timeout: float = 5.0,
287+
) -> Optional[Dict[str, Any]]:
288+
"""Block until the keyboard focus moves, then return the newly-focused
289+
element ``{name, role, app_name, ...}`` (or None on ``timeout``).
290+
291+
A zero-latency native wait (UIA AddFocusChangedEventHandler) — unlike the
292+
polling recorder, it can't miss a fast focus transition.
293+
"""
294+
self._unsupported("wait_for_focus_change")
295+
284296
def _unsupported(self, operation: str):
285297
"""Raise a clear error for an action this backend can't perform."""
286298
raise AccessibilityNotAvailableError(

je_auto_control/utils/accessibility/backends/windows_backend.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,11 @@ class WindowsAccessibilityBackend(AccessibilityBackend):
6262
name = "windows-uia"
6363

6464
def __init__(self) -> None:
65+
import threading
6566
self.available = _is_available()
6667
self._automation = None
6768
self._uia_module = None
69+
self._event_lock = threading.Lock()
6870

6971
def _ensure_automation(self):
7072
if self._automation is not None:
@@ -420,6 +422,48 @@ def set_view(self, view="", name=None, role=None, app_name=None,
420422
return False
421423
return False
422424

425+
def _make_focus_handler(self, sink):
426+
"""Build a COM focus-changed event handler that puts events on ``sink``."""
427+
try:
428+
import comtypes
429+
interface = self._uia_module.IUIAutomationFocusChangedEventHandler
430+
except (ImportError, AttributeError):
431+
return None
432+
433+
class _FocusHandler(comtypes.COMObject):
434+
_com_interfaces_ = [interface]
435+
436+
def IUIAutomationFocusChangedEventHandler_HandleFocusChangedEvent(
437+
self, sender): # noqa: N802 # reason: comtypes callback name
438+
element = _convert_uia(sender)
439+
sink.put(element.to_dict() if element else {"focused": True})
440+
return 0
441+
442+
return _FocusHandler()
443+
444+
def wait_for_focus_change(self, timeout=5.0) -> Optional[Dict[str, Any]]:
445+
import queue
446+
automation = self._ensure_automation()
447+
events: "queue.Queue" = queue.Queue()
448+
handler = self._make_focus_handler(events)
449+
if handler is None:
450+
return None
451+
try:
452+
with self._event_lock:
453+
automation.AddFocusChangedEventHandler(None, handler)
454+
except (OSError, AttributeError):
455+
return None
456+
try:
457+
return events.get(timeout=float(timeout))
458+
except queue.Empty:
459+
return None
460+
finally:
461+
with self._event_lock:
462+
try:
463+
automation.RemoveFocusChangedEventHandler(handler)
464+
except (OSError, AttributeError):
465+
pass
466+
423467
def get_table_headers(self, name=None, role=None, app_name=None,
424468
automation_id=None) -> Optional[Dict[str, Any]]:
425469
raw = self._find_raw(name, role, app_name, automation_id)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""Reactive UIA event waits (focus-changed) via the accessibility backend seam."""
2+
from je_auto_control.utils.ax_events.ax_events import wait_for_focus_change
3+
4+
__all__ = ["wait_for_focus_change"]
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Reactive UI Automation event waits (focus-changed).
2+
3+
The accessibility recorder *polls* the focused element every ~250 ms, so it can
4+
miss a fast focus transition and reacts a quarter-second late. UIA exposes real
5+
events: ``wait_for_focus_change`` blocks on the native
6+
``AddFocusChangedEventHandler`` and returns the moment focus moves — the
7+
zero-latency, miss-free "wait until focus lands on the dialog" primitive, the
8+
accessibility-tree analogue of ``wait_for_window`` / ``wait_for_image``.
9+
10+
It is a thin dispatch onto the injectable ``accessibility.backends.get_backend()``
11+
seam — headless-testable on any platform by injecting a fake backend; the real
12+
event subscription (registered / unregistered under a lock, on the calling
13+
thread) lives in the Windows backend. Imports no ``PySide6``.
14+
"""
15+
from typing import Any, Dict, Optional
16+
17+
18+
def wait_for_focus_change(*, timeout: float = 5.0) -> Optional[Dict[str, Any]]:
19+
"""Block until the keyboard focus moves, then return the newly-focused element.
20+
21+
Returns the focused element as ``{name, role, app_name, bounds, ...}``, or
22+
``None`` if no focus change occurs within ``timeout`` seconds.
23+
"""
24+
from je_auto_control.utils.accessibility.backends import get_backend
25+
return get_backend().wait_for_focus_change(float(timeout))

je_auto_control/utils/executor/action_executor.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2602,6 +2602,13 @@ def _set_view(view: str, name: Optional[str] = None, role: Optional[str] = None,
26022602
automation_id=automation_id)
26032603

26042604

2605+
def _wait_for_focus_change(timeout: Any = 5.0) -> Dict[str, Any]:
2606+
"""Adapter: block until the keyboard focus moves (UIA focus event)."""
2607+
from je_auto_control.utils.ax_events import wait_for_focus_change
2608+
element = wait_for_focus_change(timeout=float(timeout))
2609+
return {"changed": element is not None, "element": element}
2610+
2611+
26052612
def _get_control_text(name: Optional[str] = None, role: Optional[str] = None,
26062613
app_name: Optional[str] = None,
26072614
automation_id: Optional[str] = None) -> Dict[str, Any]:
@@ -6591,6 +6598,7 @@ def __init__(self):
65916598
"AC_get_selection": _get_selection,
65926599
"AC_list_views": _list_views,
65936600
"AC_set_view": _set_view,
6601+
"AC_wait_for_focus_change": _wait_for_focus_change,
65946602
"AC_get_control_text": _get_control_text,
65956603
"AC_find_control_text": _find_control_text,
65966604
"AC_select_control_text": _select_control_text,

0 commit comments

Comments
 (0)