Skip to content

Commit dae9081

Browse files
authored
Merge pull request #416 from Integration-Automation/feat/ax-props-batch
Add ax_props: read rich UIA element properties before acting
2 parents 5368cd6 + 0c76ec5 commit dae9081

13 files changed

Lines changed: 313 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) — Rich UIA Element Properties
4+
5+
Know if a control is enabled / off-screen / has a tooltip before you act. Full reference: [`docs/source/Eng/doc/new_features/v196_features_doc.rst`](docs/source/Eng/doc/new_features/v196_features_doc.rst).
6+
7+
- **`get_element_properties` / `is_element_enabled`** (`AC_get_element_properties`): the flat element list carries only name/role/bounds/app/id, but automation needs more before it acts — **is the control enabled** (don't click a disabled button), **is it off-screen**, its **item_status** (field validation/error), **help_text** (tooltip), and **accelerator_key** (drive via hotkey). This reads those high-value UIA properties (`enabled`/`offscreen`/`help_text`/`item_status`/`accelerator_key`/`access_key`/`orientation`); `is_element_enabled` is the common pre-action guard. Dispatched through the injectable accessibility backend seam (headless-testable via a fake backend; real UIA reads in the Windows backend). No `PySide6`.
8+
39
## What's new (2026-06-25) — Realize Off-Screen Items in Virtualized Lists / Grids
410

511
Reach a row that isn't scrolled into view yet — the "element not found in a long list" fix. Full reference: [`docs/source/Eng/doc/new_features/v195_features_doc.rst`](docs/source/Eng/doc/new_features/v195_features_doc.rst).
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
Rich UIA Element Properties
2+
===========================
3+
4+
``list_accessibility_elements`` / ``AccessibilityElement`` carry only name / role /
5+
bounds / app / pid / automation_id. Automation routinely needs more *before it
6+
acts*: **is the control enabled** (don't click a disabled button), **is it
7+
off-screen** (is it really visible?), its **item_status** (validation / error text
8+
on a field), **help_text** (tooltip), and **accelerator_key** (drive it via a
9+
hotkey instead of a click). ``ax_props`` exposes those high-value UIA properties.
10+
11+
* :func:`get_element_properties` — the full property dict,
12+
* :func:`is_element_enabled` — the common pre-action guard.
13+
14+
Each function is a thin dispatch onto the injectable
15+
``accessibility.backends.get_backend()`` seam — headless-testable on any platform
16+
by injecting a fake backend; the real UIA property reads live in the Windows
17+
backend. Imports no ``PySide6``.
18+
19+
Headless API
20+
------------
21+
22+
.. code-block:: python
23+
24+
from je_auto_control import get_element_properties, is_element_enabled
25+
26+
get_element_properties(name="Save", role="button")
27+
# {"enabled": False, "offscreen": False, "help_text": "Save the file",
28+
# "item_status": "", "accelerator_key": "Ctrl+S", "access_key": "S",
29+
# "orientation": 0}
30+
31+
if is_element_enabled(name="Submit"):
32+
click_text("Submit") # don't click a disabled button
33+
34+
The control is located by ``name`` / ``role`` / ``app_name`` / ``automation_id``
35+
(same as the other native-control reads). ``get_element_properties`` returns the
36+
property dict or ``None`` when the control isn't found; ``is_element_enabled``
37+
returns the ``enabled`` flag (or ``None`` if not found).
38+
39+
Executor commands
40+
-----------------
41+
42+
``AC_get_element_properties`` returns ``{found, properties}``. It is exposed as the
43+
read-only ``ac_get_element_properties`` MCP tool and as a Script Builder command
44+
under **Native UI**.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
豐富的 UIA 元素屬性
2+
===================
3+
4+
``list_accessibility_elements`` / ``AccessibilityElement``只帶有 name / role / bounds /
5+
app / pid / automation_id。自動化在*動作之前*常需要更多資訊:**控制項是否啟用**(別點停用的
6+
按鈕)、**是否在畫面外**(是否真的可見)、其 **item_status**(欄位的驗證 / 錯誤文字)、
7+
**help_text**(工具提示),以及 **accelerator_key**(以快捷鍵而非點擊來驅動它)。``ax_props``
8+
就提供這些高價值的 UIA 屬性。
9+
10+
* :func:`get_element_properties` ——完整的屬性字典,
11+
* :func:`is_element_enabled` ——常見的動作前守衛。
12+
13+
每個函式都是對可注入的 ``accessibility.backends.get_backend()`` 接縫的薄分派——可在任何平台透過
14+
注入 fake backend 進行無頭測試;真正的 UIA 屬性讀取位於 Windows 後端。不匯入 ``PySide6``。
15+
16+
無頭 API
17+
--------
18+
19+
.. code-block:: python
20+
21+
from je_auto_control import get_element_properties, is_element_enabled
22+
23+
get_element_properties(name="Save", role="button")
24+
# {"enabled": False, "offscreen": False, "help_text": "Save the file",
25+
# "item_status": "", "accelerator_key": "Ctrl+S", "access_key": "S",
26+
# "orientation": 0}
27+
28+
if is_element_enabled(name="Submit"):
29+
click_text("Submit") # 別點停用的按鈕
30+
31+
控制項以 ``name`` / ``role`` / ``app_name`` / ``automation_id`` 定位(與其他原生控制讀取相同)。
32+
``get_element_properties`` 回傳屬性字典,找不到控制項時回傳 ``None``;``is_element_enabled``
33+
回傳 ``enabled`` 旗標(找不到則為 ``None``)。
34+
35+
執行器指令
36+
----------
37+
38+
``AC_get_element_properties`` 回傳 ``{found, properties}``。以唯讀
39+
``ac_get_element_properties`` MCP 工具及 Script Builder 指令(位於 **Native UI** 分類下)
40+
形式提供。

je_auto_control/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@
6868
)
6969
# Realize off-screen items in virtualized lists / grids (UIA VirtualizedItem)
7070
from je_auto_control.utils.virtualized import realize_item
71+
# Rich UIA element properties (enabled / offscreen / help / status / keys)
72+
from je_auto_control.utils.ax_props import (
73+
get_element_properties, is_element_enabled,
74+
)
7175
# Rich clipboard formats — RTF + CSV/TSV codecs and Windows get / set
7276
from je_auto_control.utils.clipboard_rich_formats import (
7377
build_rtf, csv_to_rows, get_clipboard_csv, get_clipboard_rtf, rows_to_csv,
@@ -1669,6 +1673,7 @@ def start_autocontrol_gui(*args, **kwargs):
16691673
"assign_node_paths", "find_by_path",
16701674
"is_interactive_role", "tab_order", "audit_focus_order", "focus_control",
16711675
"realize_item",
1676+
"get_element_properties", "is_element_enabled",
16721677
"build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows",
16731678
"set_clipboard_rtf", "get_clipboard_rtf",
16741679
"set_clipboard_csv", "get_clipboard_csv",

je_auto_control/gui/script_builder/command_schema.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1621,6 +1621,11 @@ def _add_native_control_specs(specs: List[CommandSpec]) -> None:
16211621
),
16221622
description="Realize an off-screen item in a virtualized list/grid.",
16231623
))
1624+
specs.append(CommandSpec(
1625+
"AC_get_element_properties", "Native UI", "Get Element Properties",
1626+
fields=fields,
1627+
description="Read rich UIA props (enabled/offscreen/help/status/keys).",
1628+
))
16241629
specs.append(CommandSpec(
16251630
"AC_get_control_text", "Native UI", "Get Control Text",
16261631
fields=fields,

je_auto_control/utils/accessibility/backends/base.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,20 @@ def find_virtual_item(self, item_name: Optional[str] = None, by: str = "name",
148148
"""
149149
self._unsupported("find_virtual_item")
150150

151+
# --- rich element properties -------------------------------------------
152+
153+
def get_properties(self, name: Optional[str] = None,
154+
role: Optional[str] = None, app_name: Optional[str] = None,
155+
automation_id: Optional[str] = None,
156+
) -> Optional[Dict[str, Any]]:
157+
"""Return rich UIA properties of the matched control, or None.
158+
159+
Surfaces the high-value properties the flat element list omits —
160+
``enabled`` / ``offscreen`` / ``help_text`` / ``item_status`` /
161+
``accelerator_key`` / ``access_key`` / ``orientation``.
162+
"""
163+
self._unsupported("get_properties")
164+
151165
def _unsupported(self, operation: str):
152166
"""Raise a clear error for an action this backend can't perform."""
153167
raise AccessibilityNotAvailableError(

je_auto_control/utils/accessibility/backends/windows_backend.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,13 @@ def find_virtual_item(self, item_name=None, by="name", container_name=None,
298298
self._realize(found)
299299
return _convert_uia(found)
300300

301+
def get_properties(self, name=None, role=None, app_name=None,
302+
automation_id=None) -> Optional[Dict[str, Any]]:
303+
raw = self._find_raw(name, role, app_name, automation_id)
304+
if not raw:
305+
return None
306+
return _read_properties(raw)
307+
301308
def _text_pattern(self, name, role, app_name, automation_id):
302309
"""Find a control and return its IUIAutomationTextPattern, or None."""
303310
raw = self._find_raw(name, role, app_name, automation_id)
@@ -366,6 +373,33 @@ def _read_row(pattern, row: int, cols: int):
366373
return cells
367374

368375

376+
def _as_text(value) -> str:
377+
return str(value or "")
378+
379+
380+
# (key, UIA element attribute, cast) for the rich properties the flat list omits.
381+
_PROPERTY_READS = (
382+
("enabled", "CurrentIsEnabled", bool),
383+
("offscreen", "CurrentIsOffscreen", bool),
384+
("help_text", "CurrentHelpText", _as_text),
385+
("item_status", "CurrentItemStatus", _as_text),
386+
("accelerator_key", "CurrentAcceleratorKey", _as_text),
387+
("access_key", "CurrentAccessKey", _as_text),
388+
("orientation", "CurrentOrientation", int),
389+
)
390+
391+
392+
def _read_properties(raw) -> Dict[str, Any]:
393+
"""Read the rich UIA properties of a raw element into a plain dict."""
394+
properties: Dict[str, Any] = {}
395+
for key, attribute, cast in _PROPERTY_READS:
396+
try:
397+
properties[key] = cast(getattr(raw, attribute))
398+
except (OSError, AttributeError, ValueError, TypeError):
399+
properties[key] = None
400+
return properties
401+
402+
369403
def _convert_uia(raw) -> Optional[AccessibilityElement]:
370404
try:
371405
name = str(raw.CurrentName or "")
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Read rich UIA element properties (enabled / offscreen / help / status / keys)."""
2+
from je_auto_control.utils.ax_props.ax_props import (
3+
get_element_properties, is_element_enabled,
4+
)
5+
6+
__all__ = ["get_element_properties", "is_element_enabled"]
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""Read rich UI Automation properties the flat element list omits.
2+
3+
``list_accessibility_elements`` / ``AccessibilityElement`` carry only name / role /
4+
bounds / app / pid / automation_id. Automation routinely needs more before it
5+
acts: **is the control enabled** (don't click a disabled button), **is it
6+
off-screen** (is it really visible?), its **item_status** (validation / error text
7+
on a field), **help_text** (tooltip), and **accelerator_key** (drive it via a
8+
hotkey instead of a click). ``ax_props`` exposes those high-value UIA properties.
9+
10+
Each function is a thin dispatch onto the injectable
11+
``accessibility.backends.get_backend()`` seam, so the headless core is
12+
unit-testable on any platform by injecting a fake backend; the real UIA property
13+
reads live in the Windows backend. Imports no ``PySide6``.
14+
"""
15+
from typing import Any, Dict, Optional
16+
17+
18+
def get_element_properties(name: Optional[str] = None, role: Optional[str] = None,
19+
app_name: Optional[str] = None,
20+
automation_id: Optional[str] = None,
21+
) -> Optional[Dict[str, Any]]:
22+
"""Return ``{enabled, offscreen, help_text, item_status, accelerator_key,
23+
access_key, orientation}`` for the matched control, or None if not found."""
24+
from je_auto_control.utils.accessibility.backends import get_backend
25+
return get_backend().get_properties(name=name, role=role, app_name=app_name,
26+
automation_id=automation_id)
27+
28+
29+
def is_element_enabled(name: Optional[str] = None, role: Optional[str] = None,
30+
app_name: Optional[str] = None,
31+
automation_id: Optional[str] = None) -> Optional[bool]:
32+
"""Return whether the matched control is enabled (None if not found).
33+
34+
The common guard before acting — don't click a disabled button.
35+
"""
36+
properties = get_element_properties(name=name, role=role, app_name=app_name,
37+
automation_id=automation_id)
38+
return properties.get("enabled") if properties else None

je_auto_control/utils/executor/action_executor.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2476,6 +2476,16 @@ def _realize_item(item_name: str, by: str = "name",
24762476
"element": element.to_dict() if element else None}
24772477

24782478

2479+
def _get_element_properties(name: Optional[str] = None, role: Optional[str] = None,
2480+
app_name: Optional[str] = None,
2481+
automation_id: Optional[str] = None) -> Dict[str, Any]:
2482+
"""Adapter: read rich UIA properties (enabled/offscreen/help/status/keys)."""
2483+
from je_auto_control.utils.ax_props import get_element_properties
2484+
props = get_element_properties(name=name, role=role, app_name=app_name,
2485+
automation_id=automation_id)
2486+
return {"found": props is not None, "properties": props}
2487+
2488+
24792489
def _get_control_text(name: Optional[str] = None, role: Optional[str] = None,
24802490
app_name: Optional[str] = None,
24812491
automation_id: Optional[str] = None) -> Dict[str, Any]:
@@ -6420,6 +6430,7 @@ def __init__(self):
64206430
"AC_set_control_range": _set_control_range,
64216431
"AC_scroll_control_into_view": _scroll_control_into_view,
64226432
"AC_realize_item": _realize_item,
6433+
"AC_get_element_properties": _get_element_properties,
64236434
"AC_get_control_text": _get_control_text,
64246435
"AC_get_selected_text": _get_selected_text,
64256436
"AC_get_visible_text": _get_visible_text,

0 commit comments

Comments
 (0)