From 64ee88037746b84a5d926e559fdc0d68ea0f6b1a Mon Sep 17 00:00:00 2001 From: Jeza Date: Wed, 22 Jul 2026 21:21:25 +0800 Subject: [PATCH 1/5] feat: Add TextCursor Tool --- src/windows_mcp/text_cursor/__init__.py | 1256 +++++++++++++++++++++++ src/windows_mcp/tools/__init__.py | 2 + src/windows_mcp/tools/text_cursor.py | 45 + tests/test_iserror_compliance.py | 66 ++ tests/test_text_cursor.py | 489 +++++++++ 5 files changed, 1858 insertions(+) create mode 100644 src/windows_mcp/text_cursor/__init__.py create mode 100644 src/windows_mcp/tools/text_cursor.py create mode 100644 tests/test_text_cursor.py diff --git a/src/windows_mcp/text_cursor/__init__.py b/src/windows_mcp/text_cursor/__init__.py new file mode 100644 index 00000000..b009c279 --- /dev/null +++ b/src/windows_mcp/text_cursor/__init__.py @@ -0,0 +1,1256 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Inspect and manipulate the caret/selection of the currently focused Windows +text control through UI Automation. + +The caret/selection is discovered via IUIAutomationTextPattern.GetSelection() +on the focused element, walking up to its ancestors when needed. +IUIAutomationTextPattern2.GetCaretRange() is intentionally not used; see +try_get_caret_on_element for the rationale. + +Supported modes: +- get_info +- move_relative +- move_absolute +- select_relative +- select_absolute +- select_all +- collapse_selection + +Important: +- IUIAutomationTextRange.Move() only moves a client-side range. +- Select() asks the provider to apply that range as the real caret/selection. +- Write operations verify the applied range by reading it back. +- Some providers expose TextPattern but do not support moving the real caret. +""" + +from __future__ import annotations + +import asyncio +import ctypes +import threading +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from typing import Annotated, Any, Literal, Optional, Union, NamedTuple +import enum + +import comtypes.client +from comtypes import COMError +from pydantic import BaseModel, ConfigDict, Field, model_validator + +# --------------------------------------------------------------------------- +# UI Automation constants +# --------------------------------------------------------------------------- + +UIA_TEXT_PATTERN_ID = 10014 +UIA_TEXT_PATTERN2_ID = 10024 + +CLSID_CUIAUTOMATION8 = "{E22AD333-B25F-460C-83D0-0581107395C9}" +CLSID_CUIAUTOMATION = "{FF48DBA4-60EF-4201-AA87-54103EEF594E}" + +MAX_TEXT_UNIT_MOVE = 2_147_483_647 + +# Upper bound on the selected-text string embedded in a snapshot. Unlike the +# surrounding context (capped by context_chars), the selection can be the whole +# document (e.g. after select_all), so cap it here to keep the MCP payload small. +MAX_SELECTED_TEXT_CHARS = 4096 + + +class TextCursorError(RuntimeError): + """Base error raised by TextCursor operations.""" + + +class TextCursorVerificationError(TextCursorError): + """Raised when a TextCursor write cannot be verified.""" + + +# --------------------------------------------------------------------------- +# Internal UIA result +# --------------------------------------------------------------------------- + + +@dataclass +class UIACaretInfo: + element: UIAElement + text_pattern: TextPattern + text_range: TextRange + source: str + exact_caret: bool + selection_count: int = 1 + + +# --------------------------------------------------------------------------- +# UI Automation low-level helpers +# --------------------------------------------------------------------------- + + +class UIAModule: + def __init__(self, raw_module): + self.raw_module = raw_module + + +class UIAAutomationObject: + def __init__(self, raw_obj): + self.raw_obj = raw_obj + + def get_focused_element(self) -> UIAElement | None: + raw_elem = self.raw_obj.GetFocusedElement() + if not raw_elem: + return None + return UIAElement(raw_elem, self.raw_obj) + + +def create_automation() -> tuple[UIAModule, UIAAutomationObject]: + """ + Load UIAutomationCore.dll type information and create the UIA client. + CUIAutomation8 is preferred; CUIAutomation is used as a compatibility fallback. + """ + raw_uia = comtypes.client.GetModule("UIAutomationCore.dll") + + try: + automation = comtypes.client.CreateObject( + CLSID_CUIAUTOMATION8, + interface=raw_uia.IUIAutomation, + ) + except COMError: + automation = comtypes.client.CreateObject( + CLSID_CUIAUTOMATION, + interface=raw_uia.IUIAutomation, + ) + + return UIAModule(raw_uia), UIAAutomationObject(automation) + + +class TextRangeEndpoint(enum.Enum): + """Mirrors the UIA TextPatternRangeEndpoint enumeration.""" + + Start = 0 # TextPatternRangeEndpoint_Start + End = 1 # TextPatternRangeEndpoint_End + + +class TextUnit(enum.Enum): + """Mirrors the UIA TextUnit enumeration.""" + + Character = 0 # TextUnit_Character + Format = 1 # TextUnit_Format + Word = 2 # TextUnit_Word + Line = 3 # TextUnit_Line + Paragraph = 4 # TextUnit_Paragraph + Page = 5 # TextUnit_Page + Document = 6 # TextUnit_Document + + +class UIAElement: + """A simple wrapper for UIA Element""" + + def __init__(self, raw_element: Any, raw_uia_object: Any): + self.raw_element = raw_element + self.raw_uia_obj = raw_uia_object + + def get_pattern(self, pattern_id: int, interface: Any) -> TextPattern | None: + """Return the requested control pattern wrapped as a TextPattern, or None.""" + try: + unknown = self.raw_element.GetCurrentPattern(pattern_id) + if not unknown: + return None + + raw_pattern = unknown.QueryInterface(interface) + if not raw_pattern: + return None + return TextPattern(raw_pattern) + + except (COMError, AttributeError, TypeError): + return None + + def get_name(self) -> str: + try: + return str(self.raw_element.CurrentName or "") + except (COMError, AttributeError): + return "" + + def parent(self) -> UIAElement | None: + walker = self.raw_uia_obj.RawViewWalker + try: + ret = walker.GetParentElement(self.raw_element) + except COMError: + ret = None + + if not ret: + return None + + return UIAElement(ret, self.raw_uia_obj) + + def set_focus(self): + self.raw_element.SetFocus() + + +class TextPattern: + """A simple wrapper for UIA TextPattern""" + + def __init__(self, raw_pattern): + self.raw_pattern = raw_pattern + + def get_selections(self) -> list[TextRange]: + try: + selections = self.raw_pattern.GetSelection() + + if not selections: + return [] + + if int(selections.Length) <= 0: + return [] + + return [ + TextRange(selections.GetElement(index)) for index in range(int(selections.Length)) + ] + + except (COMError, AttributeError, TypeError, ValueError): + return [] + + def get_first_selection(self) -> TextRange | None: + selections = self.get_selections() + if not selections: + return None + + return selections[0] + + def document_range(self) -> TextRange: + return TextRange(self.raw_pattern.DocumentRange) + + +class TextRange: + """A simple wrapper for UIA TextRange""" + + def __init__(self, raw_range): + self.raw_range = raw_range + + def clone(self) -> TextRange: + return TextRange(self.raw_range.Clone()) + + def move(self, unit: TextUnit, count: int) -> int: + """Moves the text range the specified number of TextUnit units within the document range. + Return the number of units actually moved + """ + return int(self.raw_range.Move(unit.value, count)) + + def select(self): + return self.raw_range.Select() + + def move_endpoint_by_range( + self, + src_endpoint: TextRangeEndpoint, + other: TextRange, + target_endpoint: TextRangeEndpoint, + ): + """Moves one endpoint of the current text range to the specified endpoint of a second text range.""" + self.raw_range.MoveEndpointByRange( + src_endpoint.value, + other.raw_range, + target_endpoint.value, + ) + + def move_endpoint_by_unit(self, endpoint: TextRangeEndpoint, unit: TextUnit, count: int) -> int: + """Moves one endpoint of the text range the specified number of TextUnit units within the document range. + Return the number of units actually moved + """ + return int( + self.raw_range.MoveEndpointByUnit( + endpoint.value, + unit.value, + count, + ) + ) + + def compare_endpoints( + self, + src_endpoint: TextRangeEndpoint, + other: TextRange, + target_endpoint: TextRangeEndpoint, + ) -> int: + return int( + self.raw_range.CompareEndpoints( + src_endpoint.value, + other.raw_range, + target_endpoint.value, + ) + ) + + def is_degenerate(self) -> bool: + comparison = self.compare_endpoints( + TextRangeEndpoint.Start, + self, + TextRangeEndpoint.End, + ) + + return int(comparison) == 0 + + def collapse_range(self, *, to_end: bool) -> None: + """Collapse the range to a single point, clearing any selection. + + to_end=False collapses to the start (left) endpoint; to_end=True + collapses to the end (right) endpoint. + """ + if to_end: + # Start --move-> End. + self.move_endpoint_by_range( + TextRangeEndpoint.Start, + self, + TextRangeEndpoint.End, + ) + else: + # End --move-> Start. + self.move_endpoint_by_range( + TextRangeEndpoint.End, + self, + TextRangeEndpoint.Start, + ) + + def get_text(self, max_length: int = -1) -> str: + return str(self.raw_range.GetText(max_length) or "") + + def text_before(self, count: int) -> str: + """Return up to `count` characters immediately before the range.""" + clone = self.clone() + + # Collapse to the start of the range. + clone.collapse_range(to_end=False) + + # Extend the start endpoint backward. + clone.move_endpoint_by_unit(TextRangeEndpoint.Start, TextUnit.Character, -count) + + return clone.get_text() + + def text_after(self, count: int) -> str: + """Return up to `count` characters immediately after the range.""" + clone = self.clone() + + # Collapse to the end of the range. + clone.collapse_range(to_end=True) + + # Extend the end endpoint forward. + clone.move_endpoint_by_unit(TextRangeEndpoint.End, TextUnit.Character, count) + + return clone.get_text() + + def bounding_rectangles(self, try_again_if_err: bool = True) -> list[ScreenRect]: + try: + # An array of bounding rectangles for each fully or partially + # visible line of text in the range. A selection can span multiple + # lines, so each line is returned as its own rectangle. The result + # is a flat tuple of (left, top, width, height) per rectangle: + # (x0, y0, w0, h0, x1, y1, w1, h1, ...). + values = self.raw_range.GetBoundingRectangles() # type: tuple[float, ...] + if values is None: + return [] + + flat = list(values) + + rects = [ + ScreenRect( + left=float(flat[index]), + top=float(flat[index + 1]), + width=float(flat[index + 2]), + height=float(flat[index + 3]), + ) + for index in range(0, len(flat) - 3, 4) + ] + if len(rects) == 0 and self.is_degenerate() and try_again_if_err: + # A caret (degenerate range) sometimes has no bounding + # rectangle; extend it by one character and try once more. + adjacent = self.clone() + moved = adjacent.move_endpoint_by_unit(TextRangeEndpoint.End, TextUnit.Character, 1) + + if int(moved) != 0: + return adjacent.bounding_rectangles(False) # avoid recursion + return rects + + except ( + COMError, + AttributeError, + TypeError, + ValueError, + ): + return [] + + # A TextRange has no stable value-based hash (its endpoints can move) and + # equality requires live COM calls, so keep instances explicitly + # unhashable. Python already does this once __eq__ is defined; stating it + # makes the intent obvious and guards against accidental set/dict use. + __hash__ = None + + def __eq__(self, other: object) -> bool: + if not isinstance(other, TextRange): + return NotImplemented + + try: + start_comparison = self.compare_endpoints( + TextRangeEndpoint.Start, + other, + TextRangeEndpoint.Start, + ) + + end_comparison = self.compare_endpoints( + TextRangeEndpoint.End, + other, + TextRangeEndpoint.End, + ) + except COMError: + # A stale range can no longer be compared; treat it as not equal + # rather than propagating the COM failure out of an equality check. + return False + + return int(start_comparison) == 0 and int(end_comparison) == 0 + + +def try_get_caret_on_element(uia: UIAModule, element: UIAElement) -> Optional[UIACaretInfo]: + # Use TextPattern.GetSelection rather than TextPattern2.GetCaretRange: + # GetCaretRange does not expose the actual selection range, so handling it + # separately is not worth the effort. The first selection is kept as-is: + # a degenerate range is treated as a caret (exact_caret=True), and a + # non-empty one as a range whose active caret endpoint is unknown. + text_pattern = element.get_pattern( + UIA_TEXT_PATTERN_ID, + uia.raw_module.IUIAutomationTextPattern, + ) + + if text_pattern is not None: + selections = text_pattern.get_selections() + + if selections: + selection = selections[0] + return UIACaretInfo( + element=element, + text_pattern=text_pattern, + text_range=selection, + source="TextPattern.GetSelection", + exact_caret=selection.is_degenerate(), + selection_count=len(selections), + ) + + return None + + +def find_caret_provider(max_parent_levels: int = 8) -> UIACaretInfo: + """ + Start at the focused UIA element and walk up RawView parents. + Some controls expose TextPattern on an ancestor rather than on the exact + focused child. + """ + uia, automation = get_thread_automation() + + element = automation.get_focused_element() + + if not element: + raise RuntimeError("UI Automation returned no focused element.") + + element_name = element.get_name() + + tried_cnt = 0 + while element and tried_cnt < max_parent_levels + 1: + tried_cnt += 1 + result = try_get_caret_on_element(uia, element) + + if result is not None: + return result + + try: + element = element.parent() + except COMError: + element = None + + raise RuntimeError( + f"The focused element {f'"{element_name}" ' if element_name else ''}and " + f"its inspected parents do not expose TextPattern." + ) + + +def range_start_offset( + text_range: TextRange, +) -> Optional[int]: + """Return the range start in UIA TextUnit_Character steps. + + The offset is measured from DocumentRange start and uses the same + provider-defined coordinate system as absolute move/select actions. + + Cost note: UIA exposes no direct "character index" query, so the offset is + derived by moving a degenerate range back to DocumentRange start. Providers + that implement Move as a linear walk make this O(offset), i.e. proportional + to the distance from the document start. This is negligible for typical text + controls but can be noticeable in very large documents, so avoid high- + frequency polling there. + """ + try: + clone = text_range.clone() + clone.collapse_range(to_end=False) + # Walk back to the very start of the document. + moved = clone.move(TextUnit.Character, -MAX_TEXT_UNIT_MOVE) + # Moving backward returns a negative count, so negate it to get the + # positive offset from DocumentRange start. + return -moved + + except (COMError, AttributeError, TypeError): + return None + + +# region +# --------------------------------------------------------------------------- +# MCP input models +# --------------------------------------------------------------------------- + +RelativeOrigin = Literal[ + "caret", + "selection_start", + "selection_end", +] + +CollapseEdge = Literal["start", "end"] + + +class ActionBase(BaseModel): + model_config = ConfigDict(extra="forbid") + + delay: float = Field( + default=0.0, + ge=0.0, + le=300.0, + description=( + "Seconds to wait before locating the focused UIA element and " + "executing this action. Use this to leave time for the user or " + "another automation step to focus the target text control." + ), + ) + + context_chars: int = Field( + default=40, + ge=0, + le=4096, + description=("Number of UIA character units to read around the caret or selection."), + ) + + verify: bool = Field( + default=True, + description=( + "Read the selection back after a write operation and verify that " + "the provider actually applied it." + ), + ) + + +class GetInfoAction(ActionBase): + mode: Literal["get_info"] + + +class MoveRelativeAction(ActionBase): + mode: Literal["move_relative"] + + delta: int = Field( + ge=-MAX_TEXT_UNIT_MOVE, + le=MAX_TEXT_UNIT_MOVE, + description=( + "Signed UIA TextUnit_Character movement. Positive moves forward; " + "negative moves backward." + ), + ) + + origin: RelativeOrigin = Field( + default="caret", + description=( + "Movement origin. For a non-empty TextPattern fallback selection, " + "use selection_start or selection_end." + ), + ) + + +class MoveAbsoluteAction(ActionBase): + mode: Literal["move_absolute"] + + offset: int = Field( + ge=0, + le=MAX_TEXT_UNIT_MOVE, + description="Target position in UIA TextUnit_Character steps from DocumentRange start.", + ) + + +class SelectRelativeAction(ActionBase): + mode: Literal["select_relative"] + + origin: RelativeOrigin = "caret" + + start_delta: int = Field( + ge=-MAX_TEXT_UNIT_MOVE, + le=MAX_TEXT_UNIT_MOVE, + description="Signed delta from origin to the inclusive selection start.", + ) + end_delta: int = Field( + ge=-MAX_TEXT_UNIT_MOVE, + le=MAX_TEXT_UNIT_MOVE, + description="Signed delta from origin to the exclusive selection end.", + ) + + @model_validator(mode="after") + def validate_deltas(self) -> "SelectRelativeAction": + if self.start_delta > self.end_delta: + raise ValueError("start_delta must be less than or equal to end_delta") + + return self + + +class SelectAbsoluteAction(ActionBase): + mode: Literal["select_absolute"] + + start: int = Field( + ge=0, + le=MAX_TEXT_UNIT_MOVE, + description=( + "Inclusive selection start in UIA TextUnit_Character steps from DocumentRange start." + ), + ) + + end: int = Field( + ge=0, + le=MAX_TEXT_UNIT_MOVE, + description=( + "Exclusive selection end in UIA TextUnit_Character steps from DocumentRange start." + ), + ) + + @model_validator(mode="after") + def validate_offsets(self) -> "SelectAbsoluteAction": + if self.start > self.end: + raise ValueError("start must be less than or equal to end") + + return self + + +class SelectAllAction(ActionBase): + mode: Literal["select_all"] + + +class CollapseSelectionAction(ActionBase): + mode: Literal["collapse_selection"] + edge: CollapseEdge + + +CursorAction = Annotated[ + Union[ + GetInfoAction, + MoveRelativeAction, + MoveAbsoluteAction, + SelectRelativeAction, + SelectAbsoluteAction, + SelectAllAction, + CollapseSelectionAction, + ], + Field(discriminator="mode"), +] + + +# endregion + +# region +# --------------------------------------------------------------------------- +# MCP output models +# --------------------------------------------------------------------------- + + +class ScreenRect(BaseModel): + left: float + top: float + width: float + height: float + + +def _ignore_none(value: object) -> bool: + return value is None + + +class CursorSnapshot(BaseModel): + provider: str + element_name: str | None = None + + type: Literal["caret", "range"] + + caret_offset_units: int | None = Field( + default=None, + exclude_if=_ignore_none, + description=( + "Caret offset in provider-defined UIA TextUnit_Character steps " + "from DocumentRange start." + ), + ) + selection_start_units: int | None = Field( + default=None, + exclude_if=_ignore_none, + description=( + "Selection start in provider-defined UIA TextUnit_Character steps " + "from DocumentRange start." + ), + ) + selection_end_units: int | None = Field( + default=None, + exclude_if=_ignore_none, + description=( + "Selection end in provider-defined UIA TextUnit_Character steps " + "from DocumentRange start." + ), + ) + + selected_text: str | None = Field(default=None, exclude_if=_ignore_none) + text_before: str | None = Field(default=None, exclude_if=_ignore_none) + text_after: str | None = Field(default=None, exclude_if=_ignore_none) + + bounding_rects: list[ScreenRect] = Field(default_factory=list) + + warnings: list[str] = Field(default_factory=list) + + +class CursorToolResult(BaseModel): + success: bool + mode: str + message: str + + verified: bool | None = Field(default=None, exclude_if=_ignore_none) + + requested: dict[str, Any] = Field(default_factory=dict) + + target: dict[str, Any] = Field( + default_factory=dict, + description=( + "Target calculated on the client-side UIA range after applying document-boundary " + "clamping. This does not prove that the provider applied the target." + ), + ) + + actual: dict[str, Any] = Field( + default_factory=dict, + description="Real caret or selection position read back from the provider after the write.", + ) + + before: CursorSnapshot | None = None + after: CursorSnapshot | None = None + + warnings: list[str] = Field(default_factory=list) + + +# endregion + +# --------------------------------------------------------------------------- +# COM worker +# --------------------------------------------------------------------------- + +COINIT_MULTITHREADED = 0x0 +RPC_E_CHANGED_MODE = 0x80010106 + +_EXECUTOR = ThreadPoolExecutor( + max_workers=1, + thread_name_prefix="uia-text-cursor", +) + +# Per-worker-thread COM state. The executor thread lives for the process +# lifetime, so COM is initialized once and the IUIAutomation client is cached +# and reused across calls instead of being recreated (and the apartment +# re-initialized) on every invocation. +_thread_state = threading.local() + + +def co_initialize_mta() -> bool: + ole32 = ctypes.windll.ole32 + + ole32.CoInitializeEx.argtypes = [ + ctypes.c_void_p, + ctypes.c_ulong, + ] + ole32.CoInitializeEx.restype = ctypes.c_long + + hr = int( + ole32.CoInitializeEx( + None, + COINIT_MULTITHREADED, + ) + ) + + unsigned_hr = hr & 0xFFFFFFFF + + if unsigned_hr == RPC_E_CHANGED_MODE: + raise RuntimeError("The UIA worker thread has an incompatible COM apartment.") + + if unsigned_hr & 0x80000000: + raise OSError(f"CoInitializeEx failed: 0x{unsigned_hr:08X}") + + return True + + +def co_uninitialize() -> None: + ctypes.windll.ole32.CoUninitialize() + + +def get_thread_automation() -> tuple[UIAModule, UIAAutomationObject]: + """Return this worker thread's cached UIA client, creating it on first use. + + COM is initialized (MTA) exactly once per thread and the IUIAutomation + client is cached, so repeated calls reuse the same client rather than + paying for CoInitializeEx + CreateObject on every invocation. The client + is long-lived and never goes stale; only the focused element and its text + ranges are re-fetched per call. + """ + if not getattr(_thread_state, "com_initialized", False): + co_initialize_mta() + _thread_state.com_initialized = True + + automation = getattr(_thread_state, "automation", None) + if automation is None: + automation = create_automation() + _thread_state.automation = automation + + return automation + + +# --------------------------------------------------------------------------- +# Snapshot helpers +# --------------------------------------------------------------------------- + + +def endpoint_offset( + caret_info: UIACaretInfo, + endpoint: TextRangeEndpoint, +) -> Optional[int]: + """Return the character offset from the document start to the given + endpoint (Start or End) of the caret/selection range.""" + marker = caret_info.text_range.clone() + + if endpoint == TextRangeEndpoint.End: + marker.collapse_range(to_end=True) + else: + marker.collapse_range(to_end=False) + + return range_start_offset(marker) + + +def make_snapshot( + caret_info: UIACaretInfo, + context_chars: int, + *, + include_context: bool = True, + include_selected_text: bool = True, +) -> CursorSnapshot: + start = endpoint_offset(caret_info, TextRangeEndpoint.Start) + # A caret is a degenerate range: both endpoints resolve to the same offset, + # and only caret_offset_units (== start) is emitted. Computing the End + # endpoint would be a second full Move(-MAX) walk back to DocumentRange + # start for nothing, so reuse start. Only a real selection needs End. + # (The start-is-None short-circuit keeps the unavailable-offset error below + # from being masked by inspecting the range first.) + if start is not None and caret_info.exact_caret: + end = start + else: + end = endpoint_offset(caret_info, TextRangeEndpoint.End) + + if start is None or end is None: + raise TextCursorError( + "The provider did not allow calculating UIA TextUnit_Character offsets." + ) + + warnings: list[str] = [] + + selected_text = None + selected_text_truncated = False + if include_selected_text and not caret_info.exact_caret: + # Read one extra character so a selection sitting exactly on the limit + # is not mistaken for a truncated one. + try: + selected_text = caret_info.text_range.get_text(MAX_SELECTED_TEXT_CHARS + 1) + except COMError: + warnings.append( + "The provider did not allow reading selected_text. The field was omitted; " + "selection_start_units and selection_end_units are still available." + ) + else: + if len(selected_text) > MAX_SELECTED_TEXT_CHARS: + selected_text = selected_text[:MAX_SELECTED_TEXT_CHARS] + "…" + selected_text_truncated = True + + # --- Read the text on both sides of the caret/selection. --- + before = None + after = None + + if include_context: + try: + before = caret_info.text_range.text_before(context_chars) + except COMError: + warnings.append("The provider did not allow reading text_before. The field was omitted.") + + try: + after = caret_info.text_range.text_after(context_chars) + except COMError: + warnings.append("The provider did not allow reading text_after. The field was omitted.") + + if caret_info.selection_count > 1: + warnings.append( + f"TextPattern.GetSelection returned {caret_info.selection_count} " + "disjoint selections. TextCursor reports and uses only the first " + "selection; the remaining selections are ignored." + ) + + if not caret_info.exact_caret: + warnings.append( + "TextPattern.GetSelection returned a non-empty selection. " + "The active caret endpoint is unknown." + ) + + if selected_text_truncated: + warnings.append( + f"selected_text was truncated to {MAX_SELECTED_TEXT_CHARS} characters " + "(marked with a trailing ellipsis). The selection_start_units and " + "selection_end_units offsets still describe the full selection." + ) + + return CursorSnapshot( + provider=caret_info.source, + element_name=(caret_info.element.get_name() or None), + type="caret" if caret_info.exact_caret else "range", + caret_offset_units=start if caret_info.exact_caret else None, # caret only + selection_start_units=(start if not caret_info.exact_caret else None), # range only + selection_end_units=end if not caret_info.exact_caret else None, # range only + selected_text=selected_text, + text_before=before, + text_after=after, + bounding_rects=caret_info.text_range.bounding_rectangles(), + warnings=warnings, + ) + + +def snapshot_position(snapshot: CursorSnapshot) -> dict[str, Any]: + """Return only the real caret or selection coordinates from a snapshot.""" + if snapshot.type == "caret": + return { + "type": "caret", + "caret_offset_units": snapshot.caret_offset_units, + } + + return { + "type": "range", + "selection_start_units": snapshot.selection_start_units, + "selection_end_units": snapshot.selection_end_units, + } + + +# --------------------------------------------------------------------------- +# Range construction +# --------------------------------------------------------------------------- + + +def get_origin_from_range( + caret_info: UIACaretInfo, + origin: RelativeOrigin, +) -> TextRange: + """Return the base position a move/select operation is measured from. + + A range has two endpoints, so `origin` selects which one to start from: + 'caret' (only valid for a degenerate range), 'selection_start', or + 'selection_end'. + """ + base = caret_info.text_range.clone() + + if origin == "caret": + if not base.is_degenerate(): + raise RuntimeError( + "The current range is a non-empty selection. " + "The TextPattern fallback does not reveal which endpoint " + "is the active caret. Use selection_start or selection_end." + ) + + return base + + base.collapse_range(to_end=(origin == "selection_end")) + return base + + +def document_position( + caret_info: UIACaretInfo, + offset: int, +) -> tuple[TextRange, int]: + """Build a degenerate range at `offset` characters from the document start.""" + # Get the range spanning the whole document. + target = caret_info.text_pattern.document_range() + # Collapse to its start endpoint. + target.collapse_range(to_end=False) + actual_moved = int(target.move(TextUnit.Character, offset)) + return target, actual_moved + + +def make_range( + start_marker: TextRange, + end_marker: TextRange, +) -> TextRange: + # s----------e + # ^ + target = start_marker.clone() + target.collapse_range(to_end=False) + + # s----------e + # ^----------^ + target.move_endpoint_by_range( + TextRangeEndpoint.End, + end_marker, + TextRangeEndpoint.Start, + ) + + # Check whether target's start endpoint has passed its end endpoint (> 0). + comparison = target.compare_endpoints( + TextRangeEndpoint.Start, + target, + TextRangeEndpoint.End, + ) + + if int(comparison) > 0: + raise RuntimeError("The calculated selection start is after its end.") + return target + + +# --------------------------------------------------------------------------- +# Applying and verifying write operations +# --------------------------------------------------------------------------- + + +def apply_change(caret_info: UIACaretInfo, target: TextRange): + caret_info.element.set_focus() + # Move() only modifies the local range. + # Select() requests the actual caret/selection change. + target.select() + + +def verify( + caret_info: UIACaretInfo, + target: TextRange, + need_verify: bool, +) -> Optional[bool]: + """Verify the applied range matches `target` by reading the selection back. + + Returns None when verification was not requested. + """ + if not need_verify: + return None + + actual = caret_info.text_pattern.get_first_selection() + if actual is None: + return False + + return target == actual + + +# --------------------------------------------------------------------------- +# Tool modes +# --------------------------------------------------------------------------- + + +def run_get_info(action: GetInfoAction) -> CursorToolResult: + caret_info = find_caret_provider() + snapshot = make_snapshot(caret_info, action.context_chars) + + return CursorToolResult( + success=True, + mode=action.mode, + message="Caret information acquired.", + after=snapshot, + warnings=snapshot.warnings, + ) + + +class WriteActionResult(NamedTuple): + verified: bool | None # None means there is no verification after action + target_info: dict[str, Any] + + +def apply_move_relative(action: MoveRelativeAction, caret_info: UIACaretInfo) -> WriteActionResult: + # For a selection, resolve which endpoint the move is relative to. + target = get_origin_from_range(caret_info, action.origin) + target_delta = int(target.move(TextUnit.Character, action.delta)) + apply_change(caret_info, target) + verified = verify(caret_info, target, action.verify) + return WriteActionResult(verified, {"target_delta": target_delta}) + + +def apply_move_absolute(action: MoveAbsoluteAction, caret_info: UIACaretInfo) -> WriteActionResult: + target, target_offset = document_position(caret_info, action.offset) + apply_change(caret_info, target) + verified = verify(caret_info, target, action.verify) + return WriteActionResult(verified, {"target_offset_units": target_offset}) + + +def apply_select_relative( + action: SelectRelativeAction, caret_info: UIACaretInfo +) -> WriteActionResult: + origin = get_origin_from_range(caret_info, action.origin) + + start_marker = origin.clone() + end_marker = origin.clone() + + target_start_delta = start_marker.move(TextUnit.Character, action.start_delta) + target_end_delta = end_marker.move(TextUnit.Character, action.end_delta) + + target = make_range(start_marker, end_marker) + + apply_change(caret_info, target) + verified = verify(caret_info, target, action.verify) + + return WriteActionResult( + verified, + { + "target_start_delta": target_start_delta, + "target_end_delta": target_end_delta, + }, + ) + + +def apply_select_absolute( + action: SelectAbsoluteAction, + caret_info: UIACaretInfo, +) -> WriteActionResult: + start_marker, target_start = document_position(caret_info, action.start) + end_marker, target_end = document_position(caret_info, action.end) + + target = make_range(start_marker, end_marker) + + apply_change(caret_info, target) + verified = verify(caret_info, target, action.verify) + + return WriteActionResult( + verified, + { + "target_start_units": target_start, + "target_end_units": target_end, + }, + ) + + +def apply_select_all( + action: SelectAllAction, + caret_info: UIACaretInfo, +) -> WriteActionResult: + target = caret_info.text_pattern.document_range() + apply_change(caret_info, target) + verified = verify(caret_info, target, action.verify) + return WriteActionResult(verified, {}) + + +def apply_collapse_selection( + action: CollapseSelectionAction, + caret_info: UIACaretInfo, +) -> WriteActionResult: + target = caret_info.text_range.clone() + target.collapse_range(to_end=(action.edge == "end")) + + apply_change(caret_info, target) + verified = verify(caret_info, target, action.verify) + return WriteActionResult(verified, {"edge": action.edge}) + + +WriteAction = Union[ + MoveRelativeAction, + MoveAbsoluteAction, + SelectRelativeAction, + SelectAbsoluteAction, + SelectAllAction, + CollapseSelectionAction, +] + + +def apply_write(action: WriteAction, caret_info: UIACaretInfo) -> WriteActionResult: + match action: + case MoveRelativeAction(): + return apply_move_relative(action, caret_info) + case MoveAbsoluteAction(): + return apply_move_absolute(action, caret_info) + case SelectRelativeAction(): + return apply_select_relative(action, caret_info) + case SelectAbsoluteAction(): + return apply_select_absolute(action, caret_info) + case SelectAllAction(): + return apply_select_all(action, caret_info) + case CollapseSelectionAction(): + return apply_collapse_selection(action, caret_info) + case _: + raise TypeError(f"Unsupported action type: {type(action)!r}") + + +def run_write(action: WriteAction) -> CursorToolResult: + caret_info = find_caret_provider() + + before = make_snapshot(caret_info, action.context_chars) + verified, target = apply_write(action, caret_info) + requested = action.model_dump( + exclude={ + "delay", + "context_chars", + "verify", + } + ) + + # Always reacquire the focused provider before reporting success or a + # verification mismatch. The values returned by TextRange.Move describe + # only the client-side target; `actual` must come from the real provider. + refreshed = find_caret_provider() + after = make_snapshot(refreshed, action.context_chars) + actual = snapshot_position(after) + + if verified is False: + raise TextCursorVerificationError( + "The provider accepted the operation, but read-back verification " + "showed that the real caret/selection did not match the calculated " + f"target. Requested: {requested}; target: {target}; actual: {actual}." + ) + + warnings = list(dict.fromkeys([*before.warnings, *after.warnings])) + + return CursorToolResult( + success=True, + mode=action.mode, + message="Operation applied.", + verified=verified, + requested=requested, + target=target, + actual=actual, + before=before, + after=after, + warnings=warnings, + ) + + +def execute_sync(action: CursorAction) -> CursorToolResult: + # COM is initialized once per worker thread and the UIA client is cached + # on first use (see get_thread_automation); there is no longer a per-call + # CoInitialize/CoUninitialize or CreateObject. + if isinstance(action, GetInfoAction): + return run_get_info(action) + + return run_write(action) + + +async def run_tool(action: CursorAction) -> CursorToolResult: + """ + Inspect or manipulate the focused Windows text control through UIA. + Modes: + - get_info + - move_relative + - move_absolute + - select_relative + - select_absolute + - select_all + - collapse_selection + Every mode accepts `delay`, expressed in seconds. The delay occurs before + the focused UIA element is located, so the caller can focus the target + control during that interval. + Absolute move/select inputs and returned offsets both use provider-defined + UIA TextUnit_Character steps from DocumentRange start. Returned offsets can + be passed directly to absolute move/select actions. + """ + if action.delay > 0: + await asyncio.sleep(action.delay) + + loop = asyncio.get_running_loop() + return await loop.run_in_executor( + _EXECUTOR, + execute_sync, + action, + ) diff --git a/src/windows_mcp/tools/__init__.py b/src/windows_mcp/tools/__init__.py index b4b5c95c..12c43b41 100644 --- a/src/windows_mcp/tools/__init__.py +++ b/src/windows_mcp/tools/__init__.py @@ -13,6 +13,7 @@ scrape, shell, snapshot, + text_cursor, ) _MODULES = [ @@ -28,6 +29,7 @@ process, notification, registry, + text_cursor, ] diff --git a/src/windows_mcp/tools/text_cursor.py b/src/windows_mcp/tools/text_cursor.py new file mode 100644 index 00000000..3c939c37 --- /dev/null +++ b/src/windows_mcp/tools/text_cursor.py @@ -0,0 +1,45 @@ +""" +TextCursor tool — inspecting and manipulating the caret/selection +of the currently focused Windows text control through UI Automation +""" + +from mcp.types import ToolAnnotations +from windows_mcp.infrastructure import with_analytics +from windows_mcp.text_cursor import CursorAction, CursorToolResult, run_tool + +_description = """ +Inspect or manipulate the focused Windows text control through UIA. +Modes: +- get_info +- move_relative +- move_absolute +- select_relative +- select_absolute +- select_all +- collapse_selection +Every mode accepts `delay`, expressed in seconds. The delay occurs before +the focused UIA element is located, so the caller can focus the target +control during that interval. +Absolute move/select inputs and returned offsets both use provider-defined +UIA TextUnit_Character steps from DocumentRange start. Returned offsets can +be passed directly to absolute move/select actions. +""" + + +def register(mcp, *, get_desktop, get_analytics): + @mcp.tool( + name="TextCursor", + description=_description, + annotations=ToolAnnotations( + title="TextCursor", + readOnlyHint=False, + destructiveHint=True, + idempotentHint=False, + openWorldHint=True, + ), + ) + @with_analytics(get_analytics(), "TextCursor-Tool") + async def text_cursor( + action: CursorAction, + ) -> CursorToolResult: + return await run_tool(action) diff --git a/tests/test_iserror_compliance.py b/tests/test_iserror_compliance.py index 8d82418d..52cbb89d 100644 --- a/tests/test_iserror_compliance.py +++ b/tests/test_iserror_compliance.py @@ -85,3 +85,69 @@ def _raise(*args, **kwargs): # noqa: ARG001 with pytest.raises(ToolError) as exc_info: asyncio.run(mcp.call_tool("Registry", {"mode": "get", "path": "HKLM\\X", "name": "Nope"})) assert error_msg in str(exc_info.value) + + +def test_text_cursor_tool_error_is_error_true(monkeypatch, mcp): + """TextCursor UIA failures must surface as ToolError.""" + import windows_mcp.text_cursor as implementation + from windows_mcp.tools.text_cursor import register as text_cursor_tool_reg + + text_cursor_tool_reg(mcp, get_desktop=lambda: None, get_analytics=lambda: None) + error_msg = "synthetic UIA failure" + + def _raise(*args, **kwargs): # noqa: ARG001 + raise RuntimeError(error_msg) + + monkeypatch.setattr(implementation, "co_initialize_mta", lambda: True) + monkeypatch.setattr(implementation, "co_uninitialize", lambda: None) + monkeypatch.setattr(implementation, "run_get_info", _raise) + + with pytest.raises(ToolError) as exc_info: + asyncio.run( + mcp.call_tool( + "TextCursor", + {"action": {"mode": "get_info"}}, + ) + ) + assert error_msg in str(exc_info.value) + + +def test_text_cursor_verification_failure_raises(monkeypatch): + """A failed write verification must not return a successful MCP payload.""" + import windows_mcp.text_cursor as implementation + + action = implementation.MoveAbsoluteAction(mode="move_absolute", offset=10) + caret_info = object() + before = implementation.CursorSnapshot( + provider="fake", + type="caret", + caret_offset_units=1, + ) + after = implementation.CursorSnapshot( + provider="fake", + type="caret", + caret_offset_units=3, + ) + snapshots = iter([before, after]) + + monkeypatch.setattr(implementation, "find_caret_provider", lambda: caret_info) + monkeypatch.setattr( + implementation, + "make_snapshot", + lambda *args, **kwargs: next(snapshots), + ) + monkeypatch.setattr( + implementation, + "apply_write", + lambda *args, **kwargs: implementation.WriteActionResult( + False, + {"target_offset_units": 5}, + ), + ) + + with pytest.raises(implementation.TextCursorVerificationError) as exc_info: + implementation.run_write(action) + + assert "move_absolute" in str(exc_info.value) + assert "target_offset_units" in str(exc_info.value) + assert "'caret_offset_units': 3" in str(exc_info.value) diff --git a/tests/test_text_cursor.py b/tests/test_text_cursor.py new file mode 100644 index 00000000..718ab83e --- /dev/null +++ b/tests/test_text_cursor.py @@ -0,0 +1,489 @@ +import asyncio +from types import SimpleNamespace + +import pytest +from pydantic import ValidationError + +import windows_mcp.text_cursor as text_cursor +from windows_mcp.tools.text_cursor import _description + + +class FakeTextRange: + """Minimal in-memory text range for offset calculations.""" + + def __init__(self, start: int, end: int, document_length: int) -> None: + self.start = start + self.end = end + self.document_length = document_length + + def clone(self) -> "FakeTextRange": + return FakeTextRange(self.start, self.end, self.document_length) + + def collapse_range(self, *, to_end: bool) -> None: + position = self.end if to_end else self.start + self.start = position + self.end = position + + def move(self, unit: text_cursor.TextUnit, count: int) -> int: + assert unit is text_cursor.TextUnit.Character + assert self.start == self.end + + target = min(max(self.start + count, 0), self.document_length) + moved = target - self.start + self.start = target + self.end = target + return moved + + +class FakeTextPattern: + def __init__(self, document_length: int) -> None: + self.document_length = document_length + + def document_range(self) -> FakeTextRange: + return FakeTextRange(0, self.document_length, self.document_length) + + +@pytest.mark.parametrize( + ("model", "values"), + [ + ( + text_cursor.MoveRelativeAction, + {"mode": "move_relative", "delta": text_cursor.MAX_TEXT_UNIT_MOVE + 1}, + ), + ( + text_cursor.MoveRelativeAction, + {"mode": "move_relative", "delta": -text_cursor.MAX_TEXT_UNIT_MOVE - 1}, + ), + ( + text_cursor.MoveAbsoluteAction, + {"mode": "move_absolute", "offset": text_cursor.MAX_TEXT_UNIT_MOVE + 1}, + ), + ( + text_cursor.SelectRelativeAction, + { + "mode": "select_relative", + "start_delta": -text_cursor.MAX_TEXT_UNIT_MOVE - 1, + "end_delta": 0, + }, + ), + ( + text_cursor.SelectRelativeAction, + { + "mode": "select_relative", + "start_delta": 0, + "end_delta": text_cursor.MAX_TEXT_UNIT_MOVE + 1, + }, + ), + ( + text_cursor.SelectAbsoluteAction, + { + "mode": "select_absolute", + "start": text_cursor.MAX_TEXT_UNIT_MOVE + 1, + "end": text_cursor.MAX_TEXT_UNIT_MOVE + 1, + }, + ), + ], +) +def test_character_counts_must_fit_uia_int(model, values): + with pytest.raises(ValidationError): + model(**values) + + +def test_character_offset_round_trips_through_document_position(): + document_length = 100 + position = 37 + info = SimpleNamespace( + text_range=FakeTextRange(position, position, document_length), + text_pattern=FakeTextPattern(document_length), + ) + + offset = text_cursor.endpoint_offset(info, text_cursor.TextRangeEndpoint.Start) + target, actual = text_cursor.document_position(info, offset) + + assert offset == position + assert actual == position + assert target.start == position + assert target.end == position + + +def test_selection_endpoint_offsets_use_character_units(): + info = SimpleNamespace( + text_range=FakeTextRange(12, 34, 100), + ) + + assert text_cursor.endpoint_offset(info, text_cursor.TextRangeEndpoint.Start) == 12 + assert text_cursor.endpoint_offset(info, text_cursor.TextRangeEndpoint.End) == 34 + + +def test_snapshot_fails_when_character_offset_is_unavailable(monkeypatch): + monkeypatch.setattr(text_cursor, "endpoint_offset", lambda *args, **kwargs: None) + + with pytest.raises(text_cursor.TextCursorError, match="TextUnit_Character offsets"): + text_cursor.make_snapshot(object(), context_chars=40) + + +def test_snapshot_contract_names_character_unit_fields(): + snapshot = text_cursor.CursorSnapshot( + provider="fake", + type="caret", + caret_offset_units=7, + ) + + result = snapshot.model_dump() + schema = text_cursor.CursorSnapshot.model_json_schema() + + assert result["caret_offset_units"] == 7 + assert "caret_offset" not in result + assert "TextUnit_Character" in schema["properties"]["caret_offset_units"]["description"] + + +def test_tool_descriptions_use_character_units(): + assert "UTF-16" not in _description + assert "passed directly to absolute move/select actions" in _description + assert "UTF-16" not in (text_cursor.run_tool.__doc__ or "") + + +def test_text_range_get_text_normalizes_none_to_empty_text(): + text_range = text_cursor.TextRange( + SimpleNamespace(GetText=lambda max_length: None), + ) + + assert text_range.get_text() == "" + + +def test_text_range_get_text_propagates_com_error(): + def fail_get_text(max_length): + raise text_cursor.COMError(-2147467259, "provider unavailable", None) + + text_range = text_cursor.TextRange( + SimpleNamespace(GetText=fail_get_text), + ) + + with pytest.raises(text_cursor.COMError, match="provider unavailable"): + text_range.get_text() + + +@pytest.mark.asyncio +async def test_cancelled_delay_does_not_reach_com_worker(monkeypatch): + sleep_started = asyncio.Event() + execute_called = False + + async def blocking_sleep(delay: float) -> None: + assert delay == 300 + sleep_started.set() + await asyncio.Event().wait() + + def fake_execute(action) -> None: + nonlocal execute_called + execute_called = True + + monkeypatch.setattr(text_cursor.asyncio, "sleep", blocking_sleep) + monkeypatch.setattr(text_cursor, "execute_sync", fake_execute) + + task = asyncio.create_task( + text_cursor.run_tool(text_cursor.GetInfoAction(mode="get_info", delay=300)) + ) + await sleep_started.wait() + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await task + + assert execute_called is False + + +def test_snapshot_warns_when_provider_returns_multiple_selections(monkeypatch): + caret_info = SimpleNamespace( + text_range=SimpleNamespace( + get_text=lambda max_length=-1: "selected", + text_before=lambda count: "before", + text_after=lambda count: "after", + bounding_rectangles=lambda: [], + ), + element=SimpleNamespace(get_name=lambda: "editor"), + source="TextPattern.GetSelection", + exact_caret=False, + selection_count=3, + ) + monkeypatch.setattr( + text_cursor, + "endpoint_offset", + lambda info, endpoint: 4 if endpoint is text_cursor.TextRangeEndpoint.Start else 12, + ) + + snapshot = text_cursor.make_snapshot(caret_info, context_chars=40) + + assert snapshot.selection_start_units == 4 + assert snapshot.selection_end_units == 12 + assert any("3 disjoint selections" in warning for warning in snapshot.warnings) + assert any("uses only the first selection" in warning for warning in snapshot.warnings) + + +def test_snapshot_truncates_long_selected_text_with_ellipsis(monkeypatch): + limit = text_cursor.MAX_SELECTED_TEXT_CHARS + # The wrapper is asked for limit + 1 chars; the provider returns that many, + # which signals the real selection is longer than the limit. + caret_info = SimpleNamespace( + text_range=SimpleNamespace( + get_text=lambda max_length=-1: "a" * max_length, + text_before=lambda count: "", + text_after=lambda count: "", + bounding_rectangles=lambda: [], + ), + element=SimpleNamespace(get_name=lambda: "editor"), + source="TextPattern.GetSelection", + exact_caret=False, + selection_count=1, + ) + monkeypatch.setattr( + text_cursor, + "endpoint_offset", + lambda info, endpoint: 0 if endpoint is text_cursor.TextRangeEndpoint.Start else 10_000, + ) + + snapshot = text_cursor.make_snapshot(caret_info, context_chars=40) + + assert len(snapshot.selected_text) == limit + 1 # limit chars + ellipsis + assert snapshot.selected_text.endswith("…") + assert snapshot.selected_text[:limit] == "a" * limit + assert any("truncated" in warning for warning in snapshot.warnings) + + +def test_snapshot_keeps_selected_text_at_limit_untruncated(monkeypatch): + limit = text_cursor.MAX_SELECTED_TEXT_CHARS + # A selection exactly at the limit: the provider returns fewer than the + # requested limit + 1 chars, so it must not be flagged as truncated. + caret_info = SimpleNamespace( + text_range=SimpleNamespace( + get_text=lambda max_length=-1: "b" * limit, + text_before=lambda count: "", + text_after=lambda count: "", + bounding_rectangles=lambda: [], + ), + element=SimpleNamespace(get_name=lambda: "editor"), + source="TextPattern.GetSelection", + exact_caret=False, + selection_count=1, + ) + monkeypatch.setattr( + text_cursor, + "endpoint_offset", + lambda info, endpoint: 0 if endpoint is text_cursor.TextRangeEndpoint.Start else limit, + ) + + snapshot = text_cursor.make_snapshot(caret_info, context_chars=40) + + assert snapshot.selected_text == "b" * limit + assert "…" not in snapshot.selected_text + assert not any("truncated" in warning for warning in snapshot.warnings) + + +def test_snapshot_preserves_genuinely_empty_selected_text(monkeypatch): + caret_info = SimpleNamespace( + text_range=SimpleNamespace( + get_text=lambda max_length=-1: "", + bounding_rectangles=lambda: [], + ), + element=SimpleNamespace(get_name=lambda: "editor"), + source="TextPattern.GetSelection", + exact_caret=False, + selection_count=1, + ) + monkeypatch.setattr( + text_cursor, + "endpoint_offset", + lambda info, endpoint: 4 if endpoint is text_cursor.TextRangeEndpoint.Start else 12, + ) + + snapshot = text_cursor.make_snapshot( + caret_info, + context_chars=40, + include_context=False, + ) + + assert snapshot.selected_text == "" + assert not any("reading selected_text" in warning for warning in snapshot.warnings) + + +def test_snapshot_omits_selected_text_and_warns_on_com_error(monkeypatch): + def fail_get_text(max_length=-1): + raise text_cursor.COMError(-2147467259, "provider unavailable", None) + + caret_info = SimpleNamespace( + text_range=SimpleNamespace( + get_text=fail_get_text, + bounding_rectangles=lambda: [], + ), + element=SimpleNamespace(get_name=lambda: "editor"), + source="TextPattern.GetSelection", + exact_caret=False, + selection_count=1, + ) + monkeypatch.setattr( + text_cursor, + "endpoint_offset", + lambda info, endpoint: 4 if endpoint is text_cursor.TextRangeEndpoint.Start else 12, + ) + + snapshot = text_cursor.make_snapshot( + caret_info, + context_chars=40, + include_context=False, + ) + + assert snapshot.selected_text is None + assert snapshot.selection_start_units == 4 + assert snapshot.selection_end_units == 12 + assert any("reading selected_text" in warning for warning in snapshot.warnings) + + +def test_snapshot_reads_context_fields_independently_on_com_error(monkeypatch): + def fail_text_before(count): + raise text_cursor.COMError(-2147467259, "provider unavailable", None) + + caret_info = SimpleNamespace( + text_range=SimpleNamespace( + text_before=fail_text_before, + text_after=lambda count: "after", + bounding_rectangles=lambda: [], + ), + element=SimpleNamespace(get_name=lambda: "editor"), + source="TextPattern.GetSelection", + exact_caret=True, + selection_count=1, + ) + monkeypatch.setattr(text_cursor, "endpoint_offset", lambda info, endpoint: 7) + + snapshot = text_cursor.make_snapshot(caret_info, context_chars=40) + + assert snapshot.caret_offset_units == 7 + assert snapshot.text_before is None + assert snapshot.text_after == "after" + assert any("reading text_before" in warning for warning in snapshot.warnings) + assert not any("reading text_after" in warning for warning in snapshot.warnings) + + +@pytest.mark.parametrize("error_type", [AttributeError, TypeError]) +def test_snapshot_does_not_hide_programming_errors_when_reading_text( + monkeypatch, + error_type, +): + def fail_get_text(max_length=-1): + raise error_type("broken text range wrapper") + + caret_info = SimpleNamespace( + text_range=SimpleNamespace( + get_text=fail_get_text, + bounding_rectangles=lambda: [], + ), + element=SimpleNamespace(get_name=lambda: "editor"), + source="TextPattern.GetSelection", + exact_caret=False, + selection_count=1, + ) + monkeypatch.setattr( + text_cursor, + "endpoint_offset", + lambda info, endpoint: 4 if endpoint is text_cursor.TextRangeEndpoint.Start else 12, + ) + + with pytest.raises(error_type, match="broken text range wrapper"): + text_cursor.make_snapshot( + caret_info, + context_chars=40, + include_context=False, + ) + + +def test_run_write_propagates_warning_from_before_snapshot(monkeypatch): + warning = ( + "TextPattern.GetSelection returned 2 disjoint selections. " + "TextCursor reports and uses only the first selection." + ) + before = text_cursor.CursorSnapshot( + provider="fake", + type="range", + selection_start_units=1, + selection_end_units=2, + warnings=[warning], + ) + after = text_cursor.CursorSnapshot( + provider="fake", + type="caret", + caret_offset_units=2, + ) + providers = iter([object(), object()]) + + monkeypatch.setattr(text_cursor, "find_caret_provider", lambda: next(providers)) + snapshots = iter([before, after]) + monkeypatch.setattr(text_cursor, "make_snapshot", lambda *args, **kwargs: next(snapshots)) + monkeypatch.setattr( + text_cursor, + "apply_write", + lambda action, caret_info: text_cursor.WriteActionResult(None, {}), + ) + + result = text_cursor.run_write( + text_cursor.MoveAbsoluteAction(mode="move_absolute", offset=2, verify=False) + ) + + assert result.warnings == [warning] + + +def test_run_write_distinguishes_target_from_read_back_actual(monkeypatch): + before = text_cursor.CursorSnapshot( + provider="fake", + type="caret", + caret_offset_units=1, + ) + after = text_cursor.CursorSnapshot( + provider="fake", + type="caret", + caret_offset_units=3, + ) + providers = iter([object(), object()]) + snapshots = iter([before, after]) + + monkeypatch.setattr(text_cursor, "find_caret_provider", lambda: next(providers)) + monkeypatch.setattr( + text_cursor, + "make_snapshot", + lambda *args, **kwargs: next(snapshots), + ) + monkeypatch.setattr( + text_cursor, + "apply_write", + lambda action, caret_info: text_cursor.WriteActionResult( + None, + {"target_offset_units": 5}, + ), + ) + + result = text_cursor.run_write( + text_cursor.MoveAbsoluteAction( + mode="move_absolute", + offset=5, + verify=False, + ) + ) + + assert result.target == {"target_offset_units": 5} + assert result.actual == { + "type": "caret", + "caret_offset_units": 3, + } + + +def test_snapshot_position_reports_real_selection_coordinates(): + snapshot = text_cursor.CursorSnapshot( + provider="fake", + type="range", + selection_start_units=4, + selection_end_units=12, + ) + + assert text_cursor.snapshot_position(snapshot) == { + "type": "range", + "selection_start_units": 4, + "selection_end_units": 12, + } From bc81f153eacb3381c138911a52633250209f4377 Mon Sep 17 00:00:00 2001 From: Jeza Date: Thu, 23 Jul 2026 19:51:53 +0800 Subject: [PATCH 2/5] refactor(text_cursor): split monolith into focused modules with a thin facade Break the ~1300-line text_cursor/__init__.py into cohesive modules (constants, errors, models, uia, worker, discovery, ranges, snapshots, operations, service) and reduce __init__.py to a small public facade. - Single test seam: collaborators are called by module-level name and patched on their owning module; drop the dependency-injection params that duplicated that seam. - Break the uia<->worker cycle: move find_caret_provider and try_get_caret_on_element into discovery.py so uia.py is a pure leaf. - Trim the facade to the intended public API (I/O + action models, errors, run_tool); stop re-exporting internal plumbing. - Remove dead code: the unused UIA_TEXT_PATTERN2_ID constant and worker.co_uninitialize. - Minor cleanups: drop a redundant int() wrap and document SelectRelativeAction.origin. Repoint tests to import internals from their owning submodules. No behavior change; full suite passes. --- src/windows_mcp/text_cursor/__init__.py | 1251 +-------------------- src/windows_mcp/text_cursor/constants.py | 13 + src/windows_mcp/text_cursor/discovery.py | 78 ++ src/windows_mcp/text_cursor/errors.py | 9 + src/windows_mcp/text_cursor/models.py | 241 ++++ src/windows_mcp/text_cursor/operations.py | 139 +++ src/windows_mcp/text_cursor/ranges.py | 98 ++ src/windows_mcp/text_cursor/service.py | 116 ++ src/windows_mcp/text_cursor/snapshots.py | 138 +++ src/windows_mcp/text_cursor/uia.py | 373 ++++++ src/windows_mcp/text_cursor/worker.py | 69 ++ tests/test_iserror_compliance.py | 18 +- tests/test_text_cursor.py | 117 +- 13 files changed, 1371 insertions(+), 1289 deletions(-) create mode 100644 src/windows_mcp/text_cursor/constants.py create mode 100644 src/windows_mcp/text_cursor/discovery.py create mode 100644 src/windows_mcp/text_cursor/errors.py create mode 100644 src/windows_mcp/text_cursor/models.py create mode 100644 src/windows_mcp/text_cursor/operations.py create mode 100644 src/windows_mcp/text_cursor/ranges.py create mode 100644 src/windows_mcp/text_cursor/service.py create mode 100644 src/windows_mcp/text_cursor/snapshots.py create mode 100644 src/windows_mcp/text_cursor/uia.py create mode 100644 src/windows_mcp/text_cursor/worker.py diff --git a/src/windows_mcp/text_cursor/__init__.py b/src/windows_mcp/text_cursor/__init__.py index b009c279..b91bd415 100644 --- a/src/windows_mcp/text_cursor/__init__.py +++ b/src/windows_mcp/text_cursor/__init__.py @@ -7,7 +7,7 @@ The caret/selection is discovered via IUIAutomationTextPattern.GetSelection() on the focused element, walking up to its ancestors when needed. IUIAutomationTextPattern2.GetCaretRange() is intentionally not used; see -try_get_caret_on_element for the rationale. +discovery.try_get_caret_on_element for the rationale. Supported modes: - get_info @@ -27,1230 +27,33 @@ from __future__ import annotations -import asyncio -import ctypes -import threading -from concurrent.futures import ThreadPoolExecutor -from dataclasses import dataclass -from typing import Annotated, Any, Literal, Optional, Union, NamedTuple -import enum - -import comtypes.client -from comtypes import COMError -from pydantic import BaseModel, ConfigDict, Field, model_validator - -# --------------------------------------------------------------------------- -# UI Automation constants -# --------------------------------------------------------------------------- - -UIA_TEXT_PATTERN_ID = 10014 -UIA_TEXT_PATTERN2_ID = 10024 - -CLSID_CUIAUTOMATION8 = "{E22AD333-B25F-460C-83D0-0581107395C9}" -CLSID_CUIAUTOMATION = "{FF48DBA4-60EF-4201-AA87-54103EEF594E}" - -MAX_TEXT_UNIT_MOVE = 2_147_483_647 - -# Upper bound on the selected-text string embedded in a snapshot. Unlike the -# surrounding context (capped by context_chars), the selection can be the whole -# document (e.g. after select_all), so cap it here to keep the MCP payload small. -MAX_SELECTED_TEXT_CHARS = 4096 - - -class TextCursorError(RuntimeError): - """Base error raised by TextCursor operations.""" - - -class TextCursorVerificationError(TextCursorError): - """Raised when a TextCursor write cannot be verified.""" - - -# --------------------------------------------------------------------------- -# Internal UIA result -# --------------------------------------------------------------------------- - - -@dataclass -class UIACaretInfo: - element: UIAElement - text_pattern: TextPattern - text_range: TextRange - source: str - exact_caret: bool - selection_count: int = 1 - - -# --------------------------------------------------------------------------- -# UI Automation low-level helpers -# --------------------------------------------------------------------------- - - -class UIAModule: - def __init__(self, raw_module): - self.raw_module = raw_module - - -class UIAAutomationObject: - def __init__(self, raw_obj): - self.raw_obj = raw_obj - - def get_focused_element(self) -> UIAElement | None: - raw_elem = self.raw_obj.GetFocusedElement() - if not raw_elem: - return None - return UIAElement(raw_elem, self.raw_obj) - - -def create_automation() -> tuple[UIAModule, UIAAutomationObject]: - """ - Load UIAutomationCore.dll type information and create the UIA client. - CUIAutomation8 is preferred; CUIAutomation is used as a compatibility fallback. - """ - raw_uia = comtypes.client.GetModule("UIAutomationCore.dll") - - try: - automation = comtypes.client.CreateObject( - CLSID_CUIAUTOMATION8, - interface=raw_uia.IUIAutomation, - ) - except COMError: - automation = comtypes.client.CreateObject( - CLSID_CUIAUTOMATION, - interface=raw_uia.IUIAutomation, - ) - - return UIAModule(raw_uia), UIAAutomationObject(automation) - - -class TextRangeEndpoint(enum.Enum): - """Mirrors the UIA TextPatternRangeEndpoint enumeration.""" - - Start = 0 # TextPatternRangeEndpoint_Start - End = 1 # TextPatternRangeEndpoint_End - - -class TextUnit(enum.Enum): - """Mirrors the UIA TextUnit enumeration.""" - - Character = 0 # TextUnit_Character - Format = 1 # TextUnit_Format - Word = 2 # TextUnit_Word - Line = 3 # TextUnit_Line - Paragraph = 4 # TextUnit_Paragraph - Page = 5 # TextUnit_Page - Document = 6 # TextUnit_Document - - -class UIAElement: - """A simple wrapper for UIA Element""" - - def __init__(self, raw_element: Any, raw_uia_object: Any): - self.raw_element = raw_element - self.raw_uia_obj = raw_uia_object - - def get_pattern(self, pattern_id: int, interface: Any) -> TextPattern | None: - """Return the requested control pattern wrapped as a TextPattern, or None.""" - try: - unknown = self.raw_element.GetCurrentPattern(pattern_id) - if not unknown: - return None - - raw_pattern = unknown.QueryInterface(interface) - if not raw_pattern: - return None - return TextPattern(raw_pattern) - - except (COMError, AttributeError, TypeError): - return None - - def get_name(self) -> str: - try: - return str(self.raw_element.CurrentName or "") - except (COMError, AttributeError): - return "" - - def parent(self) -> UIAElement | None: - walker = self.raw_uia_obj.RawViewWalker - try: - ret = walker.GetParentElement(self.raw_element) - except COMError: - ret = None - - if not ret: - return None - - return UIAElement(ret, self.raw_uia_obj) - - def set_focus(self): - self.raw_element.SetFocus() - - -class TextPattern: - """A simple wrapper for UIA TextPattern""" - - def __init__(self, raw_pattern): - self.raw_pattern = raw_pattern - - def get_selections(self) -> list[TextRange]: - try: - selections = self.raw_pattern.GetSelection() - - if not selections: - return [] - - if int(selections.Length) <= 0: - return [] - - return [ - TextRange(selections.GetElement(index)) for index in range(int(selections.Length)) - ] - - except (COMError, AttributeError, TypeError, ValueError): - return [] - - def get_first_selection(self) -> TextRange | None: - selections = self.get_selections() - if not selections: - return None - - return selections[0] - - def document_range(self) -> TextRange: - return TextRange(self.raw_pattern.DocumentRange) - - -class TextRange: - """A simple wrapper for UIA TextRange""" - - def __init__(self, raw_range): - self.raw_range = raw_range - - def clone(self) -> TextRange: - return TextRange(self.raw_range.Clone()) - - def move(self, unit: TextUnit, count: int) -> int: - """Moves the text range the specified number of TextUnit units within the document range. - Return the number of units actually moved - """ - return int(self.raw_range.Move(unit.value, count)) - - def select(self): - return self.raw_range.Select() - - def move_endpoint_by_range( - self, - src_endpoint: TextRangeEndpoint, - other: TextRange, - target_endpoint: TextRangeEndpoint, - ): - """Moves one endpoint of the current text range to the specified endpoint of a second text range.""" - self.raw_range.MoveEndpointByRange( - src_endpoint.value, - other.raw_range, - target_endpoint.value, - ) - - def move_endpoint_by_unit(self, endpoint: TextRangeEndpoint, unit: TextUnit, count: int) -> int: - """Moves one endpoint of the text range the specified number of TextUnit units within the document range. - Return the number of units actually moved - """ - return int( - self.raw_range.MoveEndpointByUnit( - endpoint.value, - unit.value, - count, - ) - ) - - def compare_endpoints( - self, - src_endpoint: TextRangeEndpoint, - other: TextRange, - target_endpoint: TextRangeEndpoint, - ) -> int: - return int( - self.raw_range.CompareEndpoints( - src_endpoint.value, - other.raw_range, - target_endpoint.value, - ) - ) - - def is_degenerate(self) -> bool: - comparison = self.compare_endpoints( - TextRangeEndpoint.Start, - self, - TextRangeEndpoint.End, - ) - - return int(comparison) == 0 - - def collapse_range(self, *, to_end: bool) -> None: - """Collapse the range to a single point, clearing any selection. - - to_end=False collapses to the start (left) endpoint; to_end=True - collapses to the end (right) endpoint. - """ - if to_end: - # Start --move-> End. - self.move_endpoint_by_range( - TextRangeEndpoint.Start, - self, - TextRangeEndpoint.End, - ) - else: - # End --move-> Start. - self.move_endpoint_by_range( - TextRangeEndpoint.End, - self, - TextRangeEndpoint.Start, - ) - - def get_text(self, max_length: int = -1) -> str: - return str(self.raw_range.GetText(max_length) or "") - - def text_before(self, count: int) -> str: - """Return up to `count` characters immediately before the range.""" - clone = self.clone() - - # Collapse to the start of the range. - clone.collapse_range(to_end=False) - - # Extend the start endpoint backward. - clone.move_endpoint_by_unit(TextRangeEndpoint.Start, TextUnit.Character, -count) - - return clone.get_text() - - def text_after(self, count: int) -> str: - """Return up to `count` characters immediately after the range.""" - clone = self.clone() - - # Collapse to the end of the range. - clone.collapse_range(to_end=True) - - # Extend the end endpoint forward. - clone.move_endpoint_by_unit(TextRangeEndpoint.End, TextUnit.Character, count) - - return clone.get_text() - - def bounding_rectangles(self, try_again_if_err: bool = True) -> list[ScreenRect]: - try: - # An array of bounding rectangles for each fully or partially - # visible line of text in the range. A selection can span multiple - # lines, so each line is returned as its own rectangle. The result - # is a flat tuple of (left, top, width, height) per rectangle: - # (x0, y0, w0, h0, x1, y1, w1, h1, ...). - values = self.raw_range.GetBoundingRectangles() # type: tuple[float, ...] - if values is None: - return [] - - flat = list(values) - - rects = [ - ScreenRect( - left=float(flat[index]), - top=float(flat[index + 1]), - width=float(flat[index + 2]), - height=float(flat[index + 3]), - ) - for index in range(0, len(flat) - 3, 4) - ] - if len(rects) == 0 and self.is_degenerate() and try_again_if_err: - # A caret (degenerate range) sometimes has no bounding - # rectangle; extend it by one character and try once more. - adjacent = self.clone() - moved = adjacent.move_endpoint_by_unit(TextRangeEndpoint.End, TextUnit.Character, 1) - - if int(moved) != 0: - return adjacent.bounding_rectangles(False) # avoid recursion - return rects - - except ( - COMError, - AttributeError, - TypeError, - ValueError, - ): - return [] - - # A TextRange has no stable value-based hash (its endpoints can move) and - # equality requires live COM calls, so keep instances explicitly - # unhashable. Python already does this once __eq__ is defined; stating it - # makes the intent obvious and guards against accidental set/dict use. - __hash__ = None - - def __eq__(self, other: object) -> bool: - if not isinstance(other, TextRange): - return NotImplemented - - try: - start_comparison = self.compare_endpoints( - TextRangeEndpoint.Start, - other, - TextRangeEndpoint.Start, - ) - - end_comparison = self.compare_endpoints( - TextRangeEndpoint.End, - other, - TextRangeEndpoint.End, - ) - except COMError: - # A stale range can no longer be compared; treat it as not equal - # rather than propagating the COM failure out of an equality check. - return False - - return int(start_comparison) == 0 and int(end_comparison) == 0 - - -def try_get_caret_on_element(uia: UIAModule, element: UIAElement) -> Optional[UIACaretInfo]: - # Use TextPattern.GetSelection rather than TextPattern2.GetCaretRange: - # GetCaretRange does not expose the actual selection range, so handling it - # separately is not worth the effort. The first selection is kept as-is: - # a degenerate range is treated as a caret (exact_caret=True), and a - # non-empty one as a range whose active caret endpoint is unknown. - text_pattern = element.get_pattern( - UIA_TEXT_PATTERN_ID, - uia.raw_module.IUIAutomationTextPattern, - ) - - if text_pattern is not None: - selections = text_pattern.get_selections() - - if selections: - selection = selections[0] - return UIACaretInfo( - element=element, - text_pattern=text_pattern, - text_range=selection, - source="TextPattern.GetSelection", - exact_caret=selection.is_degenerate(), - selection_count=len(selections), - ) - - return None - - -def find_caret_provider(max_parent_levels: int = 8) -> UIACaretInfo: - """ - Start at the focused UIA element and walk up RawView parents. - Some controls expose TextPattern on an ancestor rather than on the exact - focused child. - """ - uia, automation = get_thread_automation() - - element = automation.get_focused_element() - - if not element: - raise RuntimeError("UI Automation returned no focused element.") - - element_name = element.get_name() - - tried_cnt = 0 - while element and tried_cnt < max_parent_levels + 1: - tried_cnt += 1 - result = try_get_caret_on_element(uia, element) - - if result is not None: - return result - - try: - element = element.parent() - except COMError: - element = None - - raise RuntimeError( - f"The focused element {f'"{element_name}" ' if element_name else ''}and " - f"its inspected parents do not expose TextPattern." - ) - - -def range_start_offset( - text_range: TextRange, -) -> Optional[int]: - """Return the range start in UIA TextUnit_Character steps. - - The offset is measured from DocumentRange start and uses the same - provider-defined coordinate system as absolute move/select actions. - - Cost note: UIA exposes no direct "character index" query, so the offset is - derived by moving a degenerate range back to DocumentRange start. Providers - that implement Move as a linear walk make this O(offset), i.e. proportional - to the distance from the document start. This is negligible for typical text - controls but can be noticeable in very large documents, so avoid high- - frequency polling there. - """ - try: - clone = text_range.clone() - clone.collapse_range(to_end=False) - # Walk back to the very start of the document. - moved = clone.move(TextUnit.Character, -MAX_TEXT_UNIT_MOVE) - # Moving backward returns a negative count, so negate it to get the - # positive offset from DocumentRange start. - return -moved - - except (COMError, AttributeError, TypeError): - return None - - -# region -# --------------------------------------------------------------------------- -# MCP input models -# --------------------------------------------------------------------------- - -RelativeOrigin = Literal[ - "caret", - "selection_start", - "selection_end", -] - -CollapseEdge = Literal["start", "end"] - - -class ActionBase(BaseModel): - model_config = ConfigDict(extra="forbid") - - delay: float = Field( - default=0.0, - ge=0.0, - le=300.0, - description=( - "Seconds to wait before locating the focused UIA element and " - "executing this action. Use this to leave time for the user or " - "another automation step to focus the target text control." - ), - ) - - context_chars: int = Field( - default=40, - ge=0, - le=4096, - description=("Number of UIA character units to read around the caret or selection."), - ) - - verify: bool = Field( - default=True, - description=( - "Read the selection back after a write operation and verify that " - "the provider actually applied it." - ), - ) - - -class GetInfoAction(ActionBase): - mode: Literal["get_info"] - - -class MoveRelativeAction(ActionBase): - mode: Literal["move_relative"] - - delta: int = Field( - ge=-MAX_TEXT_UNIT_MOVE, - le=MAX_TEXT_UNIT_MOVE, - description=( - "Signed UIA TextUnit_Character movement. Positive moves forward; " - "negative moves backward." - ), - ) - - origin: RelativeOrigin = Field( - default="caret", - description=( - "Movement origin. For a non-empty TextPattern fallback selection, " - "use selection_start or selection_end." - ), - ) - - -class MoveAbsoluteAction(ActionBase): - mode: Literal["move_absolute"] - - offset: int = Field( - ge=0, - le=MAX_TEXT_UNIT_MOVE, - description="Target position in UIA TextUnit_Character steps from DocumentRange start.", - ) - - -class SelectRelativeAction(ActionBase): - mode: Literal["select_relative"] - - origin: RelativeOrigin = "caret" - - start_delta: int = Field( - ge=-MAX_TEXT_UNIT_MOVE, - le=MAX_TEXT_UNIT_MOVE, - description="Signed delta from origin to the inclusive selection start.", - ) - end_delta: int = Field( - ge=-MAX_TEXT_UNIT_MOVE, - le=MAX_TEXT_UNIT_MOVE, - description="Signed delta from origin to the exclusive selection end.", - ) - - @model_validator(mode="after") - def validate_deltas(self) -> "SelectRelativeAction": - if self.start_delta > self.end_delta: - raise ValueError("start_delta must be less than or equal to end_delta") - - return self - - -class SelectAbsoluteAction(ActionBase): - mode: Literal["select_absolute"] - - start: int = Field( - ge=0, - le=MAX_TEXT_UNIT_MOVE, - description=( - "Inclusive selection start in UIA TextUnit_Character steps from DocumentRange start." - ), - ) - - end: int = Field( - ge=0, - le=MAX_TEXT_UNIT_MOVE, - description=( - "Exclusive selection end in UIA TextUnit_Character steps from DocumentRange start." - ), - ) - - @model_validator(mode="after") - def validate_offsets(self) -> "SelectAbsoluteAction": - if self.start > self.end: - raise ValueError("start must be less than or equal to end") - - return self - - -class SelectAllAction(ActionBase): - mode: Literal["select_all"] - - -class CollapseSelectionAction(ActionBase): - mode: Literal["collapse_selection"] - edge: CollapseEdge - - -CursorAction = Annotated[ - Union[ - GetInfoAction, - MoveRelativeAction, - MoveAbsoluteAction, - SelectRelativeAction, - SelectAbsoluteAction, - SelectAllAction, - CollapseSelectionAction, - ], - Field(discriminator="mode"), -] - - -# endregion - -# region -# --------------------------------------------------------------------------- -# MCP output models -# --------------------------------------------------------------------------- - - -class ScreenRect(BaseModel): - left: float - top: float - width: float - height: float - - -def _ignore_none(value: object) -> bool: - return value is None - - -class CursorSnapshot(BaseModel): - provider: str - element_name: str | None = None - - type: Literal["caret", "range"] - - caret_offset_units: int | None = Field( - default=None, - exclude_if=_ignore_none, - description=( - "Caret offset in provider-defined UIA TextUnit_Character steps " - "from DocumentRange start." - ), - ) - selection_start_units: int | None = Field( - default=None, - exclude_if=_ignore_none, - description=( - "Selection start in provider-defined UIA TextUnit_Character steps " - "from DocumentRange start." - ), - ) - selection_end_units: int | None = Field( - default=None, - exclude_if=_ignore_none, - description=( - "Selection end in provider-defined UIA TextUnit_Character steps " - "from DocumentRange start." - ), - ) - - selected_text: str | None = Field(default=None, exclude_if=_ignore_none) - text_before: str | None = Field(default=None, exclude_if=_ignore_none) - text_after: str | None = Field(default=None, exclude_if=_ignore_none) - - bounding_rects: list[ScreenRect] = Field(default_factory=list) - - warnings: list[str] = Field(default_factory=list) - - -class CursorToolResult(BaseModel): - success: bool - mode: str - message: str - - verified: bool | None = Field(default=None, exclude_if=_ignore_none) - - requested: dict[str, Any] = Field(default_factory=dict) - - target: dict[str, Any] = Field( - default_factory=dict, - description=( - "Target calculated on the client-side UIA range after applying document-boundary " - "clamping. This does not prove that the provider applied the target." - ), - ) - - actual: dict[str, Any] = Field( - default_factory=dict, - description="Real caret or selection position read back from the provider after the write.", - ) - - before: CursorSnapshot | None = None - after: CursorSnapshot | None = None - - warnings: list[str] = Field(default_factory=list) - - -# endregion - -# --------------------------------------------------------------------------- -# COM worker -# --------------------------------------------------------------------------- - -COINIT_MULTITHREADED = 0x0 -RPC_E_CHANGED_MODE = 0x80010106 - -_EXECUTOR = ThreadPoolExecutor( - max_workers=1, - thread_name_prefix="uia-text-cursor", -) - -# Per-worker-thread COM state. The executor thread lives for the process -# lifetime, so COM is initialized once and the IUIAutomation client is cached -# and reused across calls instead of being recreated (and the apartment -# re-initialized) on every invocation. -_thread_state = threading.local() - - -def co_initialize_mta() -> bool: - ole32 = ctypes.windll.ole32 - - ole32.CoInitializeEx.argtypes = [ - ctypes.c_void_p, - ctypes.c_ulong, - ] - ole32.CoInitializeEx.restype = ctypes.c_long - - hr = int( - ole32.CoInitializeEx( - None, - COINIT_MULTITHREADED, - ) - ) - - unsigned_hr = hr & 0xFFFFFFFF - - if unsigned_hr == RPC_E_CHANGED_MODE: - raise RuntimeError("The UIA worker thread has an incompatible COM apartment.") - - if unsigned_hr & 0x80000000: - raise OSError(f"CoInitializeEx failed: 0x{unsigned_hr:08X}") - - return True - - -def co_uninitialize() -> None: - ctypes.windll.ole32.CoUninitialize() - - -def get_thread_automation() -> tuple[UIAModule, UIAAutomationObject]: - """Return this worker thread's cached UIA client, creating it on first use. - - COM is initialized (MTA) exactly once per thread and the IUIAutomation - client is cached, so repeated calls reuse the same client rather than - paying for CoInitializeEx + CreateObject on every invocation. The client - is long-lived and never goes stale; only the focused element and its text - ranges are re-fetched per call. - """ - if not getattr(_thread_state, "com_initialized", False): - co_initialize_mta() - _thread_state.com_initialized = True - - automation = getattr(_thread_state, "automation", None) - if automation is None: - automation = create_automation() - _thread_state.automation = automation - - return automation - - -# --------------------------------------------------------------------------- -# Snapshot helpers -# --------------------------------------------------------------------------- - - -def endpoint_offset( - caret_info: UIACaretInfo, - endpoint: TextRangeEndpoint, -) -> Optional[int]: - """Return the character offset from the document start to the given - endpoint (Start or End) of the caret/selection range.""" - marker = caret_info.text_range.clone() - - if endpoint == TextRangeEndpoint.End: - marker.collapse_range(to_end=True) - else: - marker.collapse_range(to_end=False) - - return range_start_offset(marker) - - -def make_snapshot( - caret_info: UIACaretInfo, - context_chars: int, - *, - include_context: bool = True, - include_selected_text: bool = True, -) -> CursorSnapshot: - start = endpoint_offset(caret_info, TextRangeEndpoint.Start) - # A caret is a degenerate range: both endpoints resolve to the same offset, - # and only caret_offset_units (== start) is emitted. Computing the End - # endpoint would be a second full Move(-MAX) walk back to DocumentRange - # start for nothing, so reuse start. Only a real selection needs End. - # (The start-is-None short-circuit keeps the unavailable-offset error below - # from being masked by inspecting the range first.) - if start is not None and caret_info.exact_caret: - end = start - else: - end = endpoint_offset(caret_info, TextRangeEndpoint.End) - - if start is None or end is None: - raise TextCursorError( - "The provider did not allow calculating UIA TextUnit_Character offsets." - ) - - warnings: list[str] = [] - - selected_text = None - selected_text_truncated = False - if include_selected_text and not caret_info.exact_caret: - # Read one extra character so a selection sitting exactly on the limit - # is not mistaken for a truncated one. - try: - selected_text = caret_info.text_range.get_text(MAX_SELECTED_TEXT_CHARS + 1) - except COMError: - warnings.append( - "The provider did not allow reading selected_text. The field was omitted; " - "selection_start_units and selection_end_units are still available." - ) - else: - if len(selected_text) > MAX_SELECTED_TEXT_CHARS: - selected_text = selected_text[:MAX_SELECTED_TEXT_CHARS] + "…" - selected_text_truncated = True - - # --- Read the text on both sides of the caret/selection. --- - before = None - after = None - - if include_context: - try: - before = caret_info.text_range.text_before(context_chars) - except COMError: - warnings.append("The provider did not allow reading text_before. The field was omitted.") - - try: - after = caret_info.text_range.text_after(context_chars) - except COMError: - warnings.append("The provider did not allow reading text_after. The field was omitted.") - - if caret_info.selection_count > 1: - warnings.append( - f"TextPattern.GetSelection returned {caret_info.selection_count} " - "disjoint selections. TextCursor reports and uses only the first " - "selection; the remaining selections are ignored." - ) - - if not caret_info.exact_caret: - warnings.append( - "TextPattern.GetSelection returned a non-empty selection. " - "The active caret endpoint is unknown." - ) - - if selected_text_truncated: - warnings.append( - f"selected_text was truncated to {MAX_SELECTED_TEXT_CHARS} characters " - "(marked with a trailing ellipsis). The selection_start_units and " - "selection_end_units offsets still describe the full selection." - ) - - return CursorSnapshot( - provider=caret_info.source, - element_name=(caret_info.element.get_name() or None), - type="caret" if caret_info.exact_caret else "range", - caret_offset_units=start if caret_info.exact_caret else None, # caret only - selection_start_units=(start if not caret_info.exact_caret else None), # range only - selection_end_units=end if not caret_info.exact_caret else None, # range only - selected_text=selected_text, - text_before=before, - text_after=after, - bounding_rects=caret_info.text_range.bounding_rectangles(), - warnings=warnings, - ) - - -def snapshot_position(snapshot: CursorSnapshot) -> dict[str, Any]: - """Return only the real caret or selection coordinates from a snapshot.""" - if snapshot.type == "caret": - return { - "type": "caret", - "caret_offset_units": snapshot.caret_offset_units, - } - - return { - "type": "range", - "selection_start_units": snapshot.selection_start_units, - "selection_end_units": snapshot.selection_end_units, - } - - -# --------------------------------------------------------------------------- -# Range construction -# --------------------------------------------------------------------------- - - -def get_origin_from_range( - caret_info: UIACaretInfo, - origin: RelativeOrigin, -) -> TextRange: - """Return the base position a move/select operation is measured from. - - A range has two endpoints, so `origin` selects which one to start from: - 'caret' (only valid for a degenerate range), 'selection_start', or - 'selection_end'. - """ - base = caret_info.text_range.clone() - - if origin == "caret": - if not base.is_degenerate(): - raise RuntimeError( - "The current range is a non-empty selection. " - "The TextPattern fallback does not reveal which endpoint " - "is the active caret. Use selection_start or selection_end." - ) - - return base - - base.collapse_range(to_end=(origin == "selection_end")) - return base - - -def document_position( - caret_info: UIACaretInfo, - offset: int, -) -> tuple[TextRange, int]: - """Build a degenerate range at `offset` characters from the document start.""" - # Get the range spanning the whole document. - target = caret_info.text_pattern.document_range() - # Collapse to its start endpoint. - target.collapse_range(to_end=False) - actual_moved = int(target.move(TextUnit.Character, offset)) - return target, actual_moved - - -def make_range( - start_marker: TextRange, - end_marker: TextRange, -) -> TextRange: - # s----------e - # ^ - target = start_marker.clone() - target.collapse_range(to_end=False) - - # s----------e - # ^----------^ - target.move_endpoint_by_range( - TextRangeEndpoint.End, - end_marker, - TextRangeEndpoint.Start, - ) - - # Check whether target's start endpoint has passed its end endpoint (> 0). - comparison = target.compare_endpoints( - TextRangeEndpoint.Start, - target, - TextRangeEndpoint.End, - ) - - if int(comparison) > 0: - raise RuntimeError("The calculated selection start is after its end.") - return target - - -# --------------------------------------------------------------------------- -# Applying and verifying write operations -# --------------------------------------------------------------------------- - - -def apply_change(caret_info: UIACaretInfo, target: TextRange): - caret_info.element.set_focus() - # Move() only modifies the local range. - # Select() requests the actual caret/selection change. - target.select() - - -def verify( - caret_info: UIACaretInfo, - target: TextRange, - need_verify: bool, -) -> Optional[bool]: - """Verify the applied range matches `target` by reading the selection back. - - Returns None when verification was not requested. - """ - if not need_verify: - return None - - actual = caret_info.text_pattern.get_first_selection() - if actual is None: - return False - - return target == actual - - -# --------------------------------------------------------------------------- -# Tool modes -# --------------------------------------------------------------------------- - - -def run_get_info(action: GetInfoAction) -> CursorToolResult: - caret_info = find_caret_provider() - snapshot = make_snapshot(caret_info, action.context_chars) - - return CursorToolResult( - success=True, - mode=action.mode, - message="Caret information acquired.", - after=snapshot, - warnings=snapshot.warnings, - ) - - -class WriteActionResult(NamedTuple): - verified: bool | None # None means there is no verification after action - target_info: dict[str, Any] - - -def apply_move_relative(action: MoveRelativeAction, caret_info: UIACaretInfo) -> WriteActionResult: - # For a selection, resolve which endpoint the move is relative to. - target = get_origin_from_range(caret_info, action.origin) - target_delta = int(target.move(TextUnit.Character, action.delta)) - apply_change(caret_info, target) - verified = verify(caret_info, target, action.verify) - return WriteActionResult(verified, {"target_delta": target_delta}) - - -def apply_move_absolute(action: MoveAbsoluteAction, caret_info: UIACaretInfo) -> WriteActionResult: - target, target_offset = document_position(caret_info, action.offset) - apply_change(caret_info, target) - verified = verify(caret_info, target, action.verify) - return WriteActionResult(verified, {"target_offset_units": target_offset}) - - -def apply_select_relative( - action: SelectRelativeAction, caret_info: UIACaretInfo -) -> WriteActionResult: - origin = get_origin_from_range(caret_info, action.origin) - - start_marker = origin.clone() - end_marker = origin.clone() - - target_start_delta = start_marker.move(TextUnit.Character, action.start_delta) - target_end_delta = end_marker.move(TextUnit.Character, action.end_delta) - - target = make_range(start_marker, end_marker) - - apply_change(caret_info, target) - verified = verify(caret_info, target, action.verify) - - return WriteActionResult( - verified, - { - "target_start_delta": target_start_delta, - "target_end_delta": target_end_delta, - }, - ) - - -def apply_select_absolute( - action: SelectAbsoluteAction, - caret_info: UIACaretInfo, -) -> WriteActionResult: - start_marker, target_start = document_position(caret_info, action.start) - end_marker, target_end = document_position(caret_info, action.end) - - target = make_range(start_marker, end_marker) - - apply_change(caret_info, target) - verified = verify(caret_info, target, action.verify) - - return WriteActionResult( - verified, - { - "target_start_units": target_start, - "target_end_units": target_end, - }, - ) - - -def apply_select_all( - action: SelectAllAction, - caret_info: UIACaretInfo, -) -> WriteActionResult: - target = caret_info.text_pattern.document_range() - apply_change(caret_info, target) - verified = verify(caret_info, target, action.verify) - return WriteActionResult(verified, {}) - - -def apply_collapse_selection( - action: CollapseSelectionAction, - caret_info: UIACaretInfo, -) -> WriteActionResult: - target = caret_info.text_range.clone() - target.collapse_range(to_end=(action.edge == "end")) - - apply_change(caret_info, target) - verified = verify(caret_info, target, action.verify) - return WriteActionResult(verified, {"edge": action.edge}) - - -WriteAction = Union[ - MoveRelativeAction, +from .errors import TextCursorError, TextCursorVerificationError +from .models import ( + CollapseSelectionAction, + CursorAction, + CursorSnapshot, + CursorToolResult, + GetInfoAction, MoveAbsoluteAction, - SelectRelativeAction, + MoveRelativeAction, SelectAbsoluteAction, SelectAllAction, - CollapseSelectionAction, + SelectRelativeAction, +) +from .service import run_tool + +__all__ = [ + "CollapseSelectionAction", + "CursorAction", + "CursorSnapshot", + "CursorToolResult", + "GetInfoAction", + "MoveAbsoluteAction", + "MoveRelativeAction", + "SelectAbsoluteAction", + "SelectAllAction", + "SelectRelativeAction", + "TextCursorError", + "TextCursorVerificationError", + "run_tool", ] - - -def apply_write(action: WriteAction, caret_info: UIACaretInfo) -> WriteActionResult: - match action: - case MoveRelativeAction(): - return apply_move_relative(action, caret_info) - case MoveAbsoluteAction(): - return apply_move_absolute(action, caret_info) - case SelectRelativeAction(): - return apply_select_relative(action, caret_info) - case SelectAbsoluteAction(): - return apply_select_absolute(action, caret_info) - case SelectAllAction(): - return apply_select_all(action, caret_info) - case CollapseSelectionAction(): - return apply_collapse_selection(action, caret_info) - case _: - raise TypeError(f"Unsupported action type: {type(action)!r}") - - -def run_write(action: WriteAction) -> CursorToolResult: - caret_info = find_caret_provider() - - before = make_snapshot(caret_info, action.context_chars) - verified, target = apply_write(action, caret_info) - requested = action.model_dump( - exclude={ - "delay", - "context_chars", - "verify", - } - ) - - # Always reacquire the focused provider before reporting success or a - # verification mismatch. The values returned by TextRange.Move describe - # only the client-side target; `actual` must come from the real provider. - refreshed = find_caret_provider() - after = make_snapshot(refreshed, action.context_chars) - actual = snapshot_position(after) - - if verified is False: - raise TextCursorVerificationError( - "The provider accepted the operation, but read-back verification " - "showed that the real caret/selection did not match the calculated " - f"target. Requested: {requested}; target: {target}; actual: {actual}." - ) - - warnings = list(dict.fromkeys([*before.warnings, *after.warnings])) - - return CursorToolResult( - success=True, - mode=action.mode, - message="Operation applied.", - verified=verified, - requested=requested, - target=target, - actual=actual, - before=before, - after=after, - warnings=warnings, - ) - - -def execute_sync(action: CursorAction) -> CursorToolResult: - # COM is initialized once per worker thread and the UIA client is cached - # on first use (see get_thread_automation); there is no longer a per-call - # CoInitialize/CoUninitialize or CreateObject. - if isinstance(action, GetInfoAction): - return run_get_info(action) - - return run_write(action) - - -async def run_tool(action: CursorAction) -> CursorToolResult: - """ - Inspect or manipulate the focused Windows text control through UIA. - Modes: - - get_info - - move_relative - - move_absolute - - select_relative - - select_absolute - - select_all - - collapse_selection - Every mode accepts `delay`, expressed in seconds. The delay occurs before - the focused UIA element is located, so the caller can focus the target - control during that interval. - Absolute move/select inputs and returned offsets both use provider-defined - UIA TextUnit_Character steps from DocumentRange start. Returned offsets can - be passed directly to absolute move/select actions. - """ - if action.delay > 0: - await asyncio.sleep(action.delay) - - loop = asyncio.get_running_loop() - return await loop.run_in_executor( - _EXECUTOR, - execute_sync, - action, - ) diff --git a/src/windows_mcp/text_cursor/constants.py b/src/windows_mcp/text_cursor/constants.py new file mode 100644 index 00000000..7b13a6db --- /dev/null +++ b/src/windows_mcp/text_cursor/constants.py @@ -0,0 +1,13 @@ +"""Constants used by the text cursor implementation.""" + +UIA_TEXT_PATTERN_ID = 10014 + +CLSID_CUIAUTOMATION8 = "{E22AD333-B25F-460C-83D0-0581107395C9}" +CLSID_CUIAUTOMATION = "{FF48DBA4-60EF-4201-AA87-54103EEF594E}" + +MAX_TEXT_UNIT_MOVE = 2_147_483_647 + +# Upper bound on the selected-text string embedded in a snapshot. Unlike the +# surrounding context (capped by context_chars), the selection can be the whole +# document (e.g. after select_all), so cap it here to keep the MCP payload small. +MAX_SELECTED_TEXT_CHARS = 4096 diff --git a/src/windows_mcp/text_cursor/discovery.py b/src/windows_mcp/text_cursor/discovery.py new file mode 100644 index 00000000..dc767629 --- /dev/null +++ b/src/windows_mcp/text_cursor/discovery.py @@ -0,0 +1,78 @@ +"""Locate the focused UIA element that exposes the caret/selection TextPattern. + +This sits above the pure UIA wrappers in `uia` because it depends on the COM +worker thread (`worker.get_thread_automation`). Keeping it here lets `uia` stay +a leaf module that `worker` can import without forming a cycle. +""" + +from __future__ import annotations + +from typing import Optional + +from comtypes import COMError + +from .constants import UIA_TEXT_PATTERN_ID +from .uia import UIACaretInfo, UIAElement, UIAModule +from .worker import get_thread_automation + + +def try_get_caret_on_element(uia: UIAModule, element: UIAElement) -> Optional[UIACaretInfo]: + # Use TextPattern.GetSelection rather than TextPattern2.GetCaretRange: + # GetCaretRange does not expose the actual selection range, so handling it + # separately is not worth the effort. The first selection is kept as-is: + # a degenerate range is treated as a caret (exact_caret=True), and a + # non-empty one as a range whose active caret endpoint is unknown. + text_pattern = element.get_pattern( + UIA_TEXT_PATTERN_ID, + uia.raw_module.IUIAutomationTextPattern, + ) + + if text_pattern is not None: + selections = text_pattern.get_selections() + + if selections: + selection = selections[0] + return UIACaretInfo( + element=element, + text_pattern=text_pattern, + text_range=selection, + source="TextPattern.GetSelection", + exact_caret=selection.is_degenerate(), + selection_count=len(selections), + ) + + return None + + +def find_caret_provider(max_parent_levels: int = 8) -> UIACaretInfo: + """ + Start at the focused UIA element and walk up RawView parents. + Some controls expose TextPattern on an ancestor rather than on the exact + focused child. + """ + uia, automation = get_thread_automation() + + element = automation.get_focused_element() + + if not element: + raise RuntimeError("UI Automation returned no focused element.") + + element_name = element.get_name() + + tried_cnt = 0 + while element and tried_cnt < max_parent_levels + 1: + tried_cnt += 1 + result = try_get_caret_on_element(uia, element) + + if result is not None: + return result + + try: + element = element.parent() + except COMError: + element = None + + raise RuntimeError( + f"The focused element {f'"{element_name}" ' if element_name else ''}and " + f"its inspected parents do not expose TextPattern." + ) diff --git a/src/windows_mcp/text_cursor/errors.py b/src/windows_mcp/text_cursor/errors.py new file mode 100644 index 00000000..1ac19a88 --- /dev/null +++ b/src/windows_mcp/text_cursor/errors.py @@ -0,0 +1,9 @@ +"""Errors raised by text cursor operations.""" + + +class TextCursorError(RuntimeError): + """Base error raised by TextCursor operations.""" + + +class TextCursorVerificationError(TextCursorError): + """Raised when a TextCursor write cannot be verified.""" diff --git a/src/windows_mcp/text_cursor/models.py b/src/windows_mcp/text_cursor/models.py new file mode 100644 index 00000000..838a70a0 --- /dev/null +++ b/src/windows_mcp/text_cursor/models.py @@ -0,0 +1,241 @@ +"""Input and output models for the TextCursor MCP tool.""" + +from __future__ import annotations + +from typing import Annotated, Any, Literal, Union + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from .constants import MAX_TEXT_UNIT_MOVE + +RelativeOrigin = Literal[ + "caret", + "selection_start", + "selection_end", +] + +CollapseEdge = Literal["start", "end"] + + +class ActionBase(BaseModel): + model_config = ConfigDict(extra="forbid") + + delay: float = Field( + default=0.0, + ge=0.0, + le=300.0, + description=( + "Seconds to wait before locating the focused UIA element and " + "executing this action. Use this to leave time for the user or " + "another automation step to focus the target text control." + ), + ) + + context_chars: int = Field( + default=40, + ge=0, + le=4096, + description=("Number of UIA character units to read around the caret or selection."), + ) + + verify: bool = Field( + default=True, + description=( + "Read the selection back after a write operation and verify that " + "the provider actually applied it." + ), + ) + + +class GetInfoAction(ActionBase): + mode: Literal["get_info"] + + +class MoveRelativeAction(ActionBase): + mode: Literal["move_relative"] + + delta: int = Field( + ge=-MAX_TEXT_UNIT_MOVE, + le=MAX_TEXT_UNIT_MOVE, + description=( + "Signed UIA TextUnit_Character movement. Positive moves forward; " + "negative moves backward." + ), + ) + + origin: RelativeOrigin = Field( + default="caret", + description=( + "Movement origin. For a non-empty TextPattern fallback selection, " + "use selection_start or selection_end." + ), + ) + + +class MoveAbsoluteAction(ActionBase): + mode: Literal["move_absolute"] + + offset: int = Field( + ge=0, + le=MAX_TEXT_UNIT_MOVE, + description="Target position in UIA TextUnit_Character steps from DocumentRange start.", + ) + + +class SelectRelativeAction(ActionBase): + mode: Literal["select_relative"] + + origin: RelativeOrigin = Field( + default="caret", + description=( + "Origin the selection deltas are measured from. For a non-empty " + "TextPattern fallback selection, use selection_start or selection_end." + ), + ) + + start_delta: int = Field( + ge=-MAX_TEXT_UNIT_MOVE, + le=MAX_TEXT_UNIT_MOVE, + description="Signed delta from origin to the inclusive selection start.", + ) + end_delta: int = Field( + ge=-MAX_TEXT_UNIT_MOVE, + le=MAX_TEXT_UNIT_MOVE, + description="Signed delta from origin to the exclusive selection end.", + ) + + @model_validator(mode="after") + def validate_deltas(self) -> "SelectRelativeAction": + if self.start_delta > self.end_delta: + raise ValueError("start_delta must be less than or equal to end_delta") + + return self + + +class SelectAbsoluteAction(ActionBase): + mode: Literal["select_absolute"] + + start: int = Field( + ge=0, + le=MAX_TEXT_UNIT_MOVE, + description=( + "Inclusive selection start in UIA TextUnit_Character steps from DocumentRange start." + ), + ) + + end: int = Field( + ge=0, + le=MAX_TEXT_UNIT_MOVE, + description=( + "Exclusive selection end in UIA TextUnit_Character steps from DocumentRange start." + ), + ) + + @model_validator(mode="after") + def validate_offsets(self) -> "SelectAbsoluteAction": + if self.start > self.end: + raise ValueError("start must be less than or equal to end") + + return self + + +class SelectAllAction(ActionBase): + mode: Literal["select_all"] + + +class CollapseSelectionAction(ActionBase): + mode: Literal["collapse_selection"] + edge: CollapseEdge + + +CursorAction = Annotated[ + Union[ + GetInfoAction, + MoveRelativeAction, + MoveAbsoluteAction, + SelectRelativeAction, + SelectAbsoluteAction, + SelectAllAction, + CollapseSelectionAction, + ], + Field(discriminator="mode"), +] + + +class ScreenRect(BaseModel): + left: float + top: float + width: float + height: float + + +def _ignore_none(value: object) -> bool: + return value is None + + +class CursorSnapshot(BaseModel): + provider: str + element_name: str | None = None + + type: Literal["caret", "range"] + + caret_offset_units: int | None = Field( + default=None, + exclude_if=_ignore_none, + description=( + "Caret offset in provider-defined UIA TextUnit_Character steps " + "from DocumentRange start." + ), + ) + selection_start_units: int | None = Field( + default=None, + exclude_if=_ignore_none, + description=( + "Selection start in provider-defined UIA TextUnit_Character steps " + "from DocumentRange start." + ), + ) + selection_end_units: int | None = Field( + default=None, + exclude_if=_ignore_none, + description=( + "Selection end in provider-defined UIA TextUnit_Character steps " + "from DocumentRange start." + ), + ) + + selected_text: str | None = Field(default=None, exclude_if=_ignore_none) + text_before: str | None = Field(default=None, exclude_if=_ignore_none) + text_after: str | None = Field(default=None, exclude_if=_ignore_none) + + bounding_rects: list[ScreenRect] = Field(default_factory=list) + + warnings: list[str] = Field(default_factory=list) + + +class CursorToolResult(BaseModel): + success: bool + mode: str + message: str + + verified: bool | None = Field(default=None, exclude_if=_ignore_none) + + requested: dict[str, Any] = Field(default_factory=dict) + + target: dict[str, Any] = Field( + default_factory=dict, + description=( + "Target calculated on the client-side UIA range after applying document-boundary " + "clamping. This does not prove that the provider applied the target." + ), + ) + + actual: dict[str, Any] = Field( + default_factory=dict, + description="Real caret or selection position read back from the provider after the write.", + ) + + before: CursorSnapshot | None = None + after: CursorSnapshot | None = None + + warnings: list[str] = Field(default_factory=list) diff --git a/src/windows_mcp/text_cursor/operations.py b/src/windows_mcp/text_cursor/operations.py new file mode 100644 index 00000000..5e2b0b1f --- /dev/null +++ b/src/windows_mcp/text_cursor/operations.py @@ -0,0 +1,139 @@ +"""Implement TextCursor write modes against UI Automation ranges.""" + +from __future__ import annotations + +from typing import Any, NamedTuple, Union + +from .models import ( + CollapseSelectionAction, + MoveAbsoluteAction, + MoveRelativeAction, + SelectAbsoluteAction, + SelectAllAction, + SelectRelativeAction, +) +from .ranges import ( + apply_change, + document_position, + get_origin_from_range, + make_range, + verify, +) +from .uia import UIACaretInfo, TextUnit + + +class WriteActionResult(NamedTuple): + verified: bool | None # None means there is no verification after action + target_info: dict[str, Any] + + +def apply_move_relative(action: MoveRelativeAction, caret_info: UIACaretInfo) -> WriteActionResult: + # For a selection, resolve which endpoint the move is relative to. + target = get_origin_from_range(caret_info, action.origin) + target_delta = target.move(TextUnit.Character, action.delta) + apply_change(caret_info, target) + verified = verify(caret_info, target, action.verify) + return WriteActionResult(verified, {"target_delta": target_delta}) + + +def apply_move_absolute(action: MoveAbsoluteAction, caret_info: UIACaretInfo) -> WriteActionResult: + target, target_offset = document_position(caret_info, action.offset) + apply_change(caret_info, target) + verified = verify(caret_info, target, action.verify) + return WriteActionResult(verified, {"target_offset_units": target_offset}) + + +def apply_select_relative( + action: SelectRelativeAction, caret_info: UIACaretInfo +) -> WriteActionResult: + origin = get_origin_from_range(caret_info, action.origin) + + start_marker = origin.clone() + end_marker = origin.clone() + + target_start_delta = start_marker.move(TextUnit.Character, action.start_delta) + target_end_delta = end_marker.move(TextUnit.Character, action.end_delta) + + target = make_range(start_marker, end_marker) + + apply_change(caret_info, target) + verified = verify(caret_info, target, action.verify) + + return WriteActionResult( + verified, + { + "target_start_delta": target_start_delta, + "target_end_delta": target_end_delta, + }, + ) + + +def apply_select_absolute( + action: SelectAbsoluteAction, + caret_info: UIACaretInfo, +) -> WriteActionResult: + start_marker, target_start = document_position(caret_info, action.start) + end_marker, target_end = document_position(caret_info, action.end) + + target = make_range(start_marker, end_marker) + + apply_change(caret_info, target) + verified = verify(caret_info, target, action.verify) + + return WriteActionResult( + verified, + { + "target_start_units": target_start, + "target_end_units": target_end, + }, + ) + + +def apply_select_all( + action: SelectAllAction, + caret_info: UIACaretInfo, +) -> WriteActionResult: + target = caret_info.text_pattern.document_range() + apply_change(caret_info, target) + verified = verify(caret_info, target, action.verify) + return WriteActionResult(verified, {}) + + +def apply_collapse_selection( + action: CollapseSelectionAction, + caret_info: UIACaretInfo, +) -> WriteActionResult: + target = caret_info.text_range.clone() + target.collapse_range(to_end=(action.edge == "end")) + + apply_change(caret_info, target) + verified = verify(caret_info, target, action.verify) + return WriteActionResult(verified, {"edge": action.edge}) + + +WriteAction = Union[ + MoveRelativeAction, + MoveAbsoluteAction, + SelectRelativeAction, + SelectAbsoluteAction, + SelectAllAction, + CollapseSelectionAction, +] + + +def apply_write(action: WriteAction, caret_info: UIACaretInfo) -> WriteActionResult: + match action: + case MoveRelativeAction(): + return apply_move_relative(action, caret_info) + case MoveAbsoluteAction(): + return apply_move_absolute(action, caret_info) + case SelectRelativeAction(): + return apply_select_relative(action, caret_info) + case SelectAbsoluteAction(): + return apply_select_absolute(action, caret_info) + case SelectAllAction(): + return apply_select_all(action, caret_info) + case CollapseSelectionAction(): + return apply_collapse_selection(action, caret_info) + case _: + raise TypeError(f"Unsupported action type: {type(action)!r}") diff --git a/src/windows_mcp/text_cursor/ranges.py b/src/windows_mcp/text_cursor/ranges.py new file mode 100644 index 00000000..4bac335c --- /dev/null +++ b/src/windows_mcp/text_cursor/ranges.py @@ -0,0 +1,98 @@ +"""Construct and apply UI Automation text ranges.""" + +from .models import RelativeOrigin +from .uia import UIACaretInfo, TextRange, TextRangeEndpoint, TextUnit + + +def get_origin_from_range( + caret_info: UIACaretInfo, + origin: RelativeOrigin, +) -> TextRange: + """Return the base position a move/select operation is measured from. + + A range has two endpoints, so `origin` selects which one to start from: + 'caret' (only valid for a degenerate range), 'selection_start', or + 'selection_end'. + """ + base = caret_info.text_range.clone() + + if origin == "caret": + if not base.is_degenerate(): + raise RuntimeError( + "The current range is a non-empty selection. " + "The TextPattern fallback does not reveal which endpoint " + "is the active caret. Use selection_start or selection_end." + ) + + return base + + base.collapse_range(to_end=(origin == "selection_end")) + return base + + +def document_position( + caret_info: UIACaretInfo, + offset: int, +) -> tuple[TextRange, int]: + """Build a degenerate range at `offset` characters from the document start.""" + # Get the range spanning the whole document. + target = caret_info.text_pattern.document_range() + # Collapse to its start endpoint. + target.collapse_range(to_end=False) + actual_moved = int(target.move(TextUnit.Character, offset)) + return target, actual_moved + + +def make_range( + start_marker: TextRange, + end_marker: TextRange, +) -> TextRange: + # s----------e + # ^ + target = start_marker.clone() + target.collapse_range(to_end=False) + + # s----------e + # ^----------^ + target.move_endpoint_by_range( + TextRangeEndpoint.End, + end_marker, + TextRangeEndpoint.Start, + ) + + # Check whether target's start endpoint has passed its end endpoint (> 0). + comparison = target.compare_endpoints( + TextRangeEndpoint.Start, + target, + TextRangeEndpoint.End, + ) + + if int(comparison) > 0: + raise RuntimeError("The calculated selection start is after its end.") + return target + + +def apply_change(caret_info: UIACaretInfo, target: TextRange): + caret_info.element.set_focus() + # Move() only modifies the local range. + # Select() requests the actual caret/selection change. + target.select() + + +def verify( + caret_info: UIACaretInfo, + target: TextRange, + need_verify: bool, +) -> bool | None: + """Verify the applied range matches `target` by reading the selection back. + + Returns None when verification was not requested. + """ + if not need_verify: + return None + + actual = caret_info.text_pattern.get_first_selection() + if actual is None: + return False + + return target == actual diff --git a/src/windows_mcp/text_cursor/service.py b/src/windows_mcp/text_cursor/service.py new file mode 100644 index 00000000..393f0f63 --- /dev/null +++ b/src/windows_mcp/text_cursor/service.py @@ -0,0 +1,116 @@ +"""Orchestrate TextCursor actions on the dedicated COM worker thread. + +Collaborators (find_caret_provider, make_snapshot, apply_write, +snapshot_position, execute_sync) are referenced by their module-level names so +that tests can substitute them with monkeypatch.setattr on this module. +""" + +from __future__ import annotations + +import asyncio + +from .errors import TextCursorVerificationError +from .models import CursorAction, CursorToolResult, GetInfoAction +from .discovery import find_caret_provider +from .operations import WriteAction, apply_write +from .snapshots import make_snapshot, snapshot_position +from .worker import EXECUTOR + + +def run_get_info(action: GetInfoAction) -> CursorToolResult: + """Read information about the focused caret or selection.""" + caret_info = find_caret_provider() + snapshot = make_snapshot(caret_info, action.context_chars) + + return CursorToolResult( + success=True, + mode=action.mode, + message="Caret information acquired.", + after=snapshot, + warnings=snapshot.warnings, + ) + + +def run_write(action: WriteAction) -> CursorToolResult: + """Apply a write action and report the resulting caret or selection.""" + caret_info = find_caret_provider() + + before = make_snapshot(caret_info, action.context_chars) + verified, target = apply_write(action, caret_info) + requested = action.model_dump( + exclude={ + "delay", + "context_chars", + "verify", + } + ) + + # Always reacquire the focused provider before reporting success or a + # verification mismatch. The values returned by TextRange.Move describe + # only the client-side target; `actual` must come from the real provider. + refreshed = find_caret_provider() + after = make_snapshot(refreshed, action.context_chars) + actual = snapshot_position(after) + + if verified is False: + raise TextCursorVerificationError( + "The provider accepted the operation, but read-back verification " + "showed that the real caret/selection did not match the calculated " + f"target. Requested: {requested}; target: {target}; actual: {actual}." + ) + + warnings = list(dict.fromkeys([*before.warnings, *after.warnings])) + + return CursorToolResult( + success=True, + mode=action.mode, + message="Operation applied.", + verified=verified, + requested=requested, + target=target, + actual=actual, + before=before, + after=after, + warnings=warnings, + ) + + +def execute_sync(action: CursorAction) -> CursorToolResult: + """Execute one action on the dedicated COM worker thread. + + COM is initialized once per worker thread and the UIA client is cached on + first use; there is no per-call CoInitialize/CoUninitialize or CreateObject. + """ + if isinstance(action, GetInfoAction): + return run_get_info(action) + + return run_write(action) + + +async def run_tool(action: CursorAction) -> CursorToolResult: + """ + Inspect or manipulate the focused Windows text control through UIA. + Modes: + - get_info + - move_relative + - move_absolute + - select_relative + - select_absolute + - select_all + - collapse_selection + Every mode accepts `delay`, expressed in seconds. The delay occurs before + the focused UIA element is located, so the caller can focus the target + control during that interval. + Absolute move/select inputs and returned offsets both use provider-defined + UIA TextUnit_Character steps from DocumentRange start. Returned offsets can + be passed directly to absolute move/select actions. + """ + if action.delay > 0: + await asyncio.sleep(action.delay) + + loop = asyncio.get_running_loop() + return await loop.run_in_executor( + EXECUTOR, + execute_sync, + action, + ) diff --git a/src/windows_mcp/text_cursor/snapshots.py b/src/windows_mcp/text_cursor/snapshots.py new file mode 100644 index 00000000..dc59cc85 --- /dev/null +++ b/src/windows_mcp/text_cursor/snapshots.py @@ -0,0 +1,138 @@ +"""Build serializable snapshots from UI Automation caret information.""" + +from __future__ import annotations + +from typing import Any, Optional + +from comtypes import COMError + +from .constants import MAX_SELECTED_TEXT_CHARS +from .errors import TextCursorError +from .models import CursorSnapshot +from .uia import UIACaretInfo, TextRangeEndpoint, range_start_offset + + +def endpoint_offset( + caret_info: UIACaretInfo, + endpoint: TextRangeEndpoint, +) -> Optional[int]: + """Return the character offset from the document start to the given + endpoint (Start or End) of the caret/selection range.""" + marker = caret_info.text_range.clone() + + if endpoint == TextRangeEndpoint.End: + marker.collapse_range(to_end=True) + else: + marker.collapse_range(to_end=False) + + return range_start_offset(marker) + + +def make_snapshot( + caret_info: UIACaretInfo, + context_chars: int, + *, + include_context: bool = True, + include_selected_text: bool = True, +) -> CursorSnapshot: + start = endpoint_offset(caret_info, TextRangeEndpoint.Start) + # A caret is a degenerate range: both endpoints resolve to the same offset, + # and only caret_offset_units (== start) is emitted. Computing the End + # endpoint would be a second full Move(-MAX) walk back to DocumentRange + # start for nothing, so reuse start. Only a real selection needs End. + # (The start-is-None short-circuit keeps the unavailable-offset error below + # from being masked by inspecting the range first.) + if start is not None and caret_info.exact_caret: + end = start + else: + end = endpoint_offset(caret_info, TextRangeEndpoint.End) + + if start is None or end is None: + raise TextCursorError( + "The provider did not allow calculating UIA TextUnit_Character offsets." + ) + + warnings: list[str] = [] + + selected_text = None + selected_text_truncated = False + if include_selected_text and not caret_info.exact_caret: + # Read one extra character so a selection sitting exactly on the limit + # is not mistaken for a truncated one. + try: + selected_text = caret_info.text_range.get_text(MAX_SELECTED_TEXT_CHARS + 1) + except COMError: + warnings.append( + "The provider did not allow reading selected_text. The field was omitted; " + "selection_start_units and selection_end_units are still available." + ) + else: + if len(selected_text) > MAX_SELECTED_TEXT_CHARS: + selected_text = selected_text[:MAX_SELECTED_TEXT_CHARS] + "…" + selected_text_truncated = True + + # --- Read the text on both sides of the caret/selection. --- + before = None + after = None + + if include_context: + try: + before = caret_info.text_range.text_before(context_chars) + except COMError: + warnings.append( + "The provider did not allow reading text_before. The field was omitted." + ) + + try: + after = caret_info.text_range.text_after(context_chars) + except COMError: + warnings.append("The provider did not allow reading text_after. The field was omitted.") + + if caret_info.selection_count > 1: + warnings.append( + f"TextPattern.GetSelection returned {caret_info.selection_count} " + "disjoint selections. TextCursor reports and uses only the first " + "selection; the remaining selections are ignored." + ) + + if not caret_info.exact_caret: + warnings.append( + "TextPattern.GetSelection returned a non-empty selection. " + "The active caret endpoint is unknown." + ) + + if selected_text_truncated: + warnings.append( + f"selected_text was truncated to {MAX_SELECTED_TEXT_CHARS} characters " + "(marked with a trailing ellipsis). The selection_start_units and " + "selection_end_units offsets still describe the full selection." + ) + + return CursorSnapshot( + provider=caret_info.source, + element_name=(caret_info.element.get_name() or None), + type="caret" if caret_info.exact_caret else "range", + caret_offset_units=start if caret_info.exact_caret else None, # caret only + selection_start_units=(start if not caret_info.exact_caret else None), # range only + selection_end_units=end if not caret_info.exact_caret else None, # range only + selected_text=selected_text, + text_before=before, + text_after=after, + bounding_rects=caret_info.text_range.bounding_rectangles(), + warnings=warnings, + ) + + +def snapshot_position(snapshot: CursorSnapshot) -> dict[str, Any]: + """Return only the real caret or selection coordinates from a snapshot.""" + if snapshot.type == "caret": + return { + "type": "caret", + "caret_offset_units": snapshot.caret_offset_units, + } + + return { + "type": "range", + "selection_start_units": snapshot.selection_start_units, + "selection_end_units": snapshot.selection_end_units, + } diff --git a/src/windows_mcp/text_cursor/uia.py b/src/windows_mcp/text_cursor/uia.py new file mode 100644 index 00000000..5a659bbd --- /dev/null +++ b/src/windows_mcp/text_cursor/uia.py @@ -0,0 +1,373 @@ +"""Low-level wrappers and helpers for Windows UI Automation text ranges.""" + +from __future__ import annotations + +import enum +from dataclasses import dataclass +from typing import Any, Optional + +import comtypes.client +from comtypes import COMError + +from .constants import ( + CLSID_CUIAUTOMATION, + CLSID_CUIAUTOMATION8, + MAX_TEXT_UNIT_MOVE, +) +from .models import ScreenRect + + +class UIAModule: + def __init__(self, raw_module): + self.raw_module = raw_module + + +class UIAAutomationObject: + def __init__(self, raw_obj): + self.raw_obj = raw_obj + + def get_focused_element(self) -> UIAElement | None: + raw_elem = self.raw_obj.GetFocusedElement() + if not raw_elem: + return None + return UIAElement(raw_elem, self.raw_obj) + + +def create_automation() -> tuple[UIAModule, UIAAutomationObject]: + """ + Load UIAutomationCore.dll type information and create the UIA client. + CUIAutomation8 is preferred; CUIAutomation is used as a compatibility fallback. + """ + raw_uia = comtypes.client.GetModule("UIAutomationCore.dll") + + try: + automation = comtypes.client.CreateObject( + CLSID_CUIAUTOMATION8, + interface=raw_uia.IUIAutomation, + ) + except COMError: + automation = comtypes.client.CreateObject( + CLSID_CUIAUTOMATION, + interface=raw_uia.IUIAutomation, + ) + + return UIAModule(raw_uia), UIAAutomationObject(automation) + + +class TextRangeEndpoint(enum.Enum): + """Mirrors the UIA TextPatternRangeEndpoint enumeration.""" + + Start = 0 # TextPatternRangeEndpoint_Start + End = 1 # TextPatternRangeEndpoint_End + + +class TextUnit(enum.Enum): + """Mirrors the UIA TextUnit enumeration.""" + + Character = 0 # TextUnit_Character + Format = 1 # TextUnit_Format + Word = 2 # TextUnit_Word + Line = 3 # TextUnit_Line + Paragraph = 4 # TextUnit_Paragraph + Page = 5 # TextUnit_Page + Document = 6 # TextUnit_Document + + +class UIAElement: + """A simple wrapper for UIA Element""" + + def __init__(self, raw_element: Any, raw_uia_object: Any): + self.raw_element = raw_element + self.raw_uia_obj = raw_uia_object + + def get_pattern(self, pattern_id: int, interface: Any) -> TextPattern | None: + """Return the requested control pattern wrapped as a TextPattern, or None.""" + try: + unknown = self.raw_element.GetCurrentPattern(pattern_id) + if not unknown: + return None + + raw_pattern = unknown.QueryInterface(interface) + if not raw_pattern: + return None + return TextPattern(raw_pattern) + + except (COMError, AttributeError, TypeError): + return None + + def get_name(self) -> str: + try: + return str(self.raw_element.CurrentName or "") + except (COMError, AttributeError): + return "" + + def parent(self) -> UIAElement | None: + walker = self.raw_uia_obj.RawViewWalker + try: + ret = walker.GetParentElement(self.raw_element) + except COMError: + ret = None + + if not ret: + return None + + return UIAElement(ret, self.raw_uia_obj) + + def set_focus(self): + self.raw_element.SetFocus() + + +class TextPattern: + """A simple wrapper for UIA TextPattern""" + + def __init__(self, raw_pattern): + self.raw_pattern = raw_pattern + + def get_selections(self) -> list[TextRange]: + try: + selections = self.raw_pattern.GetSelection() + + if not selections: + return [] + + if int(selections.Length) <= 0: + return [] + + return [ + TextRange(selections.GetElement(index)) for index in range(int(selections.Length)) + ] + + except (COMError, AttributeError, TypeError, ValueError): + return [] + + def get_first_selection(self) -> TextRange | None: + selections = self.get_selections() + if not selections: + return None + + return selections[0] + + def document_range(self) -> TextRange: + return TextRange(self.raw_pattern.DocumentRange) + + +class TextRange: + """A simple wrapper for UIA TextRange""" + + def __init__(self, raw_range): + self.raw_range = raw_range + + def clone(self) -> TextRange: + return TextRange(self.raw_range.Clone()) + + def move(self, unit: TextUnit, count: int) -> int: + """Moves the text range the specified number of TextUnit units within the document range. + Return the number of units actually moved + """ + return int(self.raw_range.Move(unit.value, count)) + + def select(self): + return self.raw_range.Select() + + def move_endpoint_by_range( + self, + src_endpoint: TextRangeEndpoint, + other: TextRange, + target_endpoint: TextRangeEndpoint, + ): + """Moves one endpoint of the current text range to the specified endpoint of a second text range.""" + self.raw_range.MoveEndpointByRange( + src_endpoint.value, + other.raw_range, + target_endpoint.value, + ) + + def move_endpoint_by_unit(self, endpoint: TextRangeEndpoint, unit: TextUnit, count: int) -> int: + """Moves one endpoint of the text range the specified number of TextUnit units within the document range. + Return the number of units actually moved + """ + return int( + self.raw_range.MoveEndpointByUnit( + endpoint.value, + unit.value, + count, + ) + ) + + def compare_endpoints( + self, + src_endpoint: TextRangeEndpoint, + other: TextRange, + target_endpoint: TextRangeEndpoint, + ) -> int: + return int( + self.raw_range.CompareEndpoints( + src_endpoint.value, + other.raw_range, + target_endpoint.value, + ) + ) + + def is_degenerate(self) -> bool: + comparison = self.compare_endpoints( + TextRangeEndpoint.Start, + self, + TextRangeEndpoint.End, + ) + + return int(comparison) == 0 + + def collapse_range(self, *, to_end: bool) -> None: + """Collapse the range to a single point, clearing any selection. + + to_end=False collapses to the start (left) endpoint; to_end=True + collapses to the end (right) endpoint. + """ + if to_end: + # Start --move-> End. + self.move_endpoint_by_range( + TextRangeEndpoint.Start, + self, + TextRangeEndpoint.End, + ) + else: + # End --move-> Start. + self.move_endpoint_by_range( + TextRangeEndpoint.End, + self, + TextRangeEndpoint.Start, + ) + + def get_text(self, max_length: int = -1) -> str: + return str(self.raw_range.GetText(max_length) or "") + + def text_before(self, count: int) -> str: + """Return up to `count` characters immediately before the range.""" + clone = self.clone() + + # Collapse to the start of the range. + clone.collapse_range(to_end=False) + + # Extend the start endpoint backward. + clone.move_endpoint_by_unit(TextRangeEndpoint.Start, TextUnit.Character, -count) + + return clone.get_text() + + def text_after(self, count: int) -> str: + """Return up to `count` characters immediately after the range.""" + clone = self.clone() + + # Collapse to the end of the range. + clone.collapse_range(to_end=True) + + # Extend the end endpoint forward. + clone.move_endpoint_by_unit(TextRangeEndpoint.End, TextUnit.Character, count) + + return clone.get_text() + + def bounding_rectangles(self, try_again_if_err: bool = True) -> list[ScreenRect]: + try: + # An array of bounding rectangles for each fully or partially + # visible line of text in the range. A selection can span multiple + # lines, so each line is returned as its own rectangle. The result + # is a flat tuple of (left, top, width, height) per rectangle: + # (x0, y0, w0, h0, x1, y1, w1, h1, ...). + values = self.raw_range.GetBoundingRectangles() # type: tuple[float, ...] + if values is None: + return [] + + flat = list(values) + + rects = [ + ScreenRect( + left=float(flat[index]), + top=float(flat[index + 1]), + width=float(flat[index + 2]), + height=float(flat[index + 3]), + ) + for index in range(0, len(flat) - 3, 4) + ] + if len(rects) == 0 and self.is_degenerate() and try_again_if_err: + # A caret (degenerate range) sometimes has no bounding + # rectangle; extend it by one character and try once more. + adjacent = self.clone() + moved = adjacent.move_endpoint_by_unit(TextRangeEndpoint.End, TextUnit.Character, 1) + + if int(moved) != 0: + return adjacent.bounding_rectangles(False) # avoid recursion + return rects + + except ( + COMError, + AttributeError, + TypeError, + ValueError, + ): + return [] + + # A TextRange has no stable value-based hash (its endpoints can move) and + # equality requires live COM calls, so keep instances explicitly + # unhashable. Python already does this once __eq__ is defined; stating it + # makes the intent obvious and guards against accidental set/dict use. + __hash__ = None + + def __eq__(self, other: object) -> bool: + if not isinstance(other, TextRange): + return NotImplemented + + try: + start_comparison = self.compare_endpoints( + TextRangeEndpoint.Start, + other, + TextRangeEndpoint.Start, + ) + + end_comparison = self.compare_endpoints( + TextRangeEndpoint.End, + other, + TextRangeEndpoint.End, + ) + except COMError: + # A stale range can no longer be compared; treat it as not equal + # rather than propagating the COM failure out of an equality check. + return False + + return int(start_comparison) == 0 and int(end_comparison) == 0 + + +@dataclass +class UIACaretInfo: + element: UIAElement + text_pattern: TextPattern + text_range: TextRange + source: str + exact_caret: bool + selection_count: int = 1 + + +def range_start_offset( + text_range: TextRange, +) -> Optional[int]: + """Return the range start in UIA TextUnit_Character steps. + + The offset is measured from DocumentRange start and uses the same + provider-defined coordinate system as absolute move/select actions. + + Cost note: UIA exposes no direct "character index" query, so the offset is + derived by moving a degenerate range back to DocumentRange start. Providers + that implement Move as a linear walk make this O(offset), i.e. proportional + to the distance from the document start. This is negligible for typical text + controls but can be noticeable in very large documents, so avoid high- + frequency polling there. + """ + try: + clone = text_range.clone() + clone.collapse_range(to_end=False) + # Walk back to the very start of the document. + moved = clone.move(TextUnit.Character, -MAX_TEXT_UNIT_MOVE) + # Moving backward returns a negative count, so negate it to get the + # positive offset from DocumentRange start. + return -moved + + except (COMError, AttributeError, TypeError): + return None diff --git a/src/windows_mcp/text_cursor/worker.py b/src/windows_mcp/text_cursor/worker.py new file mode 100644 index 00000000..18b70a13 --- /dev/null +++ b/src/windows_mcp/text_cursor/worker.py @@ -0,0 +1,69 @@ +"""Single-threaded COM worker state for TextCursor operations.""" + +import ctypes +import threading +from concurrent.futures import ThreadPoolExecutor + +from .uia import UIAAutomationObject, UIAModule, create_automation + +COINIT_MULTITHREADED = 0x0 +RPC_E_CHANGED_MODE = 0x80010106 + +EXECUTOR = ThreadPoolExecutor( + max_workers=1, + thread_name_prefix="uia-text-cursor", +) + +# Per-worker-thread COM state. The executor thread lives for the process +# lifetime, so COM is initialized once and the IUIAutomation client is cached +# and reused across calls instead of being recreated (and the apartment +# re-initialized) on every invocation. +_thread_state = threading.local() + + +def co_initialize_mta() -> bool: + ole32 = ctypes.windll.ole32 + + ole32.CoInitializeEx.argtypes = [ + ctypes.c_void_p, + ctypes.c_ulong, + ] + ole32.CoInitializeEx.restype = ctypes.c_long + + hr = int( + ole32.CoInitializeEx( + None, + COINIT_MULTITHREADED, + ) + ) + + unsigned_hr = hr & 0xFFFFFFFF + + if unsigned_hr == RPC_E_CHANGED_MODE: + raise RuntimeError("The UIA worker thread has an incompatible COM apartment.") + + if unsigned_hr & 0x80000000: + raise OSError(f"CoInitializeEx failed: 0x{unsigned_hr:08X}") + + return True + + +def get_thread_automation() -> tuple[UIAModule, UIAAutomationObject]: + """Return this worker thread's cached UIA client, creating it on first use. + + COM is initialized (MTA) exactly once per thread and the IUIAutomation + client is cached, so repeated calls reuse the same client rather than + paying for CoInitializeEx + CreateObject on every invocation. The client + is long-lived and never goes stale; only the focused element and its text + ranges are re-fetched per call. + """ + if not getattr(_thread_state, "com_initialized", False): + co_initialize_mta() + _thread_state.com_initialized = True + + automation = getattr(_thread_state, "automation", None) + if automation is None: + automation = create_automation() + _thread_state.automation = automation + + return automation diff --git a/tests/test_iserror_compliance.py b/tests/test_iserror_compliance.py index 52cbb89d..6bffed13 100644 --- a/tests/test_iserror_compliance.py +++ b/tests/test_iserror_compliance.py @@ -89,7 +89,7 @@ def _raise(*args, **kwargs): # noqa: ARG001 def test_text_cursor_tool_error_is_error_true(monkeypatch, mcp): """TextCursor UIA failures must surface as ToolError.""" - import windows_mcp.text_cursor as implementation + from windows_mcp.text_cursor import service from windows_mcp.tools.text_cursor import register as text_cursor_tool_reg text_cursor_tool_reg(mcp, get_desktop=lambda: None, get_analytics=lambda: None) @@ -98,9 +98,8 @@ def test_text_cursor_tool_error_is_error_true(monkeypatch, mcp): def _raise(*args, **kwargs): # noqa: ARG001 raise RuntimeError(error_msg) - monkeypatch.setattr(implementation, "co_initialize_mta", lambda: True) - monkeypatch.setattr(implementation, "co_uninitialize", lambda: None) - monkeypatch.setattr(implementation, "run_get_info", _raise) + # run_get_info raises before find_caret_provider, so no real COM init runs. + monkeypatch.setattr(service, "run_get_info", _raise) with pytest.raises(ToolError) as exc_info: asyncio.run( @@ -115,6 +114,7 @@ def _raise(*args, **kwargs): # noqa: ARG001 def test_text_cursor_verification_failure_raises(monkeypatch): """A failed write verification must not return a successful MCP payload.""" import windows_mcp.text_cursor as implementation + from windows_mcp.text_cursor import operations, service action = implementation.MoveAbsoluteAction(mode="move_absolute", offset=10) caret_info = object() @@ -130,23 +130,23 @@ def test_text_cursor_verification_failure_raises(monkeypatch): ) snapshots = iter([before, after]) - monkeypatch.setattr(implementation, "find_caret_provider", lambda: caret_info) + monkeypatch.setattr(service, "find_caret_provider", lambda: caret_info) monkeypatch.setattr( - implementation, + service, "make_snapshot", lambda *args, **kwargs: next(snapshots), ) monkeypatch.setattr( - implementation, + service, "apply_write", - lambda *args, **kwargs: implementation.WriteActionResult( + lambda *args, **kwargs: operations.WriteActionResult( False, {"target_offset_units": 5}, ), ) with pytest.raises(implementation.TextCursorVerificationError) as exc_info: - implementation.run_write(action) + service.run_write(action) assert "move_absolute" in str(exc_info.value) assert "target_offset_units" in str(exc_info.value) diff --git a/tests/test_text_cursor.py b/tests/test_text_cursor.py index 718ab83e..2ebb6bc7 100644 --- a/tests/test_text_cursor.py +++ b/tests/test_text_cursor.py @@ -2,9 +2,14 @@ from types import SimpleNamespace import pytest +from comtypes import COMError from pydantic import ValidationError import windows_mcp.text_cursor as text_cursor +from windows_mcp.text_cursor import ranges, service, snapshots +from windows_mcp.text_cursor.constants import MAX_SELECTED_TEXT_CHARS, MAX_TEXT_UNIT_MOVE +from windows_mcp.text_cursor.operations import WriteActionResult +from windows_mcp.text_cursor.uia import TextRange, TextRangeEndpoint, TextUnit from windows_mcp.tools.text_cursor import _description @@ -24,8 +29,8 @@ def collapse_range(self, *, to_end: bool) -> None: self.start = position self.end = position - def move(self, unit: text_cursor.TextUnit, count: int) -> int: - assert unit is text_cursor.TextUnit.Character + def move(self, unit: TextUnit, count: int) -> int: + assert unit is TextUnit.Character assert self.start == self.end target = min(max(self.start + count, 0), self.document_length) @@ -48,21 +53,21 @@ def document_range(self) -> FakeTextRange: [ ( text_cursor.MoveRelativeAction, - {"mode": "move_relative", "delta": text_cursor.MAX_TEXT_UNIT_MOVE + 1}, + {"mode": "move_relative", "delta": MAX_TEXT_UNIT_MOVE + 1}, ), ( text_cursor.MoveRelativeAction, - {"mode": "move_relative", "delta": -text_cursor.MAX_TEXT_UNIT_MOVE - 1}, + {"mode": "move_relative", "delta": -MAX_TEXT_UNIT_MOVE - 1}, ), ( text_cursor.MoveAbsoluteAction, - {"mode": "move_absolute", "offset": text_cursor.MAX_TEXT_UNIT_MOVE + 1}, + {"mode": "move_absolute", "offset": MAX_TEXT_UNIT_MOVE + 1}, ), ( text_cursor.SelectRelativeAction, { "mode": "select_relative", - "start_delta": -text_cursor.MAX_TEXT_UNIT_MOVE - 1, + "start_delta": -MAX_TEXT_UNIT_MOVE - 1, "end_delta": 0, }, ), @@ -71,15 +76,15 @@ def document_range(self) -> FakeTextRange: { "mode": "select_relative", "start_delta": 0, - "end_delta": text_cursor.MAX_TEXT_UNIT_MOVE + 1, + "end_delta": MAX_TEXT_UNIT_MOVE + 1, }, ), ( text_cursor.SelectAbsoluteAction, { "mode": "select_absolute", - "start": text_cursor.MAX_TEXT_UNIT_MOVE + 1, - "end": text_cursor.MAX_TEXT_UNIT_MOVE + 1, + "start": MAX_TEXT_UNIT_MOVE + 1, + "end": MAX_TEXT_UNIT_MOVE + 1, }, ), ], @@ -97,8 +102,8 @@ def test_character_offset_round_trips_through_document_position(): text_pattern=FakeTextPattern(document_length), ) - offset = text_cursor.endpoint_offset(info, text_cursor.TextRangeEndpoint.Start) - target, actual = text_cursor.document_position(info, offset) + offset = snapshots.endpoint_offset(info, TextRangeEndpoint.Start) + target, actual = ranges.document_position(info, offset) assert offset == position assert actual == position @@ -111,15 +116,15 @@ def test_selection_endpoint_offsets_use_character_units(): text_range=FakeTextRange(12, 34, 100), ) - assert text_cursor.endpoint_offset(info, text_cursor.TextRangeEndpoint.Start) == 12 - assert text_cursor.endpoint_offset(info, text_cursor.TextRangeEndpoint.End) == 34 + assert snapshots.endpoint_offset(info, TextRangeEndpoint.Start) == 12 + assert snapshots.endpoint_offset(info, TextRangeEndpoint.End) == 34 def test_snapshot_fails_when_character_offset_is_unavailable(monkeypatch): - monkeypatch.setattr(text_cursor, "endpoint_offset", lambda *args, **kwargs: None) + monkeypatch.setattr(snapshots, "endpoint_offset", lambda *args, **kwargs: None) with pytest.raises(text_cursor.TextCursorError, match="TextUnit_Character offsets"): - text_cursor.make_snapshot(object(), context_chars=40) + snapshots.make_snapshot(object(), context_chars=40) def test_snapshot_contract_names_character_unit_fields(): @@ -144,7 +149,7 @@ def test_tool_descriptions_use_character_units(): def test_text_range_get_text_normalizes_none_to_empty_text(): - text_range = text_cursor.TextRange( + text_range = TextRange( SimpleNamespace(GetText=lambda max_length: None), ) @@ -153,13 +158,13 @@ def test_text_range_get_text_normalizes_none_to_empty_text(): def test_text_range_get_text_propagates_com_error(): def fail_get_text(max_length): - raise text_cursor.COMError(-2147467259, "provider unavailable", None) + raise COMError(-2147467259, "provider unavailable", None) - text_range = text_cursor.TextRange( + text_range = TextRange( SimpleNamespace(GetText=fail_get_text), ) - with pytest.raises(text_cursor.COMError, match="provider unavailable"): + with pytest.raises(COMError, match="provider unavailable"): text_range.get_text() @@ -177,8 +182,8 @@ def fake_execute(action) -> None: nonlocal execute_called execute_called = True - monkeypatch.setattr(text_cursor.asyncio, "sleep", blocking_sleep) - monkeypatch.setattr(text_cursor, "execute_sync", fake_execute) + monkeypatch.setattr(service.asyncio, "sleep", blocking_sleep) + monkeypatch.setattr(service, "execute_sync", fake_execute) task = asyncio.create_task( text_cursor.run_tool(text_cursor.GetInfoAction(mode="get_info", delay=300)) @@ -206,12 +211,12 @@ def test_snapshot_warns_when_provider_returns_multiple_selections(monkeypatch): selection_count=3, ) monkeypatch.setattr( - text_cursor, + snapshots, "endpoint_offset", - lambda info, endpoint: 4 if endpoint is text_cursor.TextRangeEndpoint.Start else 12, + lambda info, endpoint: 4 if endpoint is TextRangeEndpoint.Start else 12, ) - snapshot = text_cursor.make_snapshot(caret_info, context_chars=40) + snapshot = snapshots.make_snapshot(caret_info, context_chars=40) assert snapshot.selection_start_units == 4 assert snapshot.selection_end_units == 12 @@ -220,7 +225,7 @@ def test_snapshot_warns_when_provider_returns_multiple_selections(monkeypatch): def test_snapshot_truncates_long_selected_text_with_ellipsis(monkeypatch): - limit = text_cursor.MAX_SELECTED_TEXT_CHARS + limit = MAX_SELECTED_TEXT_CHARS # The wrapper is asked for limit + 1 chars; the provider returns that many, # which signals the real selection is longer than the limit. caret_info = SimpleNamespace( @@ -236,12 +241,12 @@ def test_snapshot_truncates_long_selected_text_with_ellipsis(monkeypatch): selection_count=1, ) monkeypatch.setattr( - text_cursor, + snapshots, "endpoint_offset", - lambda info, endpoint: 0 if endpoint is text_cursor.TextRangeEndpoint.Start else 10_000, + lambda info, endpoint: 0 if endpoint is TextRangeEndpoint.Start else 10_000, ) - snapshot = text_cursor.make_snapshot(caret_info, context_chars=40) + snapshot = snapshots.make_snapshot(caret_info, context_chars=40) assert len(snapshot.selected_text) == limit + 1 # limit chars + ellipsis assert snapshot.selected_text.endswith("…") @@ -250,7 +255,7 @@ def test_snapshot_truncates_long_selected_text_with_ellipsis(monkeypatch): def test_snapshot_keeps_selected_text_at_limit_untruncated(monkeypatch): - limit = text_cursor.MAX_SELECTED_TEXT_CHARS + limit = MAX_SELECTED_TEXT_CHARS # A selection exactly at the limit: the provider returns fewer than the # requested limit + 1 chars, so it must not be flagged as truncated. caret_info = SimpleNamespace( @@ -266,12 +271,12 @@ def test_snapshot_keeps_selected_text_at_limit_untruncated(monkeypatch): selection_count=1, ) monkeypatch.setattr( - text_cursor, + snapshots, "endpoint_offset", - lambda info, endpoint: 0 if endpoint is text_cursor.TextRangeEndpoint.Start else limit, + lambda info, endpoint: 0 if endpoint is TextRangeEndpoint.Start else limit, ) - snapshot = text_cursor.make_snapshot(caret_info, context_chars=40) + snapshot = snapshots.make_snapshot(caret_info, context_chars=40) assert snapshot.selected_text == "b" * limit assert "…" not in snapshot.selected_text @@ -290,12 +295,12 @@ def test_snapshot_preserves_genuinely_empty_selected_text(monkeypatch): selection_count=1, ) monkeypatch.setattr( - text_cursor, + snapshots, "endpoint_offset", - lambda info, endpoint: 4 if endpoint is text_cursor.TextRangeEndpoint.Start else 12, + lambda info, endpoint: 4 if endpoint is TextRangeEndpoint.Start else 12, ) - snapshot = text_cursor.make_snapshot( + snapshot = snapshots.make_snapshot( caret_info, context_chars=40, include_context=False, @@ -307,7 +312,7 @@ def test_snapshot_preserves_genuinely_empty_selected_text(monkeypatch): def test_snapshot_omits_selected_text_and_warns_on_com_error(monkeypatch): def fail_get_text(max_length=-1): - raise text_cursor.COMError(-2147467259, "provider unavailable", None) + raise COMError(-2147467259, "provider unavailable", None) caret_info = SimpleNamespace( text_range=SimpleNamespace( @@ -320,12 +325,12 @@ def fail_get_text(max_length=-1): selection_count=1, ) monkeypatch.setattr( - text_cursor, + snapshots, "endpoint_offset", - lambda info, endpoint: 4 if endpoint is text_cursor.TextRangeEndpoint.Start else 12, + lambda info, endpoint: 4 if endpoint is TextRangeEndpoint.Start else 12, ) - snapshot = text_cursor.make_snapshot( + snapshot = snapshots.make_snapshot( caret_info, context_chars=40, include_context=False, @@ -339,7 +344,7 @@ def fail_get_text(max_length=-1): def test_snapshot_reads_context_fields_independently_on_com_error(monkeypatch): def fail_text_before(count): - raise text_cursor.COMError(-2147467259, "provider unavailable", None) + raise COMError(-2147467259, "provider unavailable", None) caret_info = SimpleNamespace( text_range=SimpleNamespace( @@ -352,9 +357,9 @@ def fail_text_before(count): exact_caret=True, selection_count=1, ) - monkeypatch.setattr(text_cursor, "endpoint_offset", lambda info, endpoint: 7) + monkeypatch.setattr(snapshots, "endpoint_offset", lambda info, endpoint: 7) - snapshot = text_cursor.make_snapshot(caret_info, context_chars=40) + snapshot = snapshots.make_snapshot(caret_info, context_chars=40) assert snapshot.caret_offset_units == 7 assert snapshot.text_before is None @@ -382,13 +387,13 @@ def fail_get_text(max_length=-1): selection_count=1, ) monkeypatch.setattr( - text_cursor, + snapshots, "endpoint_offset", - lambda info, endpoint: 4 if endpoint is text_cursor.TextRangeEndpoint.Start else 12, + lambda info, endpoint: 4 if endpoint is TextRangeEndpoint.Start else 12, ) with pytest.raises(error_type, match="broken text range wrapper"): - text_cursor.make_snapshot( + snapshots.make_snapshot( caret_info, context_chars=40, include_context=False, @@ -414,16 +419,16 @@ def test_run_write_propagates_warning_from_before_snapshot(monkeypatch): ) providers = iter([object(), object()]) - monkeypatch.setattr(text_cursor, "find_caret_provider", lambda: next(providers)) + monkeypatch.setattr(service, "find_caret_provider", lambda: next(providers)) snapshots = iter([before, after]) - monkeypatch.setattr(text_cursor, "make_snapshot", lambda *args, **kwargs: next(snapshots)) + monkeypatch.setattr(service, "make_snapshot", lambda *args, **kwargs: next(snapshots)) monkeypatch.setattr( - text_cursor, + service, "apply_write", - lambda action, caret_info: text_cursor.WriteActionResult(None, {}), + lambda action, caret_info: WriteActionResult(None, {}), ) - result = text_cursor.run_write( + result = service.run_write( text_cursor.MoveAbsoluteAction(mode="move_absolute", offset=2, verify=False) ) @@ -444,22 +449,22 @@ def test_run_write_distinguishes_target_from_read_back_actual(monkeypatch): providers = iter([object(), object()]) snapshots = iter([before, after]) - monkeypatch.setattr(text_cursor, "find_caret_provider", lambda: next(providers)) + monkeypatch.setattr(service, "find_caret_provider", lambda: next(providers)) monkeypatch.setattr( - text_cursor, + service, "make_snapshot", lambda *args, **kwargs: next(snapshots), ) monkeypatch.setattr( - text_cursor, + service, "apply_write", - lambda action, caret_info: text_cursor.WriteActionResult( + lambda action, caret_info: WriteActionResult( None, {"target_offset_units": 5}, ), ) - result = text_cursor.run_write( + result = service.run_write( text_cursor.MoveAbsoluteAction( mode="move_absolute", offset=5, @@ -482,7 +487,7 @@ def test_snapshot_position_reports_real_selection_coordinates(): selection_end_units=12, ) - assert text_cursor.snapshot_position(snapshot) == { + assert snapshots.snapshot_position(snapshot) == { "type": "range", "selection_start_units": 4, "selection_end_units": 12, From 74a5b54724e4ed2c7585b6b77d1ac3a7c60863ff Mon Sep 17 00:00:00 2001 From: Jeza Date: Fri, 24 Jul 2026 23:29:43 +0800 Subject: [PATCH 3/5] docs(text_cursor): document the TextCursor tool Add TextCursor to the README tool list and the manifest.json tools array, and update the README limitation about in-paragraph text selection now that TextCursor covers it for controls that expose the UIA TextPattern. --- README.md | 3 ++- manifest.json | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1e27faed..9f3f9bde 100755 --- a/README.md +++ b/README.md @@ -729,6 +729,7 @@ MCP Client can access the following tools to interact with Windows: - `Process`: List running processes or terminate them by PID or name. - `Notification`: Send a Windows toast notification with a title and message. - `Registry`: Read, write, delete, or list Windows Registry values and keys. +- `TextCursor`: Inspect or manipulate the caret/selection of the focused text control via UIA — read caret/selection info, move the caret (relative or absolute), select a text range, select all, or collapse a selection. Requires a control that exposes the UIA TextPattern. ## 🤝 Connect with Us @@ -793,7 +794,7 @@ For detailed information on what data is collected and how it is handled, please ## 📝 Limitations -- Selecting specific sections of the text in a paragraph, as the MCP is relying on a11y tree. (⌛ Working on it.) +- Selecting a specific section of text within a paragraph is supported by the `TextCursor` tool for controls that expose the UIA TextPattern; controls that do not expose it remain unsupported. - `Type-Tool` is meant for typing text, not programming in IDE because of it types program as a whole in a file. (⌛ Working on it.) - This MCP server can't be used to play video games 🎮. diff --git a/manifest.json b/manifest.json index 9bb66d05..9c22bc21 100755 --- a/manifest.json +++ b/manifest.json @@ -147,6 +147,10 @@ { "name": "Registry", "description": "Accesses the Windows Registry. Use mode=\"get\" to read a value, mode=\"set\" to create/update a value, mode=\"delete\" to remove a value or key, mode=\"list\" to list values and sub-keys under a path." + }, + { + "name": "TextCursor", + "description": "Inspects or manipulates the caret/selection of the focused Windows text control through UI Automation. Modes: 'get_info' (read caret/selection info), 'move_relative' and 'move_absolute' (move the caret by a signed delta or to an absolute character offset), 'select_relative' and 'select_absolute' (select a text range), 'select_all', 'collapse_selection' (collapse a selection to its start or end edge). Offsets use provider-defined UIA TextUnit_Character steps from the document start. Requires a control that exposes the UIA TextPattern." } ], "compatibility": { From 9809277cbeb4766da56debd26aeba3b1772de2e3 Mon Sep 17 00:00:00 2001 From: Jeza Date: Sat, 25 Jul 2026 12:31:33 +0800 Subject: [PATCH 4/5] fix(text_cursor): pass client context to analytics --- src/windows_mcp/tools/text_cursor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/windows_mcp/tools/text_cursor.py b/src/windows_mcp/tools/text_cursor.py index 3c939c37..7afc3a83 100644 --- a/src/windows_mcp/tools/text_cursor.py +++ b/src/windows_mcp/tools/text_cursor.py @@ -3,6 +3,7 @@ of the currently focused Windows text control through UI Automation """ +from fastmcp import Context from mcp.types import ToolAnnotations from windows_mcp.infrastructure import with_analytics from windows_mcp.text_cursor import CursorAction, CursorToolResult, run_tool @@ -41,5 +42,6 @@ def register(mcp, *, get_desktop, get_analytics): @with_analytics(get_analytics(), "TextCursor-Tool") async def text_cursor( action: CursorAction, + ctx: Context = None, ) -> CursorToolResult: return await run_tool(action) From 100ec45dc5b399e43f7680a5039d343189c89429 Mon Sep 17 00:00:00 2001 From: Jeza Date: Sun, 26 Jul 2026 22:53:47 +0800 Subject: [PATCH 5/5] refactor(text_cursor): reuse windows_mcp.uia instead of a parallel COM layer TextCursor previously reimplemented UIA text ranges/patterns and ran on a dedicated MTA worker with its own COM client. Consolidate onto the shared windows_mcp.uia: - Promote the reusable range helpers onto uia.TextRange/TextPattern (IsDegenerate, Collapse, GetStartOffset, GetTextBefore/After, GetFirstSelection) and normalize TextRange.GetText None -> "". - Delete text_cursor's TextRange/TextPattern/UIAElement wrappers, the dedicated MTA worker, and create_automation; run on the main-thread STA and reuse the shared client via GetFocusedControl / Control, like the other UIA tools. - Inline the one-shot execute_sync dispatch into run_tool (kept async so the delay stays non-blocking and cancellable). --- src/windows_mcp/text_cursor/constants.py | 5 - src/windows_mcp/text_cursor/discovery.py | 61 ++-- src/windows_mcp/text_cursor/operations.py | 20 +- src/windows_mcp/text_cursor/ranges.py | 58 ++-- src/windows_mcp/text_cursor/service.py | 29 +- src/windows_mcp/text_cursor/snapshots.py | 69 +++- src/windows_mcp/text_cursor/uia.py | 373 ---------------------- src/windows_mcp/text_cursor/worker.py | 69 ---- src/windows_mcp/uia/patterns.py | 76 ++++- tests/test_text_cursor.py | 140 +++++--- 10 files changed, 306 insertions(+), 594 deletions(-) delete mode 100644 src/windows_mcp/text_cursor/uia.py delete mode 100644 src/windows_mcp/text_cursor/worker.py diff --git a/src/windows_mcp/text_cursor/constants.py b/src/windows_mcp/text_cursor/constants.py index 7b13a6db..3b276001 100644 --- a/src/windows_mcp/text_cursor/constants.py +++ b/src/windows_mcp/text_cursor/constants.py @@ -1,10 +1,5 @@ """Constants used by the text cursor implementation.""" -UIA_TEXT_PATTERN_ID = 10014 - -CLSID_CUIAUTOMATION8 = "{E22AD333-B25F-460C-83D0-0581107395C9}" -CLSID_CUIAUTOMATION = "{FF48DBA4-60EF-4201-AA87-54103EEF594E}" - MAX_TEXT_UNIT_MOVE = 2_147_483_647 # Upper bound on the selected-text string embedded in a snapshot. Unlike the diff --git a/src/windows_mcp/text_cursor/discovery.py b/src/windows_mcp/text_cursor/discovery.py index dc767629..f90a2083 100644 --- a/src/windows_mcp/text_cursor/discovery.py +++ b/src/windows_mcp/text_cursor/discovery.py @@ -1,43 +1,51 @@ -"""Locate the focused UIA element that exposes the caret/selection TextPattern. +"""Locate the focused UIA control that exposes the caret/selection TextPattern. -This sits above the pure UIA wrappers in `uia` because it depends on the COM -worker thread (`worker.get_thread_automation`). Keeping it here lets `uia` stay -a leaf module that `worker` can import without forming a cycle. +Runs on the server's main-thread STA and reuses the shared `windows_mcp.uia` +client, so it can use `GetFocusedControl` and `Control` navigation directly. """ from __future__ import annotations -from typing import Optional +from dataclasses import dataclass from comtypes import COMError +from windows_mcp.uia import Control, GetFocusedControl, PatternId, TextPattern, TextRange -from .constants import UIA_TEXT_PATTERN_ID -from .uia import UIACaretInfo, UIAElement, UIAModule -from .worker import get_thread_automation +@dataclass +class UIACaretInfo: + """UIA provider and text range that describe the current caret or selection.""" -def try_get_caret_on_element(uia: UIAModule, element: UIAElement) -> Optional[UIACaretInfo]: + element: Control + text_pattern: TextPattern + text_range: TextRange + source: str + exact_caret: bool + selection_count: int = 1 + + +def try_get_caret_on_element(control: Control) -> UIACaretInfo | None: # Use TextPattern.GetSelection rather than TextPattern2.GetCaretRange: # GetCaretRange does not expose the actual selection range, so handling it # separately is not worth the effort. The first selection is kept as-is: # a degenerate range is treated as a caret (exact_caret=True), and a # non-empty one as a range whose active caret endpoint is unknown. - text_pattern = element.get_pattern( - UIA_TEXT_PATTERN_ID, - uia.raw_module.IUIAutomationTextPattern, - ) + text_pattern = control.GetPattern(PatternId.TextPattern) if text_pattern is not None: - selections = text_pattern.get_selections() + try: + selections = text_pattern.GetSelection() + except (COMError, AttributeError, TypeError, ValueError): + selections = [] if selections: selection = selections[0] return UIACaretInfo( - element=element, + element=control, text_pattern=text_pattern, text_range=selection, source="TextPattern.GetSelection", - exact_caret=selection.is_degenerate(), + exact_caret=selection.IsDegenerate(), selection_count=len(selections), ) @@ -46,31 +54,32 @@ def try_get_caret_on_element(uia: UIAModule, element: UIAElement) -> Optional[UI def find_caret_provider(max_parent_levels: int = 8) -> UIACaretInfo: """ - Start at the focused UIA element and walk up RawView parents. + Start at the focused UIA control and walk up RawView parents. Some controls expose TextPattern on an ancestor rather than on the exact focused child. """ - uia, automation = get_thread_automation() - - element = automation.get_focused_element() + control = GetFocusedControl() - if not element: + if control is None: raise RuntimeError("UI Automation returned no focused element.") - element_name = element.get_name() + try: + element_name = control.Name + except COMError: + element_name = "" tried_cnt = 0 - while element and tried_cnt < max_parent_levels + 1: + while control is not None and tried_cnt < max_parent_levels + 1: tried_cnt += 1 - result = try_get_caret_on_element(uia, element) + result = try_get_caret_on_element(control) if result is not None: return result try: - element = element.parent() + control = control.GetParentControl() except COMError: - element = None + control = None raise RuntimeError( f"The focused element {f'"{element_name}" ' if element_name else ''}and " diff --git a/src/windows_mcp/text_cursor/operations.py b/src/windows_mcp/text_cursor/operations.py index 5e2b0b1f..d0ae9395 100644 --- a/src/windows_mcp/text_cursor/operations.py +++ b/src/windows_mcp/text_cursor/operations.py @@ -4,6 +4,9 @@ from typing import Any, NamedTuple, Union +from windows_mcp.uia import TextUnit + +from .discovery import UIACaretInfo from .models import ( CollapseSelectionAction, MoveAbsoluteAction, @@ -19,7 +22,6 @@ make_range, verify, ) -from .uia import UIACaretInfo, TextUnit class WriteActionResult(NamedTuple): @@ -30,7 +32,7 @@ class WriteActionResult(NamedTuple): def apply_move_relative(action: MoveRelativeAction, caret_info: UIACaretInfo) -> WriteActionResult: # For a selection, resolve which endpoint the move is relative to. target = get_origin_from_range(caret_info, action.origin) - target_delta = target.move(TextUnit.Character, action.delta) + target_delta = target.Move(TextUnit.Character, action.delta, waitTime=0) apply_change(caret_info, target) verified = verify(caret_info, target, action.verify) return WriteActionResult(verified, {"target_delta": target_delta}) @@ -48,11 +50,11 @@ def apply_select_relative( ) -> WriteActionResult: origin = get_origin_from_range(caret_info, action.origin) - start_marker = origin.clone() - end_marker = origin.clone() + start_marker = origin.Clone() + end_marker = origin.Clone() - target_start_delta = start_marker.move(TextUnit.Character, action.start_delta) - target_end_delta = end_marker.move(TextUnit.Character, action.end_delta) + target_start_delta = start_marker.Move(TextUnit.Character, action.start_delta, waitTime=0) + target_end_delta = end_marker.Move(TextUnit.Character, action.end_delta, waitTime=0) target = make_range(start_marker, end_marker) @@ -93,7 +95,7 @@ def apply_select_all( action: SelectAllAction, caret_info: UIACaretInfo, ) -> WriteActionResult: - target = caret_info.text_pattern.document_range() + target = caret_info.text_pattern.DocumentRange apply_change(caret_info, target) verified = verify(caret_info, target, action.verify) return WriteActionResult(verified, {}) @@ -103,8 +105,8 @@ def apply_collapse_selection( action: CollapseSelectionAction, caret_info: UIACaretInfo, ) -> WriteActionResult: - target = caret_info.text_range.clone() - target.collapse_range(to_end=(action.edge == "end")) + target = caret_info.text_range.Clone() + target.Collapse(toEnd=(action.edge == "end")) apply_change(caret_info, target) verified = verify(caret_info, target, action.verify) diff --git a/src/windows_mcp/text_cursor/ranges.py b/src/windows_mcp/text_cursor/ranges.py index 4bac335c..67166011 100644 --- a/src/windows_mcp/text_cursor/ranges.py +++ b/src/windows_mcp/text_cursor/ranges.py @@ -1,7 +1,11 @@ """Construct and apply UI Automation text ranges.""" +from comtypes import COMError +from windows_mcp.uia import TextPatternRangeEndpoint, TextRange, TextUnit + +from .discovery import UIACaretInfo +from .errors import TextCursorError from .models import RelativeOrigin -from .uia import UIACaretInfo, TextRange, TextRangeEndpoint, TextUnit def get_origin_from_range( @@ -14,10 +18,10 @@ def get_origin_from_range( 'caret' (only valid for a degenerate range), 'selection_start', or 'selection_end'. """ - base = caret_info.text_range.clone() + base = caret_info.text_range.Clone() if origin == "caret": - if not base.is_degenerate(): + if not base.IsDegenerate(): raise RuntimeError( "The current range is a non-empty selection. " "The TextPattern fallback does not reveal which endpoint " @@ -26,7 +30,7 @@ def get_origin_from_range( return base - base.collapse_range(to_end=(origin == "selection_end")) + base.Collapse(toEnd=(origin == "selection_end")) return base @@ -36,10 +40,10 @@ def document_position( ) -> tuple[TextRange, int]: """Build a degenerate range at `offset` characters from the document start.""" # Get the range spanning the whole document. - target = caret_info.text_pattern.document_range() + target = caret_info.text_pattern.DocumentRange # Collapse to its start endpoint. - target.collapse_range(to_end=False) - actual_moved = int(target.move(TextUnit.Character, offset)) + target.Collapse(toEnd=False) + actual_moved = int(target.Move(TextUnit.Character, offset, waitTime=0)) return target, actual_moved @@ -49,22 +53,23 @@ def make_range( ) -> TextRange: # s----------e # ^ - target = start_marker.clone() - target.collapse_range(to_end=False) + target = start_marker.Clone() + target.Collapse(toEnd=False) # s----------e # ^----------^ - target.move_endpoint_by_range( - TextRangeEndpoint.End, + target.MoveEndpointByRange( + TextPatternRangeEndpoint.End, end_marker, - TextRangeEndpoint.Start, + TextPatternRangeEndpoint.Start, + waitTime=0, ) # Check whether target's start endpoint has passed its end endpoint (> 0). - comparison = target.compare_endpoints( - TextRangeEndpoint.Start, + comparison = target.CompareEndpoints( + TextPatternRangeEndpoint.Start, target, - TextRangeEndpoint.End, + TextPatternRangeEndpoint.End, ) if int(comparison) > 0: @@ -72,11 +77,14 @@ def make_range( return target -def apply_change(caret_info: UIACaretInfo, target: TextRange): - caret_info.element.set_focus() +def apply_change(caret_info: UIACaretInfo, target: TextRange) -> None: + if not caret_info.element.SetFocus(): + raise TextCursorError("Unable to focus the target text control.") + # Move() only modifies the local range. # Select() requests the actual caret/selection change. - target.select() + if not target.Select(waitTime=0): + raise TextCursorError("The provider did not accept the requested caret/selection change.") def verify( @@ -91,8 +99,14 @@ def verify( if not need_verify: return None - actual = caret_info.text_pattern.get_first_selection() - if actual is None: - return False + try: + actual = caret_info.text_pattern.GetFirstSelection() + if actual is None: + return False - return target == actual + # Compare() is True only when both ranges share the same endpoints. + return target.Compare(actual) + except COMError: + # A stale range can no longer be compared; treat it as a mismatch + # rather than propagating the COM failure out of verification. + return False diff --git a/src/windows_mcp/text_cursor/service.py b/src/windows_mcp/text_cursor/service.py index 393f0f63..f212a791 100644 --- a/src/windows_mcp/text_cursor/service.py +++ b/src/windows_mcp/text_cursor/service.py @@ -1,8 +1,9 @@ -"""Orchestrate TextCursor actions on the dedicated COM worker thread. +"""Orchestrate TextCursor actions on the server's main-thread STA. Collaborators (find_caret_provider, make_snapshot, apply_write, -snapshot_position, execute_sync) are referenced by their module-level names so -that tests can substitute them with monkeypatch.setattr on this module. +snapshot_position, run_get_info, run_write) are referenced by their +module-level names so that tests can substitute them with +monkeypatch.setattr on this module. """ from __future__ import annotations @@ -14,7 +15,6 @@ from .discovery import find_caret_provider from .operations import WriteAction, apply_write from .snapshots import make_snapshot, snapshot_position -from .worker import EXECUTOR def run_get_info(action: GetInfoAction) -> CursorToolResult: @@ -75,18 +75,6 @@ def run_write(action: WriteAction) -> CursorToolResult: ) -def execute_sync(action: CursorAction) -> CursorToolResult: - """Execute one action on the dedicated COM worker thread. - - COM is initialized once per worker thread and the UIA client is cached on - first use; there is no per-call CoInitialize/CoUninitialize or CreateObject. - """ - if isinstance(action, GetInfoAction): - return run_get_info(action) - - return run_write(action) - - async def run_tool(action: CursorAction) -> CursorToolResult: """ Inspect or manipulate the focused Windows text control through UIA. @@ -108,9 +96,6 @@ async def run_tool(action: CursorAction) -> CursorToolResult: if action.delay > 0: await asyncio.sleep(action.delay) - loop = asyncio.get_running_loop() - return await loop.run_in_executor( - EXECUTOR, - execute_sync, - action, - ) + if isinstance(action, GetInfoAction): + return run_get_info(action) + return run_write(action) diff --git a/src/windows_mcp/text_cursor/snapshots.py b/src/windows_mcp/text_cursor/snapshots.py index dc59cc85..3a5de057 100644 --- a/src/windows_mcp/text_cursor/snapshots.py +++ b/src/windows_mcp/text_cursor/snapshots.py @@ -5,27 +5,57 @@ from typing import Any, Optional from comtypes import COMError +from windows_mcp.uia import TextPatternRangeEndpoint, TextRange, TextUnit from .constants import MAX_SELECTED_TEXT_CHARS +from .discovery import UIACaretInfo from .errors import TextCursorError -from .models import CursorSnapshot -from .uia import UIACaretInfo, TextRangeEndpoint, range_start_offset +from .models import CursorSnapshot, ScreenRect def endpoint_offset( caret_info: UIACaretInfo, - endpoint: TextRangeEndpoint, + endpoint: TextPatternRangeEndpoint, ) -> Optional[int]: """Return the character offset from the document start to the given endpoint (Start or End) of the caret/selection range.""" - marker = caret_info.text_range.clone() - - if endpoint == TextRangeEndpoint.End: - marker.collapse_range(to_end=True) - else: - marker.collapse_range(to_end=False) + marker = caret_info.text_range.Clone() + marker.Collapse(toEnd=(endpoint == TextPatternRangeEndpoint.End)) + + try: + return marker.GetStartOffset() + except (COMError, AttributeError, TypeError): + return None + + +def bounding_screen_rects( + text_range: TextRange, + try_again_if_err: bool = True, +) -> list[ScreenRect]: + """Return the range's bounding boxes as `ScreenRect`s (one per visible line).""" + try: + rects = [ + ScreenRect( + left=float(rect.left), + top=float(rect.top), + width=float(rect.width()), + height=float(rect.height()), + ) + for rect in text_range.GetBoundingRectangles() + ] + if len(rects) == 0 and text_range.IsDegenerate() and try_again_if_err: + # A caret (degenerate range) sometimes has no bounding rectangle; + # extend it by one character and try once more. + adjacent = text_range.Clone() + moved = adjacent.MoveEndpointByUnit( + TextPatternRangeEndpoint.End, TextUnit.Character, 1, waitTime=0 + ) + if int(moved) != 0: + return bounding_screen_rects(adjacent, False) # avoid recursion + return rects - return range_start_offset(marker) + except (COMError, AttributeError, TypeError, ValueError): + return [] def make_snapshot( @@ -35,7 +65,7 @@ def make_snapshot( include_context: bool = True, include_selected_text: bool = True, ) -> CursorSnapshot: - start = endpoint_offset(caret_info, TextRangeEndpoint.Start) + start = endpoint_offset(caret_info, TextPatternRangeEndpoint.Start) # A caret is a degenerate range: both endpoints resolve to the same offset, # and only caret_offset_units (== start) is emitted. Computing the End # endpoint would be a second full Move(-MAX) walk back to DocumentRange @@ -45,7 +75,7 @@ def make_snapshot( if start is not None and caret_info.exact_caret: end = start else: - end = endpoint_offset(caret_info, TextRangeEndpoint.End) + end = endpoint_offset(caret_info, TextPatternRangeEndpoint.End) if start is None or end is None: raise TextCursorError( @@ -60,7 +90,7 @@ def make_snapshot( # Read one extra character so a selection sitting exactly on the limit # is not mistaken for a truncated one. try: - selected_text = caret_info.text_range.get_text(MAX_SELECTED_TEXT_CHARS + 1) + selected_text = caret_info.text_range.GetText(MAX_SELECTED_TEXT_CHARS + 1) except COMError: warnings.append( "The provider did not allow reading selected_text. The field was omitted; " @@ -77,14 +107,14 @@ def make_snapshot( if include_context: try: - before = caret_info.text_range.text_before(context_chars) + before = caret_info.text_range.GetTextBefore(context_chars) except COMError: warnings.append( "The provider did not allow reading text_before. The field was omitted." ) try: - after = caret_info.text_range.text_after(context_chars) + after = caret_info.text_range.GetTextAfter(context_chars) except COMError: warnings.append("The provider did not allow reading text_after. The field was omitted.") @@ -108,9 +138,14 @@ def make_snapshot( "selection_end_units offsets still describe the full selection." ) + try: + element_name = caret_info.element.Name or None + except COMError: + element_name = None + return CursorSnapshot( provider=caret_info.source, - element_name=(caret_info.element.get_name() or None), + element_name=element_name, type="caret" if caret_info.exact_caret else "range", caret_offset_units=start if caret_info.exact_caret else None, # caret only selection_start_units=(start if not caret_info.exact_caret else None), # range only @@ -118,7 +153,7 @@ def make_snapshot( selected_text=selected_text, text_before=before, text_after=after, - bounding_rects=caret_info.text_range.bounding_rectangles(), + bounding_rects=bounding_screen_rects(caret_info.text_range), warnings=warnings, ) diff --git a/src/windows_mcp/text_cursor/uia.py b/src/windows_mcp/text_cursor/uia.py deleted file mode 100644 index 5a659bbd..00000000 --- a/src/windows_mcp/text_cursor/uia.py +++ /dev/null @@ -1,373 +0,0 @@ -"""Low-level wrappers and helpers for Windows UI Automation text ranges.""" - -from __future__ import annotations - -import enum -from dataclasses import dataclass -from typing import Any, Optional - -import comtypes.client -from comtypes import COMError - -from .constants import ( - CLSID_CUIAUTOMATION, - CLSID_CUIAUTOMATION8, - MAX_TEXT_UNIT_MOVE, -) -from .models import ScreenRect - - -class UIAModule: - def __init__(self, raw_module): - self.raw_module = raw_module - - -class UIAAutomationObject: - def __init__(self, raw_obj): - self.raw_obj = raw_obj - - def get_focused_element(self) -> UIAElement | None: - raw_elem = self.raw_obj.GetFocusedElement() - if not raw_elem: - return None - return UIAElement(raw_elem, self.raw_obj) - - -def create_automation() -> tuple[UIAModule, UIAAutomationObject]: - """ - Load UIAutomationCore.dll type information and create the UIA client. - CUIAutomation8 is preferred; CUIAutomation is used as a compatibility fallback. - """ - raw_uia = comtypes.client.GetModule("UIAutomationCore.dll") - - try: - automation = comtypes.client.CreateObject( - CLSID_CUIAUTOMATION8, - interface=raw_uia.IUIAutomation, - ) - except COMError: - automation = comtypes.client.CreateObject( - CLSID_CUIAUTOMATION, - interface=raw_uia.IUIAutomation, - ) - - return UIAModule(raw_uia), UIAAutomationObject(automation) - - -class TextRangeEndpoint(enum.Enum): - """Mirrors the UIA TextPatternRangeEndpoint enumeration.""" - - Start = 0 # TextPatternRangeEndpoint_Start - End = 1 # TextPatternRangeEndpoint_End - - -class TextUnit(enum.Enum): - """Mirrors the UIA TextUnit enumeration.""" - - Character = 0 # TextUnit_Character - Format = 1 # TextUnit_Format - Word = 2 # TextUnit_Word - Line = 3 # TextUnit_Line - Paragraph = 4 # TextUnit_Paragraph - Page = 5 # TextUnit_Page - Document = 6 # TextUnit_Document - - -class UIAElement: - """A simple wrapper for UIA Element""" - - def __init__(self, raw_element: Any, raw_uia_object: Any): - self.raw_element = raw_element - self.raw_uia_obj = raw_uia_object - - def get_pattern(self, pattern_id: int, interface: Any) -> TextPattern | None: - """Return the requested control pattern wrapped as a TextPattern, or None.""" - try: - unknown = self.raw_element.GetCurrentPattern(pattern_id) - if not unknown: - return None - - raw_pattern = unknown.QueryInterface(interface) - if not raw_pattern: - return None - return TextPattern(raw_pattern) - - except (COMError, AttributeError, TypeError): - return None - - def get_name(self) -> str: - try: - return str(self.raw_element.CurrentName or "") - except (COMError, AttributeError): - return "" - - def parent(self) -> UIAElement | None: - walker = self.raw_uia_obj.RawViewWalker - try: - ret = walker.GetParentElement(self.raw_element) - except COMError: - ret = None - - if not ret: - return None - - return UIAElement(ret, self.raw_uia_obj) - - def set_focus(self): - self.raw_element.SetFocus() - - -class TextPattern: - """A simple wrapper for UIA TextPattern""" - - def __init__(self, raw_pattern): - self.raw_pattern = raw_pattern - - def get_selections(self) -> list[TextRange]: - try: - selections = self.raw_pattern.GetSelection() - - if not selections: - return [] - - if int(selections.Length) <= 0: - return [] - - return [ - TextRange(selections.GetElement(index)) for index in range(int(selections.Length)) - ] - - except (COMError, AttributeError, TypeError, ValueError): - return [] - - def get_first_selection(self) -> TextRange | None: - selections = self.get_selections() - if not selections: - return None - - return selections[0] - - def document_range(self) -> TextRange: - return TextRange(self.raw_pattern.DocumentRange) - - -class TextRange: - """A simple wrapper for UIA TextRange""" - - def __init__(self, raw_range): - self.raw_range = raw_range - - def clone(self) -> TextRange: - return TextRange(self.raw_range.Clone()) - - def move(self, unit: TextUnit, count: int) -> int: - """Moves the text range the specified number of TextUnit units within the document range. - Return the number of units actually moved - """ - return int(self.raw_range.Move(unit.value, count)) - - def select(self): - return self.raw_range.Select() - - def move_endpoint_by_range( - self, - src_endpoint: TextRangeEndpoint, - other: TextRange, - target_endpoint: TextRangeEndpoint, - ): - """Moves one endpoint of the current text range to the specified endpoint of a second text range.""" - self.raw_range.MoveEndpointByRange( - src_endpoint.value, - other.raw_range, - target_endpoint.value, - ) - - def move_endpoint_by_unit(self, endpoint: TextRangeEndpoint, unit: TextUnit, count: int) -> int: - """Moves one endpoint of the text range the specified number of TextUnit units within the document range. - Return the number of units actually moved - """ - return int( - self.raw_range.MoveEndpointByUnit( - endpoint.value, - unit.value, - count, - ) - ) - - def compare_endpoints( - self, - src_endpoint: TextRangeEndpoint, - other: TextRange, - target_endpoint: TextRangeEndpoint, - ) -> int: - return int( - self.raw_range.CompareEndpoints( - src_endpoint.value, - other.raw_range, - target_endpoint.value, - ) - ) - - def is_degenerate(self) -> bool: - comparison = self.compare_endpoints( - TextRangeEndpoint.Start, - self, - TextRangeEndpoint.End, - ) - - return int(comparison) == 0 - - def collapse_range(self, *, to_end: bool) -> None: - """Collapse the range to a single point, clearing any selection. - - to_end=False collapses to the start (left) endpoint; to_end=True - collapses to the end (right) endpoint. - """ - if to_end: - # Start --move-> End. - self.move_endpoint_by_range( - TextRangeEndpoint.Start, - self, - TextRangeEndpoint.End, - ) - else: - # End --move-> Start. - self.move_endpoint_by_range( - TextRangeEndpoint.End, - self, - TextRangeEndpoint.Start, - ) - - def get_text(self, max_length: int = -1) -> str: - return str(self.raw_range.GetText(max_length) or "") - - def text_before(self, count: int) -> str: - """Return up to `count` characters immediately before the range.""" - clone = self.clone() - - # Collapse to the start of the range. - clone.collapse_range(to_end=False) - - # Extend the start endpoint backward. - clone.move_endpoint_by_unit(TextRangeEndpoint.Start, TextUnit.Character, -count) - - return clone.get_text() - - def text_after(self, count: int) -> str: - """Return up to `count` characters immediately after the range.""" - clone = self.clone() - - # Collapse to the end of the range. - clone.collapse_range(to_end=True) - - # Extend the end endpoint forward. - clone.move_endpoint_by_unit(TextRangeEndpoint.End, TextUnit.Character, count) - - return clone.get_text() - - def bounding_rectangles(self, try_again_if_err: bool = True) -> list[ScreenRect]: - try: - # An array of bounding rectangles for each fully or partially - # visible line of text in the range. A selection can span multiple - # lines, so each line is returned as its own rectangle. The result - # is a flat tuple of (left, top, width, height) per rectangle: - # (x0, y0, w0, h0, x1, y1, w1, h1, ...). - values = self.raw_range.GetBoundingRectangles() # type: tuple[float, ...] - if values is None: - return [] - - flat = list(values) - - rects = [ - ScreenRect( - left=float(flat[index]), - top=float(flat[index + 1]), - width=float(flat[index + 2]), - height=float(flat[index + 3]), - ) - for index in range(0, len(flat) - 3, 4) - ] - if len(rects) == 0 and self.is_degenerate() and try_again_if_err: - # A caret (degenerate range) sometimes has no bounding - # rectangle; extend it by one character and try once more. - adjacent = self.clone() - moved = adjacent.move_endpoint_by_unit(TextRangeEndpoint.End, TextUnit.Character, 1) - - if int(moved) != 0: - return adjacent.bounding_rectangles(False) # avoid recursion - return rects - - except ( - COMError, - AttributeError, - TypeError, - ValueError, - ): - return [] - - # A TextRange has no stable value-based hash (its endpoints can move) and - # equality requires live COM calls, so keep instances explicitly - # unhashable. Python already does this once __eq__ is defined; stating it - # makes the intent obvious and guards against accidental set/dict use. - __hash__ = None - - def __eq__(self, other: object) -> bool: - if not isinstance(other, TextRange): - return NotImplemented - - try: - start_comparison = self.compare_endpoints( - TextRangeEndpoint.Start, - other, - TextRangeEndpoint.Start, - ) - - end_comparison = self.compare_endpoints( - TextRangeEndpoint.End, - other, - TextRangeEndpoint.End, - ) - except COMError: - # A stale range can no longer be compared; treat it as not equal - # rather than propagating the COM failure out of an equality check. - return False - - return int(start_comparison) == 0 and int(end_comparison) == 0 - - -@dataclass -class UIACaretInfo: - element: UIAElement - text_pattern: TextPattern - text_range: TextRange - source: str - exact_caret: bool - selection_count: int = 1 - - -def range_start_offset( - text_range: TextRange, -) -> Optional[int]: - """Return the range start in UIA TextUnit_Character steps. - - The offset is measured from DocumentRange start and uses the same - provider-defined coordinate system as absolute move/select actions. - - Cost note: UIA exposes no direct "character index" query, so the offset is - derived by moving a degenerate range back to DocumentRange start. Providers - that implement Move as a linear walk make this O(offset), i.e. proportional - to the distance from the document start. This is negligible for typical text - controls but can be noticeable in very large documents, so avoid high- - frequency polling there. - """ - try: - clone = text_range.clone() - clone.collapse_range(to_end=False) - # Walk back to the very start of the document. - moved = clone.move(TextUnit.Character, -MAX_TEXT_UNIT_MOVE) - # Moving backward returns a negative count, so negate it to get the - # positive offset from DocumentRange start. - return -moved - - except (COMError, AttributeError, TypeError): - return None diff --git a/src/windows_mcp/text_cursor/worker.py b/src/windows_mcp/text_cursor/worker.py deleted file mode 100644 index 18b70a13..00000000 --- a/src/windows_mcp/text_cursor/worker.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Single-threaded COM worker state for TextCursor operations.""" - -import ctypes -import threading -from concurrent.futures import ThreadPoolExecutor - -from .uia import UIAAutomationObject, UIAModule, create_automation - -COINIT_MULTITHREADED = 0x0 -RPC_E_CHANGED_MODE = 0x80010106 - -EXECUTOR = ThreadPoolExecutor( - max_workers=1, - thread_name_prefix="uia-text-cursor", -) - -# Per-worker-thread COM state. The executor thread lives for the process -# lifetime, so COM is initialized once and the IUIAutomation client is cached -# and reused across calls instead of being recreated (and the apartment -# re-initialized) on every invocation. -_thread_state = threading.local() - - -def co_initialize_mta() -> bool: - ole32 = ctypes.windll.ole32 - - ole32.CoInitializeEx.argtypes = [ - ctypes.c_void_p, - ctypes.c_ulong, - ] - ole32.CoInitializeEx.restype = ctypes.c_long - - hr = int( - ole32.CoInitializeEx( - None, - COINIT_MULTITHREADED, - ) - ) - - unsigned_hr = hr & 0xFFFFFFFF - - if unsigned_hr == RPC_E_CHANGED_MODE: - raise RuntimeError("The UIA worker thread has an incompatible COM apartment.") - - if unsigned_hr & 0x80000000: - raise OSError(f"CoInitializeEx failed: 0x{unsigned_hr:08X}") - - return True - - -def get_thread_automation() -> tuple[UIAModule, UIAAutomationObject]: - """Return this worker thread's cached UIA client, creating it on first use. - - COM is initialized (MTA) exactly once per thread and the IUIAutomation - client is cached, so repeated calls reuse the same client rather than - paying for CoInitializeEx + CreateObject on every invocation. The client - is long-lived and never goes stale; only the focused element and its text - ranges are re-fetched per call. - """ - if not getattr(_thread_state, "com_initialized", False): - co_initialize_mta() - _thread_state.com_initialized = True - - automation = getattr(_thread_state, "automation", None) - if automation is None: - automation = create_automation() - _thread_state.automation = automation - - return automation diff --git a/src/windows_mcp/uia/patterns.py b/src/windows_mcp/uia/patterns.py index dc6e87ab..c57e509b 100755 --- a/src/windows_mcp/uia/patterns.py +++ b/src/windows_mcp/uia/patterns.py @@ -1519,10 +1519,12 @@ def GetText(self, maxLength: int = -1) -> str: """ Call IUIAutomationTextRange::GetText. maxLength: int, the maximum length of the string to return, or -1 if no limit is required. - Return str, the plain text of the text range. + Return str, the plain text of the text range. A provider that yields no + text is normalized to an empty string so callers can treat the + result as a str unconditionally. Refer https://docs.microsoft.com/en-us/windows/win32/api/uiautomationclient/nf-uiautomationclient-iuiautomationtextrange-gettext """ - return self.textRange.GetText(maxLength) + return self.textRange.GetText(maxLength) or "" def Move(self, unit: int, count: int, waitTime: float = OPERATION_WAIT_TIME) -> int: """ @@ -1626,6 +1628,68 @@ def Select(self, waitTime: float = OPERATION_WAIT_TIME) -> bool: time.sleep(waitTime) return ret + def IsDegenerate(self) -> bool: + """ + Return bool, True if this is an empty (degenerate) range such as a caret, + i.e. its Start and End endpoints are at the same location. + A convenience built on CompareEndpoints(Start, self, End) == 0. + """ + return self.CompareEndpoints(TextPatternRangeEndpoint.Start, self, TextPatternRangeEndpoint.End) == 0 + + def Collapse(self, toEnd: bool = False, waitTime: float = 0.0) -> bool: + """ + Collapse the range to a single point (a degenerate range), discarding its span. + toEnd: bool, False collapses to the Start endpoint, True collapses to the End endpoint. + waitTime: float, defaults to 0 because this only manipulates the client-side range + and does not drive any UI. + Return bool, True if succeed otherwise False. + A convenience built on MoveEndpointByRange. + """ + if toEnd: + return self.MoveEndpointByRange( + TextPatternRangeEndpoint.Start, self, TextPatternRangeEndpoint.End, waitTime + ) + return self.MoveEndpointByRange( + TextPatternRangeEndpoint.End, self, TextPatternRangeEndpoint.Start, waitTime + ) + + def GetStartOffset(self, waitTime: float = 0.0) -> int: + """ + Return int, the offset of this range's Start endpoint from DocumentRange start, + in TextUnit.Character steps. Derived by walking a collapsed clone back to the + document start, so cost can be O(offset) on providers with a linear Move. + waitTime: float, defaults to 0 because this is a read-only query. + """ + clone = self.Clone() + clone.Collapse(toEnd=False, waitTime=waitTime) + # 0x7FFFFFFF (INT32 max) is larger than any real document, so a single + # backward Move lands on DocumentRange start; negate the (negative) + # moved count to get the positive offset. + moved = clone.Move(TextUnit.Character, -0x7FFFFFFF, waitTime) + return -moved + + def GetTextBefore(self, count: int, waitTime: float = 0.0) -> str: + """ + Return str, up to `count` characters immediately before this range's Start endpoint. + waitTime: float, defaults to 0 because this is a read-only query. + """ + clone = self.Clone() + clone.Collapse(toEnd=False, waitTime=waitTime) + clone.MoveEndpointByUnit( + TextPatternRangeEndpoint.Start, TextUnit.Character, -count, waitTime + ) + return clone.GetText() + + def GetTextAfter(self, count: int, waitTime: float = 0.0) -> str: + """ + Return str, up to `count` characters immediately after this range's End endpoint. + waitTime: float, defaults to 0 because this is a read-only query. + """ + clone = self.Clone() + clone.Collapse(toEnd=True, waitTime=waitTime) + clone.MoveEndpointByUnit(TextPatternRangeEndpoint.End, TextUnit.Character, count, waitTime) + return clone.GetText() + class TextChildPattern: def __init__(self, pattern=None): @@ -1721,6 +1785,14 @@ def GetSelection(self) -> List[TextRange]: return textRanges return [] + def GetFirstSelection(self) -> TextRange | None: + """ + Return `TextRange` or None, the first currently selected range, or None if the + control has no selection. A convenience over GetSelection()[0]. + """ + selections = self.GetSelection() + return selections[0] if selections else None + def GetVisibleRanges(self) -> List[TextRange]: """ Call IUIAutomationTextPattern::GetVisibleRanges. diff --git a/tests/test_text_cursor.py b/tests/test_text_cursor.py index 2ebb6bc7..a791d730 100644 --- a/tests/test_text_cursor.py +++ b/tests/test_text_cursor.py @@ -4,32 +4,36 @@ import pytest from comtypes import COMError from pydantic import ValidationError +from windows_mcp.uia import TextPatternRangeEndpoint, TextRange, TextUnit import windows_mcp.text_cursor as text_cursor from windows_mcp.text_cursor import ranges, service, snapshots from windows_mcp.text_cursor.constants import MAX_SELECTED_TEXT_CHARS, MAX_TEXT_UNIT_MOVE from windows_mcp.text_cursor.operations import WriteActionResult -from windows_mcp.text_cursor.uia import TextRange, TextRangeEndpoint, TextUnit from windows_mcp.tools.text_cursor import _description class FakeTextRange: - """Minimal in-memory text range for offset calculations.""" + """Minimal in-memory text range for offset calculations. + + Duck-types the subset of the `windows_mcp.uia.TextRange` API that the + offset helpers exercise. + """ def __init__(self, start: int, end: int, document_length: int) -> None: self.start = start self.end = end self.document_length = document_length - def clone(self) -> "FakeTextRange": + def Clone(self) -> "FakeTextRange": return FakeTextRange(self.start, self.end, self.document_length) - def collapse_range(self, *, to_end: bool) -> None: - position = self.end if to_end else self.start + def Collapse(self, toEnd: bool = False, waitTime: float = 0.0) -> None: + position = self.end if toEnd else self.start self.start = position self.end = position - def move(self, unit: TextUnit, count: int) -> int: + def Move(self, unit: TextUnit, count: int, waitTime: float = 0.0) -> int: assert unit is TextUnit.Character assert self.start == self.end @@ -39,12 +43,16 @@ def move(self, unit: TextUnit, count: int) -> int: self.end = target return moved + def GetStartOffset(self, waitTime: float = 0.0) -> int: + return self.start + class FakeTextPattern: def __init__(self, document_length: int) -> None: self.document_length = document_length - def document_range(self) -> FakeTextRange: + @property + def DocumentRange(self) -> FakeTextRange: return FakeTextRange(0, self.document_length, self.document_length) @@ -102,7 +110,7 @@ def test_character_offset_round_trips_through_document_position(): text_pattern=FakeTextPattern(document_length), ) - offset = snapshots.endpoint_offset(info, TextRangeEndpoint.Start) + offset = snapshots.endpoint_offset(info, TextPatternRangeEndpoint.Start) target, actual = ranges.document_position(info, offset) assert offset == position @@ -116,8 +124,8 @@ def test_selection_endpoint_offsets_use_character_units(): text_range=FakeTextRange(12, 34, 100), ) - assert snapshots.endpoint_offset(info, TextRangeEndpoint.Start) == 12 - assert snapshots.endpoint_offset(info, TextRangeEndpoint.End) == 34 + assert snapshots.endpoint_offset(info, TextPatternRangeEndpoint.Start) == 12 + assert snapshots.endpoint_offset(info, TextPatternRangeEndpoint.End) == 34 def test_snapshot_fails_when_character_offset_is_unavailable(monkeypatch): @@ -153,7 +161,7 @@ def test_text_range_get_text_normalizes_none_to_empty_text(): SimpleNamespace(GetText=lambda max_length: None), ) - assert text_range.get_text() == "" + assert text_range.GetText() == "" def test_text_range_get_text_propagates_com_error(): @@ -165,11 +173,45 @@ def fail_get_text(max_length): ) with pytest.raises(COMError, match="provider unavailable"): - text_range.get_text() + text_range.GetText() + + +def test_apply_change_stops_when_focus_fails(): + select_called = False + + def select(*, waitTime: float) -> bool: + nonlocal select_called + select_called = True + return True + + caret_info = SimpleNamespace(element=SimpleNamespace(SetFocus=lambda: False)) + target = SimpleNamespace(Select=select) + + with pytest.raises(text_cursor.TextCursorError, match="focus"): + ranges.apply_change(caret_info, target) + + assert select_called is False + + +def test_apply_change_fails_when_provider_rejects_selection(): + caret_info = SimpleNamespace(element=SimpleNamespace(SetFocus=lambda: True)) + target = SimpleNamespace(Select=lambda *, waitTime: False) + + with pytest.raises(text_cursor.TextCursorError, match="did not accept"): + ranges.apply_change(caret_info, target) + + +def test_verify_returns_false_when_selection_read_back_fails(): + def fail_get_selection(): + raise COMError(-2147467259, "provider unavailable", None) + + caret_info = SimpleNamespace(text_pattern=SimpleNamespace(GetFirstSelection=fail_get_selection)) + + assert ranges.verify(caret_info, object(), need_verify=True) is False @pytest.mark.asyncio -async def test_cancelled_delay_does_not_reach_com_worker(monkeypatch): +async def test_cancelled_delay_does_not_reach_com(monkeypatch): sleep_started = asyncio.Event() execute_called = False @@ -178,12 +220,12 @@ async def blocking_sleep(delay: float) -> None: sleep_started.set() await asyncio.Event().wait() - def fake_execute(action) -> None: + def fake_get_info(action) -> None: nonlocal execute_called execute_called = True monkeypatch.setattr(service.asyncio, "sleep", blocking_sleep) - monkeypatch.setattr(service, "execute_sync", fake_execute) + monkeypatch.setattr(service, "run_get_info", fake_get_info) task = asyncio.create_task( text_cursor.run_tool(text_cursor.GetInfoAction(mode="get_info", delay=300)) @@ -200,12 +242,12 @@ def fake_execute(action) -> None: def test_snapshot_warns_when_provider_returns_multiple_selections(monkeypatch): caret_info = SimpleNamespace( text_range=SimpleNamespace( - get_text=lambda max_length=-1: "selected", - text_before=lambda count: "before", - text_after=lambda count: "after", - bounding_rectangles=lambda: [], + GetText=lambda max_length=-1: "selected", + GetTextBefore=lambda count: "before", + GetTextAfter=lambda count: "after", + GetBoundingRectangles=lambda: [], ), - element=SimpleNamespace(get_name=lambda: "editor"), + element=SimpleNamespace(Name="editor"), source="TextPattern.GetSelection", exact_caret=False, selection_count=3, @@ -213,7 +255,7 @@ def test_snapshot_warns_when_provider_returns_multiple_selections(monkeypatch): monkeypatch.setattr( snapshots, "endpoint_offset", - lambda info, endpoint: 4 if endpoint is TextRangeEndpoint.Start else 12, + lambda info, endpoint: 4 if endpoint is TextPatternRangeEndpoint.Start else 12, ) snapshot = snapshots.make_snapshot(caret_info, context_chars=40) @@ -230,12 +272,12 @@ def test_snapshot_truncates_long_selected_text_with_ellipsis(monkeypatch): # which signals the real selection is longer than the limit. caret_info = SimpleNamespace( text_range=SimpleNamespace( - get_text=lambda max_length=-1: "a" * max_length, - text_before=lambda count: "", - text_after=lambda count: "", - bounding_rectangles=lambda: [], + GetText=lambda max_length=-1: "a" * max_length, + GetTextBefore=lambda count: "", + GetTextAfter=lambda count: "", + GetBoundingRectangles=lambda: [], ), - element=SimpleNamespace(get_name=lambda: "editor"), + element=SimpleNamespace(Name="editor"), source="TextPattern.GetSelection", exact_caret=False, selection_count=1, @@ -243,7 +285,7 @@ def test_snapshot_truncates_long_selected_text_with_ellipsis(monkeypatch): monkeypatch.setattr( snapshots, "endpoint_offset", - lambda info, endpoint: 0 if endpoint is TextRangeEndpoint.Start else 10_000, + lambda info, endpoint: 0 if endpoint is TextPatternRangeEndpoint.Start else 10_000, ) snapshot = snapshots.make_snapshot(caret_info, context_chars=40) @@ -260,12 +302,12 @@ def test_snapshot_keeps_selected_text_at_limit_untruncated(monkeypatch): # requested limit + 1 chars, so it must not be flagged as truncated. caret_info = SimpleNamespace( text_range=SimpleNamespace( - get_text=lambda max_length=-1: "b" * limit, - text_before=lambda count: "", - text_after=lambda count: "", - bounding_rectangles=lambda: [], + GetText=lambda max_length=-1: "b" * limit, + GetTextBefore=lambda count: "", + GetTextAfter=lambda count: "", + GetBoundingRectangles=lambda: [], ), - element=SimpleNamespace(get_name=lambda: "editor"), + element=SimpleNamespace(Name="editor"), source="TextPattern.GetSelection", exact_caret=False, selection_count=1, @@ -273,7 +315,7 @@ def test_snapshot_keeps_selected_text_at_limit_untruncated(monkeypatch): monkeypatch.setattr( snapshots, "endpoint_offset", - lambda info, endpoint: 0 if endpoint is TextRangeEndpoint.Start else limit, + lambda info, endpoint: 0 if endpoint is TextPatternRangeEndpoint.Start else limit, ) snapshot = snapshots.make_snapshot(caret_info, context_chars=40) @@ -286,10 +328,10 @@ def test_snapshot_keeps_selected_text_at_limit_untruncated(monkeypatch): def test_snapshot_preserves_genuinely_empty_selected_text(monkeypatch): caret_info = SimpleNamespace( text_range=SimpleNamespace( - get_text=lambda max_length=-1: "", - bounding_rectangles=lambda: [], + GetText=lambda max_length=-1: "", + GetBoundingRectangles=lambda: [], ), - element=SimpleNamespace(get_name=lambda: "editor"), + element=SimpleNamespace(Name="editor"), source="TextPattern.GetSelection", exact_caret=False, selection_count=1, @@ -297,7 +339,7 @@ def test_snapshot_preserves_genuinely_empty_selected_text(monkeypatch): monkeypatch.setattr( snapshots, "endpoint_offset", - lambda info, endpoint: 4 if endpoint is TextRangeEndpoint.Start else 12, + lambda info, endpoint: 4 if endpoint is TextPatternRangeEndpoint.Start else 12, ) snapshot = snapshots.make_snapshot( @@ -316,10 +358,10 @@ def fail_get_text(max_length=-1): caret_info = SimpleNamespace( text_range=SimpleNamespace( - get_text=fail_get_text, - bounding_rectangles=lambda: [], + GetText=fail_get_text, + GetBoundingRectangles=lambda: [], ), - element=SimpleNamespace(get_name=lambda: "editor"), + element=SimpleNamespace(Name="editor"), source="TextPattern.GetSelection", exact_caret=False, selection_count=1, @@ -327,7 +369,7 @@ def fail_get_text(max_length=-1): monkeypatch.setattr( snapshots, "endpoint_offset", - lambda info, endpoint: 4 if endpoint is TextRangeEndpoint.Start else 12, + lambda info, endpoint: 4 if endpoint is TextPatternRangeEndpoint.Start else 12, ) snapshot = snapshots.make_snapshot( @@ -348,11 +390,11 @@ def fail_text_before(count): caret_info = SimpleNamespace( text_range=SimpleNamespace( - text_before=fail_text_before, - text_after=lambda count: "after", - bounding_rectangles=lambda: [], + GetTextBefore=fail_text_before, + GetTextAfter=lambda count: "after", + GetBoundingRectangles=lambda: [], ), - element=SimpleNamespace(get_name=lambda: "editor"), + element=SimpleNamespace(Name="editor"), source="TextPattern.GetSelection", exact_caret=True, selection_count=1, @@ -378,10 +420,10 @@ def fail_get_text(max_length=-1): caret_info = SimpleNamespace( text_range=SimpleNamespace( - get_text=fail_get_text, - bounding_rectangles=lambda: [], + GetText=fail_get_text, + GetBoundingRectangles=lambda: [], ), - element=SimpleNamespace(get_name=lambda: "editor"), + element=SimpleNamespace(Name="editor"), source="TextPattern.GetSelection", exact_caret=False, selection_count=1, @@ -389,7 +431,7 @@ def fail_get_text(max_length=-1): monkeypatch.setattr( snapshots, "endpoint_offset", - lambda info, endpoint: 4 if endpoint is TextRangeEndpoint.Start else 12, + lambda info, endpoint: 4 if endpoint is TextPatternRangeEndpoint.Start else 12, ) with pytest.raises(error_type, match="broken text range wrapper"):