Skip to content

Commit fa11c4b

Browse files
committed
Add table_pattern: table headers + cell addressing for native grids
read_control_table dumps a flat 2D list of cell names with no header labels and no way to address one cell by (header, row). Add the missing half: table_headers reads row/column header labels (TablePattern), table_cell reads the cell at (row, column) with its span (GridItemPattern), and cell_by_header resolves the column index from the headers so you can read the cell at (row, named column). Extends the backend ABC + Windows UIA backend via the same fake-backend seam.
1 parent dae9081 commit fa11c4b

13 files changed

Lines changed: 461 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) — Table Headers + Cell Addressing (UIA TablePattern)
4+
5+
Assert "the Status column of row 5 says Shipped" — by header, not by guessing indices. Full reference: [`docs/source/Eng/doc/new_features/v197_features_doc.rst`](docs/source/Eng/doc/new_features/v197_features_doc.rst).
6+
7+
- **`table_headers` / `table_cell` / `cell_by_header`** (`AC_table_headers`, `AC_table_cell`, `AC_cell_by_header`): `read_control_table` (GridPattern) dumps a flat 2-D list of cell names with no header labels and no way to address one cell by (header, row) — you can dump a grid but not test one. This adds the missing half: `table_headers` reads the row/column header labels (TablePattern), `table_cell` reads the cell at `(row, column)` with its span (GridItemPattern), and `cell_by_header` resolves the column index from the headers so you can read the cell at `(row, "Status")` directly. 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) — Rich UIA Element Properties
410

511
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).
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
Table Headers + Cell Addressing (UIA TablePattern)
2+
==================================================
3+
4+
``read_control_table`` (GridPattern) dumps a flat 2-D list of cell names with **no
5+
header labels and no way to address a single cell by (header, row)** — so you can
6+
*dump* a grid but not actually *test* one. ``table_pattern`` adds the missing
7+
half:
8+
9+
* :func:`table_headers` — the row / column header labels (TablePattern),
10+
* :func:`table_cell` — the cell at ``(row, column)`` with its span
11+
(GridPattern.GetItem + GridItemPattern),
12+
* :func:`cell_by_header` — read the cell at ``(row, "Column Header")``, so you can
13+
assert "the Status column of row 5 says Shipped" without guessing indices.
14+
15+
Each is a thin dispatch onto the injectable ``accessibility.backends.get_backend()``
16+
seam — headless-testable by injecting a fake backend; the real UIA calls live in
17+
the Windows backend. Imports no ``PySide6``.
18+
19+
Headless API
20+
------------
21+
22+
.. code-block:: python
23+
24+
from je_auto_control import table_headers, table_cell, cell_by_header
25+
26+
table_headers(name="Orders")
27+
# {"columns": ["Order", "Status", "Total"], "rows": [...]}
28+
29+
table_cell(0, 1, name="Orders")
30+
# {"value": "Shipped", "row": 0, "column": 1, "row_span": 1, "column_span": 1}
31+
32+
cell_by_header(0, "Status", name="Orders") # "Shipped"
33+
34+
The table is located by ``name`` / ``role`` / ``app_name`` / ``automation_id``
35+
(same as ``read_control_table``). ``table_headers`` returns
36+
``{columns, rows}`` (or ``None``); ``table_cell`` returns the cell record (or
37+
``None``); ``cell_by_header`` resolves the column index from the headers and
38+
returns the cell value (``None`` if the header or cell isn't found).
39+
40+
Executor commands
41+
-----------------
42+
43+
``AC_table_headers`` (``{found, headers}``), ``AC_table_cell`` (``row`` /
44+
``column`` → ``{found, cell}``) and ``AC_cell_by_header`` (``row`` /
45+
``column_header`` → ``{found, value}``). They are exposed as read-only ``ac_*``
46+
MCP tools and as Script Builder commands under **Native UI**.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
表頭 + 儲存格定址(UIA TablePattern)
2+
====================================
3+
4+
``read_control_table``(GridPattern)輸出的是一份扁平的 2D 儲存格名稱清單,**沒有表頭標籤,也無法
5+
以(表頭, 列)定址單一儲存格**——所以你能*傾印*一個格線,卻無法真正*測試*它。``table_pattern``
6+
補上所缺的另一半:
7+
8+
* :func:`table_headers` ——列 / 欄的表頭標籤(TablePattern),
9+
* :func:`table_cell` ——位於 ``(row, column)`` 的儲存格及其跨距
10+
(GridPattern.GetItem + GridItemPattern),
11+
* :func:`cell_by_header` ——讀取位於 ``(row, "欄表頭")`` 的儲存格,於是你可以斷言「第 5 列的
12+
Status 欄是 Shipped」,而不必猜索引。
13+
14+
每個都是對可注入的 ``accessibility.backends.get_backend()`` 接縫的薄分派——可透過注入 fake backend
15+
進行無頭測試;真正的 UIA 呼叫位於 Windows 後端。不匯入 ``PySide6``。
16+
17+
無頭 API
18+
--------
19+
20+
.. code-block:: python
21+
22+
from je_auto_control import table_headers, table_cell, cell_by_header
23+
24+
table_headers(name="Orders")
25+
# {"columns": ["Order", "Status", "Total"], "rows": [...]}
26+
27+
table_cell(0, 1, name="Orders")
28+
# {"value": "Shipped", "row": 0, "column": 1, "row_span": 1, "column_span": 1}
29+
30+
cell_by_header(0, "Status", name="Orders") # "Shipped"
31+
32+
表格以 ``name`` / ``role`` / ``app_name`` / ``automation_id`` 定位(與 ``read_control_table``
33+
相同)。``table_headers`` 回傳 ``{columns, rows}``(或 ``None``);``table_cell`` 回傳儲存格記錄
34+
(或 ``None``);``cell_by_header`` 由表頭解析欄索引並回傳儲存格值(找不到表頭或儲存格則為
35+
``None``)。
36+
37+
執行器指令
38+
----------
39+
40+
``AC_table_headers``(``{found, headers}``)、``AC_table_cell``(``row`` / ``column`` →
41+
``{found, cell}``)與 ``AC_cell_by_header``(``row`` / ``column_header`` → ``{found, value}``)。
42+
皆以唯讀 ``ac_*`` MCP 工具及 Script Builder 指令(位於 **Native UI** 分類下)形式提供。

je_auto_control/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@
7272
from je_auto_control.utils.ax_props import (
7373
get_element_properties, is_element_enabled,
7474
)
75+
# Table headers + cell addressing for native grids (UIA TablePattern)
76+
from je_auto_control.utils.table_pattern import (
77+
cell_by_header, table_cell, table_headers,
78+
)
7579
# Rich clipboard formats — RTF + CSV/TSV codecs and Windows get / set
7680
from je_auto_control.utils.clipboard_rich_formats import (
7781
build_rtf, csv_to_rows, get_clipboard_csv, get_clipboard_rtf, rows_to_csv,
@@ -1674,6 +1678,7 @@ def start_autocontrol_gui(*args, **kwargs):
16741678
"is_interactive_role", "tab_order", "audit_focus_order", "focus_control",
16751679
"realize_item",
16761680
"get_element_properties", "is_element_enabled",
1681+
"table_headers", "table_cell", "cell_by_header",
16771682
"build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows",
16781683
"set_clipboard_rtf", "get_clipboard_rtf",
16791684
"set_clipboard_csv", "get_clipboard_csv",

je_auto_control/gui/script_builder/command_schema.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1626,6 +1626,23 @@ def _add_native_control_specs(specs: List[CommandSpec]) -> None:
16261626
fields=fields,
16271627
description="Read rich UIA props (enabled/offscreen/help/status/keys).",
16281628
))
1629+
specs.append(CommandSpec(
1630+
"AC_table_headers", "Native UI", "Get Table Headers",
1631+
fields=fields,
1632+
description="Read a table's row/column header labels (TablePattern).",
1633+
))
1634+
specs.append(CommandSpec(
1635+
"AC_table_cell", "Native UI", "Get Table Cell (by index)",
1636+
fields=(FieldSpec("row", FieldType.INT),
1637+
FieldSpec("column", FieldType.INT)) + fields,
1638+
description="Read the cell at (row, column) with its span.",
1639+
))
1640+
specs.append(CommandSpec(
1641+
"AC_cell_by_header", "Native UI", "Get Table Cell (by header)",
1642+
fields=(FieldSpec("row", FieldType.INT),
1643+
FieldSpec("column_header", FieldType.STRING)) + fields,
1644+
description="Read the cell at (row, named column) — assert by header.",
1645+
))
16291646
specs.append(CommandSpec(
16301647
"AC_get_control_text", "Native UI", "Get Control Text",
16311648
fields=fields,

je_auto_control/utils/accessibility/backends/base.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,25 @@ def get_properties(self, name: Optional[str] = None,
162162
"""
163163
self._unsupported("get_properties")
164164

165+
# --- table headers + cell addressing (TablePattern / GridItemPattern) ---
166+
167+
def get_table_headers(self, name: Optional[str] = None,
168+
role: Optional[str] = None,
169+
app_name: Optional[str] = None,
170+
automation_id: Optional[str] = None,
171+
) -> Optional[Dict[str, Any]]:
172+
"""Return a table's header labels as ``{columns: [...], rows: [...]}``."""
173+
self._unsupported("get_table_headers")
174+
175+
def get_grid_cell(self, row: int = 0, column: int = 0,
176+
name: Optional[str] = None, role: Optional[str] = None,
177+
app_name: Optional[str] = None,
178+
automation_id: Optional[str] = None,
179+
) -> Optional[Dict[str, Any]]:
180+
"""Return the cell at ``(row, column)`` as ``{value, row, column,
181+
row_span, column_span}`` (GridPattern.GetItem + GridItemPattern)."""
182+
self._unsupported("get_grid_cell")
183+
165184
def _unsupported(self, operation: str):
166185
"""Raise a clear error for an action this backend can't perform."""
167186
raise AccessibilityNotAvailableError(

je_auto_control/utils/accessibility/backends/windows_backend.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
_UIA_TEXT_PATTERN_ID = 10014
3333
_UIA_ITEMCONTAINER_PATTERN_ID = 10019
3434
_UIA_VIRTUALIZEDITEM_PATTERN_ID = 10020
35+
_UIA_TABLE_PATTERN_ID = 10012
36+
_UIA_GRIDITEM_PATTERN_ID = 10007
3537
_UIA_AUTOMATIONID_PROPERTY = 30011
3638
_EXPAND_STATES = {0: "collapsed", 1: "expanded", 2: "partial", 3: "leaf"}
3739

@@ -305,6 +307,37 @@ def get_properties(self, name=None, role=None, app_name=None,
305307
return None
306308
return _read_properties(raw)
307309

310+
def get_table_headers(self, name=None, role=None, app_name=None,
311+
automation_id=None) -> Optional[Dict[str, Any]]:
312+
raw = self._find_raw(name, role, app_name, automation_id)
313+
pattern = self._pattern(raw, _UIA_TABLE_PATTERN_ID,
314+
"IUIAutomationTablePattern") if raw else None
315+
if pattern is None:
316+
return None
317+
try:
318+
columns = pattern.GetCurrentColumnHeaders()
319+
rows = pattern.GetCurrentRowHeaders()
320+
except (OSError, AttributeError):
321+
return None
322+
return {"columns": _header_names(columns), "rows": _header_names(rows)}
323+
324+
def get_grid_cell(self, row=0, column=0, name=None, role=None,
325+
app_name=None, automation_id=None) -> Optional[Dict[str, Any]]:
326+
raw = self._find_raw(name, role, app_name, automation_id)
327+
grid = self._pattern(raw, _UIA_GRID_PATTERN_ID,
328+
"IUIAutomationGridPattern") if raw else None
329+
if grid is None:
330+
return None
331+
try:
332+
cell = grid.GetItem(int(row), int(column))
333+
except (OSError, AttributeError):
334+
return None
335+
if not cell:
336+
return None
337+
return _read_cell(self._pattern(cell, _UIA_GRIDITEM_PATTERN_ID,
338+
"IUIAutomationGridItemPattern"),
339+
cell, int(row), int(column))
340+
308341
def _text_pattern(self, name, role, app_name, automation_id):
309342
"""Find a control and return its IUIAutomationTextPattern, or None."""
310343
raw = self._find_raw(name, role, app_name, automation_id)
@@ -373,6 +406,45 @@ def _read_row(pattern, row: int, cols: int):
373406
return cells
374407

375408

409+
def _header_names(array) -> List[str]:
410+
"""Read an IUIAutomationElementArray of header elements into name strings."""
411+
names: List[str] = []
412+
try:
413+
count = int(array.Length or 0)
414+
except (OSError, AttributeError):
415+
return names
416+
for index in range(count):
417+
try:
418+
names.append(str(array.GetElement(index).CurrentName or ""))
419+
except (OSError, AttributeError):
420+
names.append("")
421+
return names
422+
423+
424+
def _read_cell(item_pattern, cell, row: int, column: int) -> Dict[str, Any]:
425+
"""Build a cell record, enriching with GridItemPattern row/col/span if present."""
426+
info: Dict[str, Any] = {
427+
"value": _safe_name(cell), "row": row, "column": column,
428+
"row_span": 1, "column_span": 1,
429+
}
430+
if item_pattern is not None:
431+
for key, attr in (("row", "CurrentRow"), ("column", "CurrentColumn"),
432+
("row_span", "CurrentRowSpan"),
433+
("column_span", "CurrentColumnSpan")):
434+
try:
435+
info[key] = int(getattr(item_pattern, attr))
436+
except (OSError, AttributeError, ValueError, TypeError):
437+
pass
438+
return info
439+
440+
441+
def _safe_name(raw) -> str:
442+
try:
443+
return str(raw.CurrentName or "")
444+
except (OSError, AttributeError):
445+
return ""
446+
447+
376448
def _as_text(value) -> str:
377449
return str(value or "")
378450

je_auto_control/utils/executor/action_executor.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2486,6 +2486,36 @@ def _get_element_properties(name: Optional[str] = None, role: Optional[str] = No
24862486
return {"found": props is not None, "properties": props}
24872487

24882488

2489+
def _table_headers(name: Optional[str] = None, role: Optional[str] = None,
2490+
app_name: Optional[str] = None,
2491+
automation_id: Optional[str] = None) -> Dict[str, Any]:
2492+
"""Adapter: a table's row/column header labels (TablePattern)."""
2493+
from je_auto_control.utils.table_pattern import table_headers
2494+
headers = table_headers(name=name, role=role, app_name=app_name,
2495+
automation_id=automation_id)
2496+
return {"found": headers is not None, "headers": headers}
2497+
2498+
2499+
def _table_cell(row: Any, column: Any, name: Optional[str] = None,
2500+
role: Optional[str] = None, app_name: Optional[str] = None,
2501+
automation_id: Optional[str] = None) -> Dict[str, Any]:
2502+
"""Adapter: the cell at (row, column) with its span (GridItemPattern)."""
2503+
from je_auto_control.utils.table_pattern import table_cell
2504+
cell = table_cell(int(row), int(column), name=name, role=role,
2505+
app_name=app_name, automation_id=automation_id)
2506+
return {"found": cell is not None, "cell": cell}
2507+
2508+
2509+
def _cell_by_header(row: Any, column_header: str, name: Optional[str] = None,
2510+
role: Optional[str] = None, app_name: Optional[str] = None,
2511+
automation_id: Optional[str] = None) -> Dict[str, Any]:
2512+
"""Adapter: read the cell at (row, named column) — assert by header."""
2513+
from je_auto_control.utils.table_pattern import cell_by_header
2514+
value = cell_by_header(int(row), str(column_header), name=name, role=role,
2515+
app_name=app_name, automation_id=automation_id)
2516+
return {"found": value is not None, "value": value}
2517+
2518+
24892519
def _get_control_text(name: Optional[str] = None, role: Optional[str] = None,
24902520
app_name: Optional[str] = None,
24912521
automation_id: Optional[str] = None) -> Dict[str, Any]:
@@ -6431,6 +6461,9 @@ def __init__(self):
64316461
"AC_scroll_control_into_view": _scroll_control_into_view,
64326462
"AC_realize_item": _realize_item,
64336463
"AC_get_element_properties": _get_element_properties,
6464+
"AC_table_headers": _table_headers,
6465+
"AC_table_cell": _table_cell,
6466+
"AC_cell_by_header": _cell_by_header,
64346467
"AC_get_control_text": _get_control_text,
64356468
"AC_get_selected_text": _get_selected_text,
64366469
"AC_get_visible_text": _get_visible_text,

je_auto_control/utils/mcp_server/tools/_factories.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1188,6 +1188,37 @@ def a11y_control_tools() -> List[MCPTool]:
11881188
handler=h.get_element_properties,
11891189
annotations=READ_ONLY,
11901190
),
1191+
MCPTool(
1192+
name="ac_table_headers",
1193+
description=("Read a native table's header labels via TablePattern: "
1194+
"{found, headers:{columns:[...], rows:[...]}}."),
1195+
input_schema=schema(dict(_M)),
1196+
handler=h.table_headers,
1197+
annotations=READ_ONLY,
1198+
),
1199+
MCPTool(
1200+
name="ac_table_cell",
1201+
description=("Read the cell at ('row','column') of a native grid: "
1202+
"{found, cell:{value,row,column,row_span,column_span}} "
1203+
"(GridPattern.GetItem + GridItemPattern)."),
1204+
input_schema=schema({"row": {"type": "integer"},
1205+
"column": {"type": "integer"}, **_M},
1206+
required=["row", "column"]),
1207+
handler=h.table_cell,
1208+
annotations=READ_ONLY,
1209+
),
1210+
MCPTool(
1211+
name="ac_cell_by_header",
1212+
description=("Read the cell at row 'row' in the column named "
1213+
"'column_header' — assert 'the Status column of row 5 "
1214+
"says Shipped' without guessing indices. Returns "
1215+
"{found, value}."),
1216+
input_schema=schema({"row": {"type": "integer"},
1217+
"column_header": {"type": "string"}, **_M},
1218+
required=["row", "column_header"]),
1219+
handler=h.cell_by_header,
1220+
annotations=READ_ONLY,
1221+
),
11911222
MCPTool(
11921223
name="ac_get_control_text",
11931224
description=("Read a control's full text via TextPattern: "

je_auto_control/utils/mcp_server/tools/_handlers.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -809,6 +809,24 @@ def get_element_properties(name=None, role=None, app_name=None,
809809
return _get_element_properties(name, role, app_name, automation_id)
810810

811811

812+
def table_headers(name=None, role=None, app_name=None, automation_id=None):
813+
from je_auto_control.utils.executor.action_executor import _table_headers
814+
return _table_headers(name, role, app_name, automation_id)
815+
816+
817+
def table_cell(row, column, name=None, role=None, app_name=None,
818+
automation_id=None):
819+
from je_auto_control.utils.executor.action_executor import _table_cell
820+
return _table_cell(row, column, name, role, app_name, automation_id)
821+
822+
823+
def cell_by_header(row, column_header, name=None, role=None, app_name=None,
824+
automation_id=None):
825+
from je_auto_control.utils.executor.action_executor import _cell_by_header
826+
return _cell_by_header(row, column_header, name, role, app_name,
827+
automation_id)
828+
829+
812830
def get_selected_text(name=None, role=None, app_name=None, automation_id=None):
813831
from je_auto_control.utils.executor.action_executor import _get_selected_text
814832
return _get_selected_text(name, role, app_name, automation_id)

0 commit comments

Comments
 (0)