Skip to content

Commit 6f2f1b8

Browse files
authored
Merge pull request #400 from Integration-Automation/feat/focus-order-batch
Add focus_order: keyboard tab sequence, WCAG focus-order audit, set-focus
2 parents b5d6a41 + 3833e01 commit 6f2f1b8

13 files changed

Lines changed: 441 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-24) — Keyboard Focus Order (Tab sequence / WCAG audit / set-focus)
4+
5+
Reason about keyboard navigation: the Tab order, a WCAG focus-order audit, and set-focus. Full reference: [`docs/source/Eng/doc/new_features/v184_features_doc.rst`](docs/source/Eng/doc/new_features/v184_features_doc.rst).
6+
7+
- **`is_interactive_role` / `tab_order` / `audit_focus_order` / `focus_control`** (`AC_tab_order`, `AC_audit_focus_order`, `AC_focus_control`): nothing reasoned about *keyboard* navigation — only mouse coordinates and element values. This adds the keyboard layer: `tab_order` returns the focusable elements in the order Tab visits them (reading order), `audit_focus_order` is a WCAG 2.4.x report (the sequence + flagged problems like a focusable element with no visible area), and `focus_control` sets keyboard focus via UIA `SetFocus`. The first three are pure functions over `AccessibilityElement` lists — `tab_order` reuses `element_parse.reading_order` and `is_interactive_role` reuses `ax_tree_walk.humanize_role`, so no logic is duplicated; `focus_control` dispatches the injectable backend seam (real `SetFocus` in the Windows backend). No `PySide6`.
8+
39
## What's new (2026-06-24) — Readable, Addressable Accessibility Tree (role names + node paths)
410

511
Turn a raw `ControlType_50000` tree dump into readable roles with a stable path per node. Full reference: [`docs/source/Eng/doc/new_features/v183_features_doc.rst`](docs/source/Eng/doc/new_features/v183_features_doc.rst).
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
Keyboard Focus Order (Tab sequence / WCAG audit / set-focus)
2+
============================================================
3+
4+
Nothing in the toolkit reasoned about *keyboard* navigation — only mouse
5+
coordinates and element values. ``focus_order`` adds the keyboard layer:
6+
7+
* :func:`is_interactive_role` — is a role one that normally takes keyboard focus,
8+
* :func:`tab_order` — the focusable elements in the order ``Tab`` will visit them
9+
(their reading order: top-to-bottom, left-to-right),
10+
* :func:`audit_focus_order` — a WCAG 2.4.x focus-order report over a flat element
11+
list (the sequence plus flagged problems, e.g. a focusable element with no
12+
visible area — focus would land somewhere unseen),
13+
* :func:`focus_control` — set the keyboard focus on a control (UIA ``SetFocus``).
14+
15+
The first three are pure functions over ``AccessibilityElement`` lists:
16+
``tab_order`` reuses ``element_parse.reading_order`` for row banding and
17+
``is_interactive_role`` reuses ``ax_tree_walk.humanize_role``, so no logic is
18+
duplicated. ``focus_control`` is a thin dispatch onto the injectable
19+
``accessibility.backends.get_backend()`` seam; the real ``SetFocus`` lives in the
20+
Windows backend. Imports no ``PySide6``.
21+
22+
Headless API
23+
------------
24+
25+
.. code-block:: python
26+
27+
from je_auto_control import (list_accessibility_elements, tab_order,
28+
audit_focus_order, focus_control)
29+
30+
elements = list_accessibility_elements(app_name="myapp.exe")
31+
for el in tab_order(elements): # the Tab visiting order
32+
print(el.name, el.role)
33+
34+
report = audit_focus_order(elements)
35+
# {"order": [...], "issues": [...], "focusable_count": N, "issue_count": M}
36+
37+
focus_control(name="Username", role="edit") # put the cursor in the field
38+
39+
Focusability is role-based (the interactive roles: Button, Edit, CheckBox,
40+
ComboBox, RadioButton, Hyperlink, ListItem, MenuItem, Slider, Tab/TabItem,
41+
TreeItem, …). ``focus_control`` locates by ``name`` / ``role`` / ``app_name`` /
42+
``automation_id`` like the other native-control actions and returns ``bool``.
43+
44+
Executor commands
45+
-----------------
46+
47+
``AC_tab_order`` / ``AC_audit_focus_order`` (``app_name`` / ``max_results``) list
48+
and audit the live app; ``AC_focus_control`` sets focus. They are exposed as the
49+
matching ``ac_*`` MCP tools (the two reads read-only, ``ac_focus_control``
50+
destructive) and as Script Builder commands under **Native UI**.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
鍵盤焦點順序(Tab 序列 / WCAG 稽核 / 設定焦點)
2+
==============================================
3+
4+
工具組原本不對*鍵盤*導覽做任何推理——只有滑鼠座標與元素值。``focus_order`` 補上鍵盤這一層:
5+
6+
* :func:`is_interactive_role` ——某角色是否通常會接受鍵盤焦點,
7+
* :func:`tab_order` ——可聚焦元素依 ``Tab`` 鍵造訪的順序(即其閱讀順序:由上到下、由左到右),
8+
* :func:`audit_focus_order` ——針對扁平元素清單的 WCAG 2.4.x 焦點順序報告(序列加上被標記的
9+
問題,例如某可聚焦元素沒有可見面積——焦點會落在看不見的地方),
10+
* :func:`focus_control` ——將鍵盤焦點設到某控制項上(UIA ``SetFocus``)。
11+
12+
前三者為針對 ``AccessibilityElement`` 清單的純函式:``tab_order`` 重用
13+
``element_parse.reading_order`` 做列分群,``is_interactive_role`` 重用
14+
``ax_tree_walk.humanize_role``,故無重複邏輯。``focus_control`` 是對可注入的
15+
``accessibility.backends.get_backend()`` 接縫的薄分派;真正的 ``SetFocus`` 位於 Windows 後端。
16+
不匯入 ``PySide6``。
17+
18+
無頭 API
19+
--------
20+
21+
.. code-block:: python
22+
23+
from je_auto_control import (list_accessibility_elements, tab_order,
24+
audit_focus_order, focus_control)
25+
26+
elements = list_accessibility_elements(app_name="myapp.exe")
27+
for el in tab_order(elements): # Tab 造訪順序
28+
print(el.name, el.role)
29+
30+
report = audit_focus_order(elements)
31+
# {"order": [...], "issues": [...], "focusable_count": N, "issue_count": M}
32+
33+
focus_control(name="Username", role="edit") # 把游標放進該欄位
34+
35+
可聚焦性以角色判定(互動角色:Button、Edit、CheckBox、ComboBox、RadioButton、Hyperlink、
36+
ListItem、MenuItem、Slider、Tab/TabItem、TreeItem……)。``focus_control`` 與其他原生控制
37+
動作一樣以 ``name`` / ``role`` / ``app_name`` / ``automation_id`` 定位,回傳 ``bool``。
38+
39+
執行器指令
40+
----------
41+
42+
``AC_tab_order`` / ``AC_audit_focus_order``(``app_name`` / ``max_results``)列出並稽核存活的
43+
應用程式;``AC_focus_control`` 設定焦點。三者皆以對應的 ``ac_*`` MCP 工具(兩個讀取為唯讀、
44+
``ac_focus_control`` 為破壞性)及 Script Builder 指令(位於 **Native UI** 分類下)形式提供。

je_auto_control/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@
6262
assign_node_paths, control_type_name, find_by_path, humanize_role,
6363
humanize_tree,
6464
)
65+
# Keyboard focus order (tab sequence / WCAG audit / set-focus)
66+
from je_auto_control.utils.focus_order import (
67+
audit_focus_order, focus_control, is_interactive_role, tab_order,
68+
)
6569
# VLM element locator (headless)
6670
from je_auto_control.utils.vision import (
6771
VLMNotAvailableError, click_by_description, locate_by_description,
@@ -1629,6 +1633,7 @@ def start_autocontrol_gui(*args, **kwargs):
16291633
"get_control_text", "get_selected_text", "get_visible_text",
16301634
"control_type_name", "humanize_role", "humanize_tree",
16311635
"assign_node_paths", "find_by_path",
1636+
"is_interactive_role", "tab_order", "audit_focus_order", "focus_control",
16321637
# VLM locator
16331638
"VLMNotAvailableError", "locate_by_description", "click_by_description",
16341639
"verify_description",

je_auto_control/gui/script_builder/command_schema.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1550,6 +1550,24 @@ def _add_native_control_specs(specs: List[CommandSpec]) -> None:
15501550
fields=(FieldSpec("role", FieldType.STRING),),
15511551
description="Translate a raw UIA role (ControlType_50000) to a name.",
15521552
))
1553+
tree_fields = (FieldSpec("app_name", FieldType.STRING, optional=True),
1554+
FieldSpec("max_results", FieldType.INT, optional=True,
1555+
default=500))
1556+
specs.append(CommandSpec(
1557+
"AC_tab_order", "Native UI", "Keyboard Tab Order",
1558+
fields=tree_fields,
1559+
description="List focusable controls in keyboard Tab (reading) order.",
1560+
))
1561+
specs.append(CommandSpec(
1562+
"AC_audit_focus_order", "Native UI", "Audit Focus Order (WCAG)",
1563+
fields=tree_fields,
1564+
description="WCAG 2.4.x focus-order audit: tab sequence + flagged issues.",
1565+
))
1566+
specs.append(CommandSpec(
1567+
"AC_focus_control", "Native UI", "Set Keyboard Focus",
1568+
fields=fields,
1569+
description="Set keyboard focus on a control natively (UIA SetFocus).",
1570+
))
15531571

15541572

15551573
def _add_misc_specs(specs: List[CommandSpec]) -> None:

je_auto_control/utils/accessibility/backends/base.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,14 @@ def visible_text(self, name: Optional[str] = None, role: Optional[str] = None,
123123
"""Return only the on-screen text of the control (TextPattern), or None."""
124124
self._unsupported("visible_text")
125125

126+
# --- keyboard focus ----------------------------------------------------
127+
128+
def set_focus(self, name: Optional[str] = None, role: Optional[str] = None,
129+
app_name: Optional[str] = None,
130+
automation_id: Optional[str] = None) -> bool:
131+
"""Set keyboard focus on the matched control (SetFocus); True on success."""
132+
self._unsupported("set_focus")
133+
126134
def _unsupported(self, operation: str):
127135
"""Raise a clear error for an action this backend can't perform."""
128136
raise AccessibilityNotAvailableError(

je_auto_control/utils/accessibility/backends/windows_backend.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,17 @@ def visible_text(self, name=None, role=None, app_name=None,
308308
except (OSError, AttributeError):
309309
return None
310310

311+
def set_focus(self, name=None, role=None, app_name=None,
312+
automation_id=None) -> bool:
313+
raw = self._find_raw(name, role, app_name, automation_id)
314+
if not raw:
315+
return False
316+
try:
317+
raw.SetFocus()
318+
return True
319+
except (OSError, AttributeError):
320+
return False
321+
311322
@staticmethod
312323
def _read_row(pattern, row: int, cols: int):
313324
"""Read one grid row into a list of cell strings."""

je_auto_control/utils/executor/action_executor.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,35 @@ def _humanize_role(role: str) -> Dict[str, Any]:
199199
return {"role": humanize_role(role)}
200200

201201

202+
def _tab_order(app_name: Optional[str] = None,
203+
max_results: int = 500) -> Dict[str, Any]:
204+
"""Executor adapter: focusable elements in keyboard Tab order."""
205+
from je_auto_control.utils.accessibility import list_accessibility_elements
206+
from je_auto_control.utils.focus_order import tab_order
207+
elements = list_accessibility_elements(app_name=app_name,
208+
max_results=int(max_results))
209+
return {"order": [el.to_dict() for el in tab_order(elements)]}
210+
211+
212+
def _audit_focus_order(app_name: Optional[str] = None,
213+
max_results: int = 500) -> Dict[str, Any]:
214+
"""Executor adapter: WCAG focus-order audit over the app's elements."""
215+
from je_auto_control.utils.accessibility import list_accessibility_elements
216+
from je_auto_control.utils.focus_order import audit_focus_order
217+
elements = list_accessibility_elements(app_name=app_name,
218+
max_results=int(max_results))
219+
return audit_focus_order(elements)
220+
221+
222+
def _focus_control(name: Optional[str] = None, role: Optional[str] = None,
223+
app_name: Optional[str] = None,
224+
automation_id: Optional[str] = None) -> bool:
225+
"""Executor adapter: set keyboard focus on a control (UIA SetFocus)."""
226+
from je_auto_control.utils.focus_order import focus_control
227+
return focus_control(name=name, role=role, app_name=app_name,
228+
automation_id=automation_id)
229+
230+
202231
def _a11y_record_start(app_name: Optional[str] = None,
203232
poll_interval_s: float = 0.25,
204233
min_movement_px: int = 8) -> Dict[str, Any]:
@@ -6127,6 +6156,9 @@ def __init__(self):
61276156
"AC_a11y_dump": _a11y_dump,
61286157
"AC_walk_tree": _walk_tree,
61296158
"AC_humanize_role": _humanize_role,
6159+
"AC_tab_order": _tab_order,
6160+
"AC_audit_focus_order": _audit_focus_order,
6161+
"AC_focus_control": _focus_control,
61306162
"AC_control_get_value": _control_get_value,
61316163
"AC_control_set_value": _control_set_value,
61326164
"AC_control_invoke": _control_invoke,
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"""Keyboard focus order: expected Tab sequence, WCAG audit, and set-focus."""
2+
from je_auto_control.utils.focus_order.focus_order import (
3+
audit_focus_order, focus_control, is_interactive_role, tab_order,
4+
)
5+
6+
__all__ = [
7+
"is_interactive_role", "tab_order", "audit_focus_order", "focus_control",
8+
]
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""Keyboard focus order: expected Tab sequence, a WCAG audit, and set-focus.
2+
3+
Nothing in the toolkit reasons about *keyboard* navigation. ``focus_order`` adds:
4+
5+
* :func:`is_interactive_role` — is a role one that normally takes keyboard focus,
6+
* :func:`tab_order` — the focusable elements in the order ``Tab`` will visit them
7+
(their reading order: top-to-bottom, left-to-right),
8+
* :func:`audit_focus_order` — a WCAG 2.4.x focus-order report over a flat element
9+
list (the sequence plus flagged problems, e.g. a focusable element with no
10+
visible area),
11+
* :func:`focus_control` — set the keyboard focus on a control (device action).
12+
13+
The first three are pure functions over :class:`AccessibilityElement` lists —
14+
``tab_order`` reuses :func:`element_parse.reading_order` for row banding and
15+
``is_interactive_role`` reuses :func:`ax_tree_walk.humanize_role`, so no logic is
16+
duplicated. ``focus_control`` is a thin dispatch onto the injectable
17+
``accessibility.backends.get_backend()`` seam; the real ``SetFocus`` call lives in
18+
the Windows backend. Imports no ``PySide6``.
19+
"""
20+
from typing import Any, Dict, List, Optional, Sequence, Union
21+
22+
from je_auto_control.utils.accessibility.element import AccessibilityElement
23+
from je_auto_control.utils.ax_tree_walk import humanize_role
24+
from je_auto_control.utils.element_parse import reading_order
25+
26+
# Roles that conventionally participate in keyboard tab navigation.
27+
_INTERACTIVE_ROLES = frozenset({
28+
"Button", "Calendar", "CheckBox", "ComboBox", "Edit", "Hyperlink",
29+
"ListItem", "MenuItem", "RadioButton", "ScrollBar", "Slider", "Spinner",
30+
"SplitButton", "Tab", "TabItem", "TreeItem", "DataItem", "Thumb",
31+
})
32+
33+
34+
def is_interactive_role(role: Union[str, int]) -> bool:
35+
"""Return True if ``role`` is one that normally accepts keyboard focus."""
36+
return humanize_role(role) in _INTERACTIVE_ROLES
37+
38+
39+
def _box(element: AccessibilityElement, index: int) -> Dict[str, Any]:
40+
left, top, width, height = element.bounds
41+
return {"x": left, "y": top, "width": width, "height": height, "_idx": index}
42+
43+
44+
def tab_order(elements: Sequence[AccessibilityElement], *,
45+
row_tol: int = 12) -> List[AccessibilityElement]:
46+
"""Return the focusable elements in the order ``Tab`` would visit them.
47+
48+
Filters to :func:`is_interactive_role` then orders by reading order (rows
49+
within ``row_tol`` px share a row, ordered left-to-right).
50+
"""
51+
interactive = [el for el in elements if is_interactive_role(el.role)]
52+
boxes = [_box(el, index) for index, el in enumerate(interactive)]
53+
ordered = reading_order(boxes, row_tol=int(row_tol))
54+
return [interactive[box["_idx"]] for box in ordered]
55+
56+
57+
def audit_focus_order(elements: Sequence[AccessibilityElement], *,
58+
row_tol: int = 12) -> Dict[str, Any]:
59+
"""Return a WCAG 2.4.x focus-order report over a flat element list.
60+
61+
``order`` is the expected Tab sequence (``tab_index`` / ``name`` / ``role`` /
62+
``bounds``); ``issues`` flags focusable elements with no visible area
63+
(WCAG 2.4.7 Focus Visible — focus would land somewhere unseen).
64+
"""
65+
order = tab_order(elements, row_tol=row_tol)
66+
sequence: List[Dict[str, Any]] = []
67+
issues: List[Dict[str, Any]] = []
68+
for tab_index, element in enumerate(order):
69+
role = humanize_role(element.role)
70+
_left, _top, width, height = element.bounds
71+
sequence.append({"tab_index": tab_index, "name": element.name,
72+
"role": role, "bounds": list(element.bounds)})
73+
if width <= 0 or height <= 0:
74+
issues.append({"tab_index": tab_index, "name": element.name,
75+
"role": role, "issue": "zero_area_focusable",
76+
"wcag": "2.4.7 Focus Visible"})
77+
return {"order": sequence, "issues": issues,
78+
"focusable_count": len(order), "issue_count": len(issues)}
79+
80+
81+
def focus_control(name: Optional[str] = None, role: Optional[str] = None,
82+
app_name: Optional[str] = None,
83+
automation_id: Optional[str] = None) -> bool:
84+
"""Set keyboard focus on the matched control (UIA SetFocus); True on success."""
85+
from je_auto_control.utils.accessibility.backends import get_backend
86+
return get_backend().set_focus(name=name, role=role, app_name=app_name,
87+
automation_id=automation_id)

0 commit comments

Comments
 (0)