Skip to content

Commit 930388e

Browse files
authored
Merge pull request #398 from Integration-Automation/feat/ax-text-batch
Add ax_text: native TextPattern reads (document / selection / visible)
2 parents 1111f6d + 1ff5dab commit 930388e

13 files changed

Lines changed: 417 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) — Native Text Reading via the UIA TextPattern (document / selection / visible)
4+
5+
Read the text in multiline editors and document controls where ValuePattern returns nothing. Full reference: [`docs/source/Eng/doc/new_features/v182_features_doc.rst`](docs/source/Eng/doc/new_features/v182_features_doc.rst).
6+
7+
- **`get_control_text` / `get_selected_text` / `get_visible_text`** (`AC_get_control_text`, `AC_get_selected_text`, `AC_get_visible_text`): `control_get_value` reads through UIA ValuePattern, which returns an empty string on multiline edits, RichEdit / document controls and web text areas — exactly the controls whose text you most want. This reads through `TextPattern` instead: `get_control_text` returns the whole `DocumentRange`, `get_selected_text` the current `GetSelection`, `get_visible_text` only the on-screen `GetVisibleRanges`. Dispatched through the injectable `accessibility.backends.get_backend()` seam (headless-testable via a fake backend; real UIA calls in the Windows backend), returning `{text}` from the executor/MCP. No `PySide6`.
8+
39
## What's new (2026-06-24) — Extended UIA Control Patterns (Expand / Select / Range / Scroll)
410

511
Drive tree nodes, list/combo items, sliders and scroll natively, not by pixel guessing. Full reference: [`docs/source/Eng/doc/new_features/v181_features_doc.rst`](docs/source/Eng/doc/new_features/v181_features_doc.rst).
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
Native Text Reading via the UIA TextPattern (document / selection / visible)
2+
============================================================================
3+
4+
``control_get_value`` reads a control through UIA ValuePattern, but ValuePattern
5+
returns an **empty string** on multiline edits, RichEdit / document controls and
6+
web text areas — exactly the controls whose text you most want to read. UIA
7+
exposes that text through a different pattern, ``TextPattern``, which models the
8+
control's content as text ranges. ``ax_text`` adds three reads on top of the
9+
existing accessibility backend ABC:
10+
11+
* :func:`get_control_text` — the whole document's text (``DocumentRange``),
12+
* :func:`get_selected_text` — the currently selected text (``GetSelection``),
13+
* :func:`get_visible_text` — only the on-screen text (``GetVisibleRanges``).
14+
15+
Each function is a thin dispatch onto the injectable
16+
``accessibility.backends.get_backend()`` seam (the same seam the rest of the
17+
accessibility module uses), so the headless core is unit-testable on any
18+
platform by injecting a fake backend; the real UI Automation calls live in the
19+
Windows backend. Backends that don't implement TextPattern raise
20+
``AccessibilityNotAvailableError``. Imports no ``PySide6``.
21+
22+
Headless API
23+
------------
24+
25+
.. code-block:: python
26+
27+
from je_auto_control import (get_control_text, get_selected_text,
28+
get_visible_text)
29+
30+
# A multiline editor where control_get_value returns "" :
31+
text = get_control_text(name="Editor", role="document")
32+
selection = get_selected_text(name="Editor") # "" when nothing selected
33+
on_screen = get_visible_text(name="Editor") # skips scrolled-off lines
34+
35+
All locate the control by ``name`` / ``role`` / ``app_name`` / ``automation_id``
36+
(same as ``control_get_value`` / ``control_invoke``). Each returns the text as a
37+
``str``, or ``None`` when the control is not found or exposes no TextPattern;
38+
``get_selected_text`` returns ``""`` when the control is found but has no
39+
selection.
40+
41+
Executor commands
42+
-----------------
43+
44+
``AC_get_control_text`` / ``AC_get_selected_text`` / ``AC_get_visible_text`` each
45+
return ``{"text": ...}``. They are exposed as the matching read-only ``ac_*`` MCP
46+
tools and as Script Builder commands under **Native UI**.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
透過 UIA TextPattern 讀取原生文字(文件 / 選取 / 可見)
2+
=======================================================
3+
4+
``control_get_value`` 透過 UIA ValuePattern 讀取控制項,但 ValuePattern 在多行編輯框、
5+
RichEdit / 文件控制項與網頁文字區塊上會回傳**空字串**——而這些正是你最想讀取其文字的控制項。
6+
UIA 透過另一個模式 ``TextPattern`` 提供這些文字,它把控制項內容建模為文字範圍(text range)。
7+
``ax_text`` 在既有的無障礙後端 ABC 之上補上三種讀取:
8+
9+
* :func:`get_control_text` ——整份文件的文字(``DocumentRange``),
10+
* :func:`get_selected_text` ——目前選取的文字(``GetSelection``),
11+
* :func:`get_visible_text` ——僅螢幕上可見的文字(``GetVisibleRanges``)。
12+
13+
每個函式都是對可注入的 ``accessibility.backends.get_backend()`` 接縫的薄分派(與無障礙模組
14+
其餘部分相同的接縫),因此無頭核心可在任何平台透過注入 fake backend 單元測試;真正的
15+
UI Automation 呼叫位於 Windows 後端。未實作 TextPattern 的後端會拋出
16+
``AccessibilityNotAvailableError``。不匯入 ``PySide6``。
17+
18+
無頭 API
19+
--------
20+
21+
.. code-block:: python
22+
23+
from je_auto_control import (get_control_text, get_selected_text,
24+
get_visible_text)
25+
26+
# 一個 control_get_value 會回傳 "" 的多行編輯框:
27+
text = get_control_text(name="Editor", role="document")
28+
selection = get_selected_text(name="Editor") # 沒有選取時回傳 ""
29+
on_screen = get_visible_text(name="Editor") # 略過捲動到畫面外的列
30+
31+
全部以 ``name`` / ``role`` / ``app_name`` / ``automation_id`` 定位控制項(與
32+
``control_get_value`` / ``control_invoke`` 相同)。各函式以 ``str`` 回傳文字,找不到控制項或
33+
控制項未提供 TextPattern 時回傳 ``None``;``get_selected_text`` 在找到控制項但沒有選取時
34+
回傳 ``""``。
35+
36+
執行器指令
37+
----------
38+
39+
``AC_get_control_text`` / ``AC_get_selected_text`` / ``AC_get_visible_text`` 各自回傳
40+
``{"text": ...}``。皆以對應的唯讀 ``ac_*`` MCP 工具及 Script Builder 指令(位於 **Native UI**
41+
分類下)形式提供。

je_auto_control/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@
5353
collapse_control, control_expand_state, control_range, expand_control,
5454
scroll_control_into_view, select_control_item, set_control_range,
5555
)
56+
# Native text reads via UIA TextPattern (document / selection / visible)
57+
from je_auto_control.utils.ax_text import (
58+
get_control_text, get_selected_text, get_visible_text,
59+
)
5660
# VLM element locator (headless)
5761
from je_auto_control.utils.vision import (
5862
VLMNotAvailableError, click_by_description, locate_by_description,
@@ -1617,6 +1621,7 @@ def start_autocontrol_gui(*args, **kwargs):
16171621
"expand_control", "collapse_control", "control_expand_state",
16181622
"select_control_item", "control_range", "set_control_range",
16191623
"scroll_control_into_view",
1624+
"get_control_text", "get_selected_text", "get_visible_text",
16201625
# VLM locator
16211626
"VLMNotAvailableError", "locate_by_description", "click_by_description",
16221627
"verify_description",

je_auto_control/gui/script_builder/command_schema.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1523,6 +1523,21 @@ def _add_native_control_specs(specs: List[CommandSpec]) -> None:
15231523
fields=fields,
15241524
description="Scroll a control into view (ScrollItemPattern).",
15251525
))
1526+
specs.append(CommandSpec(
1527+
"AC_get_control_text", "Native UI", "Get Control Text",
1528+
fields=fields,
1529+
description="Read full text via TextPattern (multiline / document safe).",
1530+
))
1531+
specs.append(CommandSpec(
1532+
"AC_get_selected_text", "Native UI", "Get Selected Text",
1533+
fields=fields,
1534+
description="Read the currently selected text via TextPattern.",
1535+
))
1536+
specs.append(CommandSpec(
1537+
"AC_get_visible_text", "Native UI", "Get Visible Text",
1538+
fields=fields,
1539+
description="Read only the on-screen text via TextPattern.GetVisibleRanges.",
1540+
))
15261541

15271542

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

je_auto_control/utils/accessibility/backends/base.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,29 @@ def scroll_into_view(self, name: Optional[str] = None,
100100
"""Scroll the matched control into view (ScrollItemPattern); True on success."""
101101
self._unsupported("scroll_into_view")
102102

103+
# --- text patterns (TextPattern reads) ---------------------------------
104+
105+
def document_text(self, name: Optional[str] = None, role: Optional[str] = None,
106+
app_name: Optional[str] = None,
107+
automation_id: Optional[str] = None) -> Optional[str]:
108+
"""Return the matched control's full text (TextPattern), or None.
109+
110+
Reads multiline / document controls where ValuePattern returns ``""``.
111+
"""
112+
self._unsupported("document_text")
113+
114+
def selected_text(self, name: Optional[str] = None, role: Optional[str] = None,
115+
app_name: Optional[str] = None,
116+
automation_id: Optional[str] = None) -> Optional[str]:
117+
"""Return the control's currently selected text (TextPattern), or None."""
118+
self._unsupported("selected_text")
119+
120+
def visible_text(self, name: Optional[str] = None, role: Optional[str] = None,
121+
app_name: Optional[str] = None,
122+
automation_id: Optional[str] = None) -> Optional[str]:
123+
"""Return only the on-screen text of the control (TextPattern), or None."""
124+
self._unsupported("visible_text")
125+
103126
def _unsupported(self, operation: str):
104127
"""Raise a clear error for an action this backend can't perform."""
105128
raise AccessibilityNotAvailableError(

je_auto_control/utils/accessibility/backends/windows_backend.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
_UIA_SELECTIONITEM_PATTERN_ID = 10010
3030
_UIA_RANGEVALUE_PATTERN_ID = 10003
3131
_UIA_SCROLLITEM_PATTERN_ID = 10017
32+
_UIA_TEXT_PATTERN_ID = 10014
3233
_EXPAND_STATES = {0: "collapsed", 1: "expanded", 2: "partial", 3: "leaf"}
3334

3435

@@ -263,6 +264,50 @@ def get_range(self, name=None, role=None, app_name=None,
263264
except (OSError, AttributeError, ValueError, TypeError):
264265
return None
265266

267+
def _text_pattern(self, name, role, app_name, automation_id):
268+
"""Find a control and return its IUIAutomationTextPattern, or None."""
269+
raw = self._find_raw(name, role, app_name, automation_id)
270+
if not raw:
271+
return None
272+
return self._pattern(raw, _UIA_TEXT_PATTERN_ID,
273+
"IUIAutomationTextPattern")
274+
275+
def document_text(self, name=None, role=None, app_name=None,
276+
automation_id=None) -> Optional[str]:
277+
pattern = self._text_pattern(name, role, app_name, automation_id)
278+
if pattern is None:
279+
return None
280+
try:
281+
return str(pattern.DocumentRange.GetText(-1) or "")
282+
except (OSError, AttributeError):
283+
return None
284+
285+
def selected_text(self, name=None, role=None, app_name=None,
286+
automation_id=None) -> Optional[str]:
287+
pattern = self._text_pattern(name, role, app_name, automation_id)
288+
if pattern is None:
289+
return None
290+
try:
291+
selection = pattern.GetSelection()
292+
if not selection or int(selection.Length or 0) == 0:
293+
return ""
294+
return str(selection.GetElement(0).GetText(-1) or "")
295+
except (OSError, AttributeError):
296+
return None
297+
298+
def visible_text(self, name=None, role=None, app_name=None,
299+
automation_id=None) -> Optional[str]:
300+
pattern = self._text_pattern(name, role, app_name, automation_id)
301+
if pattern is None:
302+
return None
303+
try:
304+
ranges = pattern.GetVisibleRanges()
305+
count = int(ranges.Length or 0)
306+
return "".join(str(ranges.GetElement(i).GetText(-1) or "")
307+
for i in range(count))
308+
except (OSError, AttributeError):
309+
return None
310+
266311
@staticmethod
267312
def _read_row(pattern, row: int, cols: int):
268313
"""Read one grid row into a list of cell strings."""
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"""Native text reading via the UI Automation TextPattern (document/selection/visible)."""
2+
from je_auto_control.utils.ax_text.ax_text import (
3+
get_control_text, get_selected_text, get_visible_text,
4+
)
5+
6+
__all__ = [
7+
"get_control_text", "get_selected_text", "get_visible_text",
8+
]
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Native text reading via the UI Automation TextPattern.
2+
3+
``control_get_value`` reads a control through ValuePattern, but ValuePattern
4+
returns an **empty string** on multiline edits, RichEdit / document controls and
5+
web text areas — exactly the controls whose text you most want to read. UIA
6+
exposes that text through a different pattern, ``TextPattern``, which models the
7+
control's content as text ranges. ``ax_text`` adds three reads on top of the
8+
existing accessibility backend ABC:
9+
10+
* :func:`get_control_text` — the whole document's text (``DocumentRange``),
11+
* :func:`get_selected_text` — the currently selected text (``GetSelection``),
12+
* :func:`get_visible_text` — only the on-screen text (``GetVisibleRanges``).
13+
14+
Each function is a thin dispatch onto the injectable
15+
``accessibility.backends.get_backend()`` seam (the same seam the rest of the
16+
accessibility module uses), so the headless core is unit-testable on any
17+
platform by injecting a fake backend; the real UIA calls live in the Windows
18+
backend. Imports no ``PySide6``.
19+
"""
20+
from typing import Optional
21+
22+
23+
def _backend():
24+
from je_auto_control.utils.accessibility.backends import get_backend
25+
return get_backend()
26+
27+
28+
def get_control_text(name: Optional[str] = None, role: Optional[str] = None,
29+
app_name: Optional[str] = None,
30+
automation_id: Optional[str] = None) -> Optional[str]:
31+
"""Return a control's full text via TextPattern (``None`` if not found).
32+
33+
Unlike :func:`control_get_value`, this works on multiline edits, RichEdit /
34+
document controls and web text areas where ValuePattern returns ``""``.
35+
"""
36+
return _backend().document_text(name=name, role=role, app_name=app_name,
37+
automation_id=automation_id)
38+
39+
40+
def get_selected_text(name: Optional[str] = None, role: Optional[str] = None,
41+
app_name: Optional[str] = None,
42+
automation_id: Optional[str] = None) -> Optional[str]:
43+
"""Return the control's currently selected text (TextPattern.GetSelection).
44+
45+
Empty string when nothing is selected; ``None`` if the control is not found
46+
or exposes no TextPattern.
47+
"""
48+
return _backend().selected_text(name=name, role=role, app_name=app_name,
49+
automation_id=automation_id)
50+
51+
52+
def get_visible_text(name: Optional[str] = None, role: Optional[str] = None,
53+
app_name: Optional[str] = None,
54+
automation_id: Optional[str] = None) -> Optional[str]:
55+
"""Return only the on-screen text of a control (TextPattern.GetVisibleRanges).
56+
57+
Useful for scrolled documents where :func:`get_control_text` would return the
58+
whole (possibly huge) buffer. ``None`` if the control is not found.
59+
"""
60+
return _backend().visible_text(name=name, role=role, app_name=app_name,
61+
automation_id=automation_id)

je_auto_control/utils/executor/action_executor.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2416,6 +2416,33 @@ def _scroll_control_into_view(name: Optional[str] = None, role: Optional[str] =
24162416
automation_id=automation_id)
24172417

24182418

2419+
def _get_control_text(name: Optional[str] = None, role: Optional[str] = None,
2420+
app_name: Optional[str] = None,
2421+
automation_id: Optional[str] = None) -> Dict[str, Any]:
2422+
"""Adapter: read a control's full text via TextPattern (multiline-safe)."""
2423+
from je_auto_control.utils.ax_text import get_control_text
2424+
return {"text": get_control_text(name=name, role=role, app_name=app_name,
2425+
automation_id=automation_id)}
2426+
2427+
2428+
def _get_selected_text(name: Optional[str] = None, role: Optional[str] = None,
2429+
app_name: Optional[str] = None,
2430+
automation_id: Optional[str] = None) -> Dict[str, Any]:
2431+
"""Adapter: read a control's currently selected text (TextPattern)."""
2432+
from je_auto_control.utils.ax_text import get_selected_text
2433+
return {"text": get_selected_text(name=name, role=role, app_name=app_name,
2434+
automation_id=automation_id)}
2435+
2436+
2437+
def _get_visible_text(name: Optional[str] = None, role: Optional[str] = None,
2438+
app_name: Optional[str] = None,
2439+
automation_id: Optional[str] = None) -> Dict[str, Any]:
2440+
"""Adapter: read only the on-screen text of a control (TextPattern)."""
2441+
from je_auto_control.utils.ax_text import get_visible_text
2442+
return {"text": get_visible_text(name=name, role=role, app_name=app_name,
2443+
automation_id=automation_id)}
2444+
2445+
24192446
def _read_table(name: Optional[str] = None, role: Optional[str] = None,
24202447
app_name: Optional[str] = None,
24212448
automation_id: Optional[str] = None) -> List[List[str]]:
@@ -6092,6 +6119,9 @@ def __init__(self):
60926119
"AC_control_range": _control_range,
60936120
"AC_set_control_range": _set_control_range,
60946121
"AC_scroll_control_into_view": _scroll_control_into_view,
6122+
"AC_get_control_text": _get_control_text,
6123+
"AC_get_selected_text": _get_selected_text,
6124+
"AC_get_visible_text": _get_visible_text,
60956125
"AC_read_table": _read_table,
60966126
"AC_watchdog_add": _watchdog_add,
60976127
"AC_watchdog_start": _watchdog_start,

0 commit comments

Comments
 (0)