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": { diff --git a/src/windows_mcp/text_cursor/__init__.py b/src/windows_mcp/text_cursor/__init__.py new file mode 100644 index 00000000..b91bd415 --- /dev/null +++ b/src/windows_mcp/text_cursor/__init__.py @@ -0,0 +1,59 @@ +#!/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 +discovery.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 + +from .errors import TextCursorError, TextCursorVerificationError +from .models import ( + CollapseSelectionAction, + CursorAction, + CursorSnapshot, + CursorToolResult, + GetInfoAction, + MoveAbsoluteAction, + MoveRelativeAction, + SelectAbsoluteAction, + SelectAllAction, + SelectRelativeAction, +) +from .service import run_tool + +__all__ = [ + "CollapseSelectionAction", + "CursorAction", + "CursorSnapshot", + "CursorToolResult", + "GetInfoAction", + "MoveAbsoluteAction", + "MoveRelativeAction", + "SelectAbsoluteAction", + "SelectAllAction", + "SelectRelativeAction", + "TextCursorError", + "TextCursorVerificationError", + "run_tool", +] diff --git a/src/windows_mcp/text_cursor/constants.py b/src/windows_mcp/text_cursor/constants.py new file mode 100644 index 00000000..3b276001 --- /dev/null +++ b/src/windows_mcp/text_cursor/constants.py @@ -0,0 +1,8 @@ +"""Constants used by the text cursor implementation.""" + +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..f90a2083 --- /dev/null +++ b/src/windows_mcp/text_cursor/discovery.py @@ -0,0 +1,87 @@ +"""Locate the focused UIA control that exposes the caret/selection TextPattern. + +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 dataclasses import dataclass + +from comtypes import COMError +from windows_mcp.uia import Control, GetFocusedControl, PatternId, TextPattern, TextRange + + +@dataclass +class UIACaretInfo: + """UIA provider and text range that describe the current caret or selection.""" + + 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 = control.GetPattern(PatternId.TextPattern) + + if text_pattern is not None: + try: + selections = text_pattern.GetSelection() + except (COMError, AttributeError, TypeError, ValueError): + selections = [] + + if selections: + selection = selections[0] + return UIACaretInfo( + element=control, + text_pattern=text_pattern, + text_range=selection, + source="TextPattern.GetSelection", + exact_caret=selection.IsDegenerate(), + selection_count=len(selections), + ) + + return None + + +def find_caret_provider(max_parent_levels: int = 8) -> UIACaretInfo: + """ + 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. + """ + control = GetFocusedControl() + + if control is None: + raise RuntimeError("UI Automation returned no focused element.") + + try: + element_name = control.Name + except COMError: + element_name = "" + + tried_cnt = 0 + while control is not None and tried_cnt < max_parent_levels + 1: + tried_cnt += 1 + result = try_get_caret_on_element(control) + + if result is not None: + return result + + try: + control = control.GetParentControl() + except COMError: + control = 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..d0ae9395 --- /dev/null +++ b/src/windows_mcp/text_cursor/operations.py @@ -0,0 +1,141 @@ +"""Implement TextCursor write modes against UI Automation ranges.""" + +from __future__ import annotations + +from typing import Any, NamedTuple, Union + +from windows_mcp.uia import TextUnit + +from .discovery import UIACaretInfo +from .models import ( + CollapseSelectionAction, + MoveAbsoluteAction, + MoveRelativeAction, + SelectAbsoluteAction, + SelectAllAction, + SelectRelativeAction, +) +from .ranges import ( + apply_change, + document_position, + get_origin_from_range, + make_range, + verify, +) + + +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, waitTime=0) + 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, waitTime=0) + target_end_delta = end_marker.Move(TextUnit.Character, action.end_delta, waitTime=0) + + 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.DocumentRange + 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(toEnd=(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..67166011 --- /dev/null +++ b/src/windows_mcp/text_cursor/ranges.py @@ -0,0 +1,112 @@ +"""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 + + +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.IsDegenerate(): + 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(toEnd=(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.DocumentRange + # Collapse to its start endpoint. + target.Collapse(toEnd=False) + actual_moved = int(target.Move(TextUnit.Character, offset, waitTime=0)) + return target, actual_moved + + +def make_range( + start_marker: TextRange, + end_marker: TextRange, +) -> TextRange: + # s----------e + # ^ + target = start_marker.Clone() + target.Collapse(toEnd=False) + + # s----------e + # ^----------^ + target.MoveEndpointByRange( + TextPatternRangeEndpoint.End, + end_marker, + TextPatternRangeEndpoint.Start, + waitTime=0, + ) + + # Check whether target's start endpoint has passed its end endpoint (> 0). + comparison = target.CompareEndpoints( + TextPatternRangeEndpoint.Start, + target, + TextPatternRangeEndpoint.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) -> 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. + if not target.Select(waitTime=0): + raise TextCursorError("The provider did not accept the requested caret/selection change.") + + +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 + + try: + actual = caret_info.text_pattern.GetFirstSelection() + if actual is None: + return False + + # 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 new file mode 100644 index 00000000..f212a791 --- /dev/null +++ b/src/windows_mcp/text_cursor/service.py @@ -0,0 +1,101 @@ +"""Orchestrate TextCursor actions on the server's main-thread STA. + +Collaborators (find_caret_provider, make_snapshot, apply_write, +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 + +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 + + +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, + ) + + +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) + + 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 new file mode 100644 index 00000000..3a5de057 --- /dev/null +++ b/src/windows_mcp/text_cursor/snapshots.py @@ -0,0 +1,173 @@ +"""Build serializable snapshots from UI Automation caret information.""" + +from __future__ import annotations + +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, ScreenRect + + +def endpoint_offset( + caret_info: UIACaretInfo, + 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() + 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 + + except (COMError, AttributeError, TypeError, ValueError): + return [] + + +def make_snapshot( + caret_info: UIACaretInfo, + context_chars: int, + *, + include_context: bool = True, + include_selected_text: bool = True, +) -> CursorSnapshot: + 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 + # 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, TextPatternRangeEndpoint.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.GetText(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.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.GetTextAfter(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." + ) + + try: + element_name = caret_info.element.Name or None + except COMError: + element_name = None + + return CursorSnapshot( + provider=caret_info.source, + 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 + 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=bounding_screen_rects(caret_info.text_range), + 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/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..7afc3a83 --- /dev/null +++ b/src/windows_mcp/tools/text_cursor.py @@ -0,0 +1,47 @@ +""" +TextCursor tool — inspecting and manipulating the caret/selection +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 + +_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, + ctx: Context = None, + ) -> CursorToolResult: + return await run_tool(action) 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_iserror_compliance.py b/tests/test_iserror_compliance.py index 8d82418d..6bffed13 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.""" + 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) + error_msg = "synthetic UIA failure" + + def _raise(*args, **kwargs): # noqa: ARG001 + raise RuntimeError(error_msg) + + # 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( + 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 + from windows_mcp.text_cursor import operations, service + + 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(service, "find_caret_provider", lambda: caret_info) + monkeypatch.setattr( + service, + "make_snapshot", + lambda *args, **kwargs: next(snapshots), + ) + monkeypatch.setattr( + service, + "apply_write", + lambda *args, **kwargs: operations.WriteActionResult( + False, + {"target_offset_units": 5}, + ), + ) + + with pytest.raises(implementation.TextCursorVerificationError) as exc_info: + service.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..a791d730 --- /dev/null +++ b/tests/test_text_cursor.py @@ -0,0 +1,536 @@ +import asyncio +from types import SimpleNamespace + +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.tools.text_cursor import _description + + +class FakeTextRange: + """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": + return FakeTextRange(self.start, self.end, self.document_length) + + 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, waitTime: float = 0.0) -> int: + assert unit is 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 + + 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 + + @property + def DocumentRange(self) -> FakeTextRange: + return FakeTextRange(0, self.document_length, self.document_length) + + +@pytest.mark.parametrize( + ("model", "values"), + [ + ( + text_cursor.MoveRelativeAction, + {"mode": "move_relative", "delta": MAX_TEXT_UNIT_MOVE + 1}, + ), + ( + text_cursor.MoveRelativeAction, + {"mode": "move_relative", "delta": -MAX_TEXT_UNIT_MOVE - 1}, + ), + ( + text_cursor.MoveAbsoluteAction, + {"mode": "move_absolute", "offset": MAX_TEXT_UNIT_MOVE + 1}, + ), + ( + text_cursor.SelectRelativeAction, + { + "mode": "select_relative", + "start_delta": -MAX_TEXT_UNIT_MOVE - 1, + "end_delta": 0, + }, + ), + ( + text_cursor.SelectRelativeAction, + { + "mode": "select_relative", + "start_delta": 0, + "end_delta": MAX_TEXT_UNIT_MOVE + 1, + }, + ), + ( + text_cursor.SelectAbsoluteAction, + { + "mode": "select_absolute", + "start": MAX_TEXT_UNIT_MOVE + 1, + "end": 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 = snapshots.endpoint_offset(info, TextPatternRangeEndpoint.Start) + target, actual = ranges.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 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): + monkeypatch.setattr(snapshots, "endpoint_offset", lambda *args, **kwargs: None) + + with pytest.raises(text_cursor.TextCursorError, match="TextUnit_Character offsets"): + snapshots.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 = TextRange( + SimpleNamespace(GetText=lambda max_length: None), + ) + + assert text_range.GetText() == "" + + +def test_text_range_get_text_propagates_com_error(): + def fail_get_text(max_length): + raise COMError(-2147467259, "provider unavailable", None) + + text_range = TextRange( + SimpleNamespace(GetText=fail_get_text), + ) + + with pytest.raises(COMError, match="provider unavailable"): + 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(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_get_info(action) -> None: + nonlocal execute_called + execute_called = True + + monkeypatch.setattr(service.asyncio, "sleep", blocking_sleep) + 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)) + ) + 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( + GetText=lambda max_length=-1: "selected", + GetTextBefore=lambda count: "before", + GetTextAfter=lambda count: "after", + GetBoundingRectangles=lambda: [], + ), + element=SimpleNamespace(Name="editor"), + source="TextPattern.GetSelection", + exact_caret=False, + selection_count=3, + ) + monkeypatch.setattr( + snapshots, + "endpoint_offset", + lambda info, endpoint: 4 if endpoint is TextPatternRangeEndpoint.Start else 12, + ) + + snapshot = snapshots.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 = 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( + GetText=lambda max_length=-1: "a" * max_length, + GetTextBefore=lambda count: "", + GetTextAfter=lambda count: "", + GetBoundingRectangles=lambda: [], + ), + element=SimpleNamespace(Name="editor"), + source="TextPattern.GetSelection", + exact_caret=False, + selection_count=1, + ) + monkeypatch.setattr( + snapshots, + "endpoint_offset", + lambda info, endpoint: 0 if endpoint is TextPatternRangeEndpoint.Start else 10_000, + ) + + snapshot = snapshots.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 = 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( + GetText=lambda max_length=-1: "b" * limit, + GetTextBefore=lambda count: "", + GetTextAfter=lambda count: "", + GetBoundingRectangles=lambda: [], + ), + element=SimpleNamespace(Name="editor"), + source="TextPattern.GetSelection", + exact_caret=False, + selection_count=1, + ) + monkeypatch.setattr( + snapshots, + "endpoint_offset", + lambda info, endpoint: 0 if endpoint is TextPatternRangeEndpoint.Start else limit, + ) + + snapshot = snapshots.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( + GetText=lambda max_length=-1: "", + GetBoundingRectangles=lambda: [], + ), + element=SimpleNamespace(Name="editor"), + source="TextPattern.GetSelection", + exact_caret=False, + selection_count=1, + ) + monkeypatch.setattr( + snapshots, + "endpoint_offset", + lambda info, endpoint: 4 if endpoint is TextPatternRangeEndpoint.Start else 12, + ) + + snapshot = snapshots.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 COMError(-2147467259, "provider unavailable", None) + + caret_info = SimpleNamespace( + text_range=SimpleNamespace( + GetText=fail_get_text, + GetBoundingRectangles=lambda: [], + ), + element=SimpleNamespace(Name="editor"), + source="TextPattern.GetSelection", + exact_caret=False, + selection_count=1, + ) + monkeypatch.setattr( + snapshots, + "endpoint_offset", + lambda info, endpoint: 4 if endpoint is TextPatternRangeEndpoint.Start else 12, + ) + + snapshot = snapshots.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 COMError(-2147467259, "provider unavailable", None) + + caret_info = SimpleNamespace( + text_range=SimpleNamespace( + GetTextBefore=fail_text_before, + GetTextAfter=lambda count: "after", + GetBoundingRectangles=lambda: [], + ), + element=SimpleNamespace(Name="editor"), + source="TextPattern.GetSelection", + exact_caret=True, + selection_count=1, + ) + monkeypatch.setattr(snapshots, "endpoint_offset", lambda info, endpoint: 7) + + snapshot = snapshots.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( + GetText=fail_get_text, + GetBoundingRectangles=lambda: [], + ), + element=SimpleNamespace(Name="editor"), + source="TextPattern.GetSelection", + exact_caret=False, + selection_count=1, + ) + monkeypatch.setattr( + snapshots, + "endpoint_offset", + lambda info, endpoint: 4 if endpoint is TextPatternRangeEndpoint.Start else 12, + ) + + with pytest.raises(error_type, match="broken text range wrapper"): + snapshots.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(service, "find_caret_provider", lambda: next(providers)) + snapshots = iter([before, after]) + monkeypatch.setattr(service, "make_snapshot", lambda *args, **kwargs: next(snapshots)) + monkeypatch.setattr( + service, + "apply_write", + lambda action, caret_info: WriteActionResult(None, {}), + ) + + result = service.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(service, "find_caret_provider", lambda: next(providers)) + monkeypatch.setattr( + service, + "make_snapshot", + lambda *args, **kwargs: next(snapshots), + ) + monkeypatch.setattr( + service, + "apply_write", + lambda action, caret_info: WriteActionResult( + None, + {"target_offset_units": 5}, + ), + ) + + result = service.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 snapshots.snapshot_position(snapshot) == { + "type": "range", + "selection_start_units": 4, + "selection_end_units": 12, + }