Skip to content

Commit fb26620

Browse files
committed
Add virtualized: realize off-screen items in virtualized lists/grids
Long lists/grids/trees only materialize visible rows, so an off-screen row has no accessibility element at all - list/read_table/select can't see it and scroll_control_into_view can't help because it doesn't exist yet. realize_item locates the item by property (ItemContainerPattern) and realizes it (VirtualizedItemPattern) so it becomes a real element. Extends the backend ABC + Windows UIA backend via the same fake-backend seam as control_patterns.
1 parent b25b747 commit fb26620

13 files changed

Lines changed: 338 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) — Realize Off-Screen Items in Virtualized Lists / Grids
4+
5+
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).
6+
7+
- **`realize_item`** (`AC_realize_item`): long lists / data grids / trees only materialize visible rows, so an off-screen row has no accessibility element at all — `list_accessibility_elements` / `read_control_table` / `select_control_item` can't see it, and `scroll_control_into_view` can't help because the element doesn't exist yet. This locates the item by property (UIA `ItemContainerPattern.FindItemByProperty`) and realizes it (`VirtualizedItemPattern.Realize`) so it becomes a real, clickable element. Match `by` name (default) or `automation_id`; locate the container by name/role/app. 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) — Per-Run Step Timeline (waterfall + bottleneck steps)
410

511
Read why *this* run was slow — a step waterfall and its bottlenecks. Full reference: [`docs/source/Eng/doc/new_features/v194_features_doc.rst`](docs/source/Eng/doc/new_features/v194_features_doc.rst).
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
Realize Off-Screen Items in Virtualized Lists / Grids
2+
=====================================================
3+
4+
Long lists, data grids and trees (WPF / WinUI / File Explorer / virtual
5+
treeviews) only materialize the rows that are scrolled into view — a row that is
6+
off-screen has **no** accessibility element at all. So
7+
``list_accessibility_elements`` / ``read_control_table`` / ``select_control_item``
8+
simply cannot see it, and ``scroll_control_into_view`` can't help because the
9+
target element does not exist yet. This is the classic "element not found in a
10+
long list" wall.
11+
12+
``realize_item`` closes that gap: it locates the item inside its container by
13+
property (UI Automation ``ItemContainerPattern.FindItemByProperty``) and realizes
14+
it (``VirtualizedItemPattern.Realize``) so it materializes as a real element you
15+
can then click or read.
16+
17+
It is a thin dispatch onto the injectable ``accessibility.backends.get_backend()``
18+
seam (the same seam the rest of the accessibility module uses) — headless-testable
19+
on any platform by injecting a fake backend; the real UIA calls live 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 realize_item, click_accessibility_element
28+
29+
# Bring a far-down row into existence, then act on it:
30+
row = realize_item("Order 5000", container_name="Orders")
31+
if row is not None:
32+
click_accessibility_element(name=row.name) # now a real element
33+
34+
realize_item("row-42", by="automation_id", container_name="DataGrid")
35+
36+
``item_name`` is matched against the item's Name (``by="name"``, default) or its
37+
AutomationId (``by="automation_id"``). The container is located by
38+
``container_name`` / ``container_role`` / ``app_name`` / ``automation_id`` (the
39+
same matchers as the other native-control actions). Returns the realized
40+
``AccessibilityElement``, or ``None`` if the container or item isn't found.
41+
42+
Executor commands
43+
-----------------
44+
45+
``AC_realize_item`` (``item_name`` / ``by`` / ``container_name`` /
46+
``container_role`` / ``app_name`` / ``automation_id``) returns
47+
``{found, element}``. It is exposed as the read-only ``ac_realize_item`` MCP tool
48+
and as a Script Builder command under **Native UI**.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
實體化虛擬化清單 / 格線中的離畫面項目
2+
======================================
3+
4+
長清單、資料格線與樹(WPF / WinUI / 檔案總管 / 虛擬化 treeview)只會實體化已捲入視野的列——
5+
離畫面的列**完全沒有**無障礙元素。因此 ``list_accessibility_elements`` /
6+
``read_control_table`` / ``select_control_item`` 根本看不到它,而 ``scroll_control_into_view``
7+
也幫不上忙,因為目標元素根本還不存在。這就是經典的「長清單裡找不到元素」的牆。
8+
9+
``realize_item`` 補上這個缺口:它以屬性在容器內定位該項目(UI Automation
10+
``ItemContainerPattern.FindItemByProperty``)並將其實體化(``VirtualizedItemPattern.Realize``),
11+
使其成為一個真正、可點擊或可讀取的元素。
12+
13+
它是對可注入的 ``accessibility.backends.get_backend()`` 接縫的薄分派(與無障礙模組其餘部分相同的
14+
接縫)——可在任何平台透過注入 fake backend 進行無頭測試;真正的 UIA 呼叫位於 Windows 後端。
15+
不匯入 ``PySide6``。
16+
17+
無頭 API
18+
--------
19+
20+
.. code-block:: python
21+
22+
from je_auto_control import realize_item, click_accessibility_element
23+
24+
# 讓一個很下方的列「存在」,然後對它操作:
25+
row = realize_item("Order 5000", container_name="Orders")
26+
if row is not None:
27+
click_accessibility_element(name=row.name) # 現在是真正的元素
28+
29+
realize_item("row-42", by="automation_id", container_name="DataGrid")
30+
31+
``item_name`` 會比對項目的 Name(``by="name"``,預設)或其 AutomationId
32+
(``by="automation_id"``)。容器以 ``container_name`` / ``container_role`` / ``app_name`` /
33+
``automation_id`` 定位(與其他原生控制動作相同的比對方式)。回傳實體化後的
34+
``AccessibilityElement``,若找不到容器或項目則回傳 ``None``。
35+
36+
執行器指令
37+
----------
38+
39+
``AC_realize_item``(``item_name`` / ``by`` / ``container_name`` / ``container_role`` /
40+
``app_name`` / ``automation_id``)回傳 ``{found, element}``。以唯讀 ``ac_realize_item`` MCP
41+
工具及 Script Builder 指令(位於 **Native UI** 分類下)形式提供。

je_auto_control/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@
6666
from je_auto_control.utils.focus_order import (
6767
audit_focus_order, focus_control, is_interactive_role, tab_order,
6868
)
69+
# Realize off-screen items in virtualized lists / grids (UIA VirtualizedItem)
70+
from je_auto_control.utils.virtualized import realize_item
6971
# Rich clipboard formats — RTF + CSV/TSV codecs and Windows get / set
7072
from je_auto_control.utils.clipboard_rich_formats import (
7173
build_rtf, csv_to_rows, get_clipboard_csv, get_clipboard_rtf, rows_to_csv,
@@ -1666,6 +1668,7 @@ def start_autocontrol_gui(*args, **kwargs):
16661668
"control_type_name", "humanize_role", "humanize_tree",
16671669
"assign_node_paths", "find_by_path",
16681670
"is_interactive_role", "tab_order", "audit_focus_order", "focus_control",
1671+
"realize_item",
16691672
"build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows",
16701673
"set_clipboard_rtf", "get_clipboard_rtf",
16711674
"set_clipboard_csv", "get_clipboard_csv",

je_auto_control/gui/script_builder/command_schema.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1608,6 +1608,19 @@ def _add_native_control_specs(specs: List[CommandSpec]) -> None:
16081608
fields=fields,
16091609
description="Scroll a control into view (ScrollItemPattern).",
16101610
))
1611+
specs.append(CommandSpec(
1612+
"AC_realize_item", "Native UI", "Realize Virtualized Item",
1613+
fields=(
1614+
FieldSpec("item_name", FieldType.STRING),
1615+
FieldSpec("by", FieldType.ENUM, optional=True, default="name",
1616+
choices=("name", "automation_id")),
1617+
FieldSpec("container_name", FieldType.STRING, optional=True),
1618+
FieldSpec("container_role", FieldType.STRING, optional=True),
1619+
FieldSpec("app_name", FieldType.STRING, optional=True),
1620+
FieldSpec("automation_id", FieldType.STRING, optional=True),
1621+
),
1622+
description="Realize an off-screen item in a virtualized list/grid.",
1623+
))
16111624
specs.append(CommandSpec(
16121625
"AC_get_control_text", "Native UI", "Get Control Text",
16131626
fields=fields,

je_auto_control/utils/accessibility/backends/base.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,23 @@ def set_focus(self, name: Optional[str] = None, role: Optional[str] = None,
131131
"""Set keyboard focus on the matched control (SetFocus); True on success."""
132132
self._unsupported("set_focus")
133133

134+
# --- virtualized items (realize off-screen list / grid items) -----------
135+
136+
def find_virtual_item(self, item_name: Optional[str] = None, by: str = "name",
137+
container_name: Optional[str] = None,
138+
container_role: Optional[str] = None,
139+
app_name: Optional[str] = None,
140+
automation_id: Optional[str] = None,
141+
) -> Optional[AccessibilityElement]:
142+
"""Find a (possibly virtualized) item inside a container and realize it.
143+
144+
Long virtualized lists / grids only materialize visible rows; this locates
145+
the item by property (``ItemContainerPattern``) and realizes it
146+
(``VirtualizedItemPattern``) so it exists as a real element. Returns the
147+
realized element, or None if the container or item isn't found.
148+
"""
149+
self._unsupported("find_virtual_item")
150+
134151
def _unsupported(self, operation: str):
135152
"""Raise a clear error for an action this backend can't perform."""
136153
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
@@ -30,6 +30,9 @@
3030
_UIA_RANGEVALUE_PATTERN_ID = 10003
3131
_UIA_SCROLLITEM_PATTERN_ID = 10017
3232
_UIA_TEXT_PATTERN_ID = 10014
33+
_UIA_ITEMCONTAINER_PATTERN_ID = 10019
34+
_UIA_VIRTUALIZEDITEM_PATTERN_ID = 10020
35+
_UIA_AUTOMATIONID_PROPERTY = 30011
3336
_EXPAND_STATES = {0: "collapsed", 1: "expanded", 2: "partial", 3: "leaf"}
3437

3538

@@ -264,6 +267,37 @@ def get_range(self, name=None, role=None, app_name=None,
264267
except (OSError, AttributeError, ValueError, TypeError):
265268
return None
266269

270+
def _realize(self, raw) -> None:
271+
"""Realize a virtualized element so it materializes (VirtualizedItemPattern)."""
272+
pattern = self._pattern(raw, _UIA_VIRTUALIZEDITEM_PATTERN_ID,
273+
"IUIAutomationVirtualizedItemPattern")
274+
if pattern is None:
275+
return
276+
try:
277+
pattern.Realize()
278+
except (OSError, AttributeError):
279+
pass
280+
281+
def find_virtual_item(self, item_name=None, by="name", container_name=None,
282+
container_role=None, app_name=None, automation_id=None):
283+
container = self._find_raw(container_name, container_role, app_name,
284+
automation_id)
285+
pattern = self._pattern(container, _UIA_ITEMCONTAINER_PATTERN_ID,
286+
"IUIAutomationItemContainerPattern"
287+
) if container else None
288+
if pattern is None:
289+
return None
290+
property_id = (_UIA_AUTOMATIONID_PROPERTY if by == "automation_id"
291+
else _UIA_NAME_PROPERTY)
292+
try:
293+
found = pattern.FindItemByProperty(None, property_id, item_name)
294+
except (OSError, AttributeError, ValueError):
295+
return None
296+
if not found:
297+
return None
298+
self._realize(found)
299+
return _convert_uia(found)
300+
267301
def _text_pattern(self, name, role, app_name, automation_id):
268302
"""Find a control and return its IUIAutomationTextPattern, or None."""
269303
raw = self._find_raw(name, role, app_name, automation_id)

je_auto_control/utils/executor/action_executor.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2462,6 +2462,20 @@ def _scroll_control_into_view(name: Optional[str] = None, role: Optional[str] =
24622462
automation_id=automation_id)
24632463

24642464

2465+
def _realize_item(item_name: str, by: str = "name",
2466+
container_name: Optional[str] = None,
2467+
container_role: Optional[str] = None,
2468+
app_name: Optional[str] = None,
2469+
automation_id: Optional[str] = None) -> Dict[str, Any]:
2470+
"""Adapter: find + realize a virtualized list/grid item (VirtualizedItem)."""
2471+
from je_auto_control.utils.virtualized import realize_item
2472+
element = realize_item(item_name, by=str(by), container_name=container_name,
2473+
container_role=container_role, app_name=app_name,
2474+
automation_id=automation_id)
2475+
return {"found": element is not None,
2476+
"element": element.to_dict() if element else None}
2477+
2478+
24652479
def _get_control_text(name: Optional[str] = None, role: Optional[str] = None,
24662480
app_name: Optional[str] = None,
24672481
automation_id: Optional[str] = None) -> Dict[str, Any]:
@@ -6405,6 +6419,7 @@ def __init__(self):
64056419
"AC_control_range": _control_range,
64066420
"AC_set_control_range": _set_control_range,
64076421
"AC_scroll_control_into_view": _scroll_control_into_view,
6422+
"AC_realize_item": _realize_item,
64086423
"AC_get_control_text": _get_control_text,
64096424
"AC_get_selected_text": _get_selected_text,
64106425
"AC_get_visible_text": _get_visible_text,

je_auto_control/utils/mcp_server/tools/_factories.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1157,6 +1157,27 @@ def a11y_control_tools() -> List[MCPTool]:
11571157
handler=h.scroll_control_into_view,
11581158
annotations=DESTRUCTIVE,
11591159
),
1160+
MCPTool(
1161+
name="ac_realize_item",
1162+
description=("Find and REALIZE an off-screen item in a virtualized "
1163+
"list/grid (ItemContainer + VirtualizedItem patterns) so "
1164+
"it materializes as a real element — rows that aren't "
1165+
"scrolled into view have no element until realized. "
1166+
"'item_name' matched by 'name' (default) or "
1167+
"'automation_id'; the container by container_name/"
1168+
"container_role/app_name/automation_id. Returns "
1169+
"{found, element}."),
1170+
input_schema=schema({
1171+
"item_name": {"type": "string"},
1172+
"by": {"type": "string", "enum": ["name", "automation_id"]},
1173+
"container_name": {"type": "string"},
1174+
"container_role": {"type": "string"},
1175+
"app_name": {"type": "string"},
1176+
"automation_id": {"type": "string"}},
1177+
required=["item_name"]),
1178+
handler=h.realize_item,
1179+
annotations=READ_ONLY,
1180+
),
11601181
MCPTool(
11611182
name="ac_get_control_text",
11621183
description=("Read a control's full text via TextPattern: "

je_auto_control/utils/mcp_server/tools/_handlers.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,13 @@ def get_control_text(name=None, role=None, app_name=None, automation_id=None):
795795
return _get_control_text(name, role, app_name, automation_id)
796796

797797

798+
def realize_item(item_name, by="name", container_name=None, container_role=None,
799+
app_name=None, automation_id=None):
800+
from je_auto_control.utils.executor.action_executor import _realize_item
801+
return _realize_item(item_name, by, container_name, container_role,
802+
app_name, automation_id)
803+
804+
798805
def get_selected_text(name=None, role=None, app_name=None, automation_id=None):
799806
from je_auto_control.utils.executor.action_executor import _get_selected_text
800807
return _get_selected_text(name, role, app_name, automation_id)

0 commit comments

Comments
 (0)