Skip to content

API Reference

This section uses mkdocstrings so signatures and docstrings are read from the current source code.

Common entry points:

Object Page
Desktop Desktop
Application Application
Window Window
Locator Locator
Keyboard and Mouse Keyboard, Mouse
Clipboard, FileDialog, MessageBox Clipboard, Dialogs
ImageLocator and Screen ImageLocator / Screen
JavaAccessBridge, ExcelApp, WordApp Java, Office
pytest plugin and CLI pytest Plugin, CLI Reference

Public Package API

The package exports these names through dolphin_desktop.__all__.

dolphin_desktop

dolphin — Playwright-like testing library for Windows desktop applications.

AliasNotFoundError

Bases: DolphinError

Raised when an alias is not found in the Object Repository.

Source code in src\dolphin_desktop\_exceptions.py
class AliasNotFoundError(DolphinError):
    """Raised when an alias is not found in the Object Repository."""

Application

Represents a running desktop application.

Do not instantiate directly — use :meth:Desktop.launch or :meth:Desktop.connect.

Usage::

with desktop.launch("notepad.exe") as app:
    win = app.window(title_re=".*Notepad")
    win.get_by_role("Edit").type_text("hello")
Source code in src\dolphin_desktop\_application.py
class Application:
    """Represents a running desktop application.

    Do not instantiate directly — use :meth:`Desktop.launch` or
    :meth:`Desktop.connect`.

    Usage::

        with desktop.launch("notepad.exe") as app:
            win = app.window(title_re=".*Notepad")
            win.get_by_role("Edit").type_text("hello")
    """

    def __init__(self, app: _PyWinApp, backend: str) -> None:
        self._app = app
        self._backend = backend
        try:
            _live_pids.add(self.process_id)
        except Exception:
            pass

    # ------------------------------------------------------------------
    # Window accessors
    # ------------------------------------------------------------------

    def window(
        self,
        alias: str | None = None,
        *,
        title: str | None = None,
        title_re: str | None = None,
        class_name: str | None = None,
        auto_id: str | None = None,
        found_index: int = 0,
        timeout: float = 10.0,
    ) -> Window:
        """Return a :class:`Window` matching the given criteria or Object Repository alias.

        Waits up to *timeout* seconds for the window to become visible.

        Args:
            alias: Optional Object Repository alias. When given, the alias is
                resolved to pywinauto criteria and the returned :class:`Window`
                stores the alias name so that :meth:`Window.element` can look up
                child elements from the same YAML entry.
        """
        if alias is not None:
            from .objects import _repository

            entry = _repository.resolve(alias)
            criteria: dict[str, Any] = dict(entry.selector)
        else:
            criteria = {"found_index": found_index}
            if title is not None:
                criteria["title"] = title
            if title_re is not None:
                criteria["title_re"] = title_re
            if class_name is not None:
                criteria["class_name"] = class_name
            if auto_id is not None:
                criteria["auto_id"] = auto_id

        win = self._find_window(criteria, timeout)
        if alias is not None:
            win._alias = alias  # type: ignore[attr-defined]
        return win

    def _find_window(self, criteria: dict[str, Any], timeout: float) -> Window:
        # First pass: search within this application's own process.
        try:
            spec = self._app.window(**criteria)
            spec.wait("visible", timeout=timeout * 0.6)
            return Window(spec)
        except Exception:
            pass

        # Fallback: single-instance apps (e.g. Windows 11 Notepad) hand off to
        # an existing process, so the window's PID differs from the launched one.
        # Search the entire desktop with the same criteria.
        try:
            spec = _PwDesktop(backend=self._backend).window(**criteria)
            spec.wait("visible", timeout=timeout * 0.4)
            # Reconnect self._app to the actual hosting process so that kill()
            # targets the right PID (not the dead launcher).
            try:
                actual_pid = spec.wrapper_object().process_id()
                old_pid = self.process_id
                self._app = _PyWinApp(backend=self._backend)
                self._app.connect(process=actual_pid)
                _live_pids.discard(old_pid)
                _live_pids.add(actual_pid)
            except Exception:
                pass
            return Window(spec)
        except Exception as exc:
            raise WindowNotFoundError(
                f"Window {criteria!r} not found after {timeout}s "
                f"(searched app process and entire desktop)"
            ) from exc

    def top_window(self) -> Window:
        """Return the topmost window of this application."""
        spec = self._app.top_window()
        return Window(spec)

    def windows(self) -> list[Window]:
        """Return all top-level windows belonging to this application."""
        return [Window(w) for w in self._app.windows()]

    # ------------------------------------------------------------------
    # Process info
    # ------------------------------------------------------------------

    @property
    def process_id(self) -> int:
        return self._app.process  # type: ignore[return-value]

    # ------------------------------------------------------------------
    # Lifecycle
    # ------------------------------------------------------------------

    def close(self, timeout: float = 5.0) -> None:
        """Ask the application to close gracefully."""
        try:
            _live_pids.discard(self.process_id)
        except Exception:
            pass
        try:
            self._app.kill(soft=True)
        except Exception:
            pass

    def kill(self) -> None:
        """Forcefully terminate the application process."""
        try:
            _live_pids.discard(self.process_id)
        except Exception:
            pass
        self._app.kill(soft=False)

    def wait_for_idle(self, timeout: float = 10.0) -> None:
        self._app.wait_cpu_usage_lower(threshold=0.5, timeout=timeout)

    # ------------------------------------------------------------------
    # Context manager — ensures cleanup even on test failure
    # ------------------------------------------------------------------

    def __enter__(self) -> Application:
        return self

    def __exit__(self, *_: object) -> None:
        self.kill()

    # ------------------------------------------------------------------
    # Electron / Chromium / WebView2 detection
    # ------------------------------------------------------------------

    def is_webview2(self) -> bool:
        """Return ``True`` if this application hosts an Edge WebView2 control.

        Detection checks whether the process has loaded ``WebView2Loader.dll``,
        which is present in every app built with the Microsoft WebView2 SDK.

        Returns ``False`` on any OS error, never raises.
        """
        return _webview2_by_modules(self.process_id)

    def is_electron(self) -> bool:
        """Return ``True`` if this application is built on the Electron/Chromium runtime.

        Detection is done in two passes:

        1. **Window class** — Electron/Chromium main windows have class
           ``Chrome_WidgetWin_1``.  This is fast and works without elevated
           privileges.
        2. **Module signatures** — if window enumeration finds nothing (e.g.
           called before the first window appears), the process module list is
           checked for ``vk_swiftshader``, ``libglesv2`` or ``electron``
           DLLs that ship with every Electron build.

        Returns ``False`` on any error so callers never need to guard against
        exceptions.
        """
        pid = self.process_id
        return _electron_by_window_class(pid) or _electron_by_modules(pid)

    def is_cef(self) -> bool:
        """Return ``True`` if this application uses the Chromium Embedded Framework (CEF).

        Detection checks for ``libcef.dll`` in the process module list while
        excluding Electron-specific DLLs (``vk_swiftshader``, ``libglesv2``,
        ``electron``).  Because Electron is itself built on CEF, both would
        carry ``libcef.dll`` — the absence of Electron modules distinguishes a
        standalone CEF app (Spotify, Steam, Battle.net, etc.) from Electron.

        Call this method after the app's main window has loaded — CEF DLLs are
        loaded lazily during UI initialization.

        .. note::
            UIA accessibility coverage for CEF apps varies widely.  Apps that
            expose ``--force-renderer-accessibility`` (or enable it internally)
            provide a richer UIA tree.  Spotify and Steam use bitmap rendering
            for most UI, leaving only the window frame and a few controls in
            the UIA tree.  See ``docs/cef.md`` for workarounds.

        Returns ``False`` on any OS error, never raises.
        """
        return _cef_by_modules(self.process_id)

    def is_legacy_ie(self) -> bool:
        """Return ``True`` if this application hosts an IE/Trident (MSHTML) browser.

        Detection checks whether the process has loaded ``mshtml.dll``, the
        MSHTML rendering engine used by the WPF ``WebBrowser`` control and
        older IE-based components.  This DLL is present in every WPF app that
        instantiates a ``WebBrowser`` control, regardless of the URL loaded.

        .. note::
            IE/Trident is End-of-Life since June 2022.  The WPF ``WebBrowser``
            control still works in .NET 4.8 and partially in .NET 8 (Windows),
            but Microsoft no longer ships security patches for MSHTML.
            For new projects use Edge WebView2 (see ``docs/webview2.md``).

        Returns ``False`` on any OS error, never raises.
        """
        return _legacy_ie_by_modules(self.process_id)

    def __repr__(self) -> str:
        return f"Application(pid={self.process_id}, backend={self._backend!r})"

window

window(
    alias: str | None = None,
    *,
    title: str | None = None,
    title_re: str | None = None,
    class_name: str | None = None,
    auto_id: str | None = None,
    found_index: int = 0,
    timeout: float = 10.0,
) -> Window

Return a :class:Window matching the given criteria or Object Repository alias.

Waits up to timeout seconds for the window to become visible.

Args: alias: Optional Object Repository alias. When given, the alias is resolved to pywinauto criteria and the returned :class:Window stores the alias name so that :meth:Window.element can look up child elements from the same YAML entry.

Source code in src\dolphin_desktop\_application.py
def window(
    self,
    alias: str | None = None,
    *,
    title: str | None = None,
    title_re: str | None = None,
    class_name: str | None = None,
    auto_id: str | None = None,
    found_index: int = 0,
    timeout: float = 10.0,
) -> Window:
    """Return a :class:`Window` matching the given criteria or Object Repository alias.

    Waits up to *timeout* seconds for the window to become visible.

    Args:
        alias: Optional Object Repository alias. When given, the alias is
            resolved to pywinauto criteria and the returned :class:`Window`
            stores the alias name so that :meth:`Window.element` can look up
            child elements from the same YAML entry.
    """
    if alias is not None:
        from .objects import _repository

        entry = _repository.resolve(alias)
        criteria: dict[str, Any] = dict(entry.selector)
    else:
        criteria = {"found_index": found_index}
        if title is not None:
            criteria["title"] = title
        if title_re is not None:
            criteria["title_re"] = title_re
        if class_name is not None:
            criteria["class_name"] = class_name
        if auto_id is not None:
            criteria["auto_id"] = auto_id

    win = self._find_window(criteria, timeout)
    if alias is not None:
        win._alias = alias  # type: ignore[attr-defined]
    return win

top_window

top_window() -> Window

Return the topmost window of this application.

Source code in src\dolphin_desktop\_application.py
def top_window(self) -> Window:
    """Return the topmost window of this application."""
    spec = self._app.top_window()
    return Window(spec)

windows

windows() -> list[Window]

Return all top-level windows belonging to this application.

Source code in src\dolphin_desktop\_application.py
def windows(self) -> list[Window]:
    """Return all top-level windows belonging to this application."""
    return [Window(w) for w in self._app.windows()]

close

close(timeout: float = 5.0) -> None

Ask the application to close gracefully.

Source code in src\dolphin_desktop\_application.py
def close(self, timeout: float = 5.0) -> None:
    """Ask the application to close gracefully."""
    try:
        _live_pids.discard(self.process_id)
    except Exception:
        pass
    try:
        self._app.kill(soft=True)
    except Exception:
        pass

kill

kill() -> None

Forcefully terminate the application process.

Source code in src\dolphin_desktop\_application.py
def kill(self) -> None:
    """Forcefully terminate the application process."""
    try:
        _live_pids.discard(self.process_id)
    except Exception:
        pass
    self._app.kill(soft=False)

is_webview2

is_webview2() -> bool

Return True if this application hosts an Edge WebView2 control.

Detection checks whether the process has loaded WebView2Loader.dll, which is present in every app built with the Microsoft WebView2 SDK.

Returns False on any OS error, never raises.

Source code in src\dolphin_desktop\_application.py
def is_webview2(self) -> bool:
    """Return ``True`` if this application hosts an Edge WebView2 control.

    Detection checks whether the process has loaded ``WebView2Loader.dll``,
    which is present in every app built with the Microsoft WebView2 SDK.

    Returns ``False`` on any OS error, never raises.
    """
    return _webview2_by_modules(self.process_id)

is_electron

is_electron() -> bool

Return True if this application is built on the Electron/Chromium runtime.

Detection is done in two passes:

  1. Window class — Electron/Chromium main windows have class Chrome_WidgetWin_1. This is fast and works without elevated privileges.
  2. Module signatures — if window enumeration finds nothing (e.g. called before the first window appears), the process module list is checked for vk_swiftshader, libglesv2 or electron DLLs that ship with every Electron build.

Returns False on any error so callers never need to guard against exceptions.

Source code in src\dolphin_desktop\_application.py
def is_electron(self) -> bool:
    """Return ``True`` if this application is built on the Electron/Chromium runtime.

    Detection is done in two passes:

    1. **Window class** — Electron/Chromium main windows have class
       ``Chrome_WidgetWin_1``.  This is fast and works without elevated
       privileges.
    2. **Module signatures** — if window enumeration finds nothing (e.g.
       called before the first window appears), the process module list is
       checked for ``vk_swiftshader``, ``libglesv2`` or ``electron``
       DLLs that ship with every Electron build.

    Returns ``False`` on any error so callers never need to guard against
    exceptions.
    """
    pid = self.process_id
    return _electron_by_window_class(pid) or _electron_by_modules(pid)

is_cef

is_cef() -> bool

Return True if this application uses the Chromium Embedded Framework (CEF).

Detection checks for libcef.dll in the process module list while excluding Electron-specific DLLs (vk_swiftshader, libglesv2, electron). Because Electron is itself built on CEF, both would carry libcef.dll — the absence of Electron modules distinguishes a standalone CEF app (Spotify, Steam, Battle.net, etc.) from Electron.

Call this method after the app's main window has loaded — CEF DLLs are loaded lazily during UI initialization.

.. note:: UIA accessibility coverage for CEF apps varies widely. Apps that expose --force-renderer-accessibility (or enable it internally) provide a richer UIA tree. Spotify and Steam use bitmap rendering for most UI, leaving only the window frame and a few controls in the UIA tree. See docs/cef.md for workarounds.

Returns False on any OS error, never raises.

Source code in src\dolphin_desktop\_application.py
def is_cef(self) -> bool:
    """Return ``True`` if this application uses the Chromium Embedded Framework (CEF).

    Detection checks for ``libcef.dll`` in the process module list while
    excluding Electron-specific DLLs (``vk_swiftshader``, ``libglesv2``,
    ``electron``).  Because Electron is itself built on CEF, both would
    carry ``libcef.dll`` — the absence of Electron modules distinguishes a
    standalone CEF app (Spotify, Steam, Battle.net, etc.) from Electron.

    Call this method after the app's main window has loaded — CEF DLLs are
    loaded lazily during UI initialization.

    .. note::
        UIA accessibility coverage for CEF apps varies widely.  Apps that
        expose ``--force-renderer-accessibility`` (or enable it internally)
        provide a richer UIA tree.  Spotify and Steam use bitmap rendering
        for most UI, leaving only the window frame and a few controls in
        the UIA tree.  See ``docs/cef.md`` for workarounds.

    Returns ``False`` on any OS error, never raises.
    """
    return _cef_by_modules(self.process_id)

is_legacy_ie

is_legacy_ie() -> bool

Return True if this application hosts an IE/Trident (MSHTML) browser.

Detection checks whether the process has loaded mshtml.dll, the MSHTML rendering engine used by the WPF WebBrowser control and older IE-based components. This DLL is present in every WPF app that instantiates a WebBrowser control, regardless of the URL loaded.

.. note:: IE/Trident is End-of-Life since June 2022. The WPF WebBrowser control still works in .NET 4.8 and partially in .NET 8 (Windows), but Microsoft no longer ships security patches for MSHTML. For new projects use Edge WebView2 (see docs/webview2.md).

Returns False on any OS error, never raises.

Source code in src\dolphin_desktop\_application.py
def is_legacy_ie(self) -> bool:
    """Return ``True`` if this application hosts an IE/Trident (MSHTML) browser.

    Detection checks whether the process has loaded ``mshtml.dll``, the
    MSHTML rendering engine used by the WPF ``WebBrowser`` control and
    older IE-based components.  This DLL is present in every WPF app that
    instantiates a ``WebBrowser`` control, regardless of the URL loaded.

    .. note::
        IE/Trident is End-of-Life since June 2022.  The WPF ``WebBrowser``
        control still works in .NET 4.8 and partially in .NET 8 (Windows),
        but Microsoft no longer ships security patches for MSHTML.
        For new projects use Edge WebView2 (see ``docs/webview2.md``).

    Returns ``False`` on any OS error, never raises.
    """
    return _legacy_ie_by_modules(self.process_id)

ApplicationError

Bases: DolphinError

Raised when application launch or connection fails.

Source code in src\dolphin_desktop\_exceptions.py
class ApplicationError(DolphinError):
    """Raised when application launch or connection fails."""

Backend

Bases: ABC

Abstract base class for dolphin automation backends.

Subclasses must set :attr:id (unique string key) and :attr:platform at class level, and implement all abstract methods.

Third-party packages register new backends via the dolphin_desktop.backends entry-point group — each entry point must point to a :class:Backend subclass.

Source code in src\dolphin_desktop\_backend.py
class Backend(abc.ABC):
    """Abstract base class for dolphin automation backends.

    Subclasses must set :attr:`id` (unique string key) and :attr:`platform`
    at class level, and implement all abstract methods.

    Third-party packages register new backends via the
    ``dolphin_desktop.backends`` entry-point group — each entry point must
    point to a :class:`Backend` subclass.
    """

    #: Unique string identifier used in ``Desktop(backend="...")`` and the registry.
    id: str
    #: Target platform: ``"windows"``, ``"macos"``, ``"linux"``, or ``"any"``.
    platform: str

    @abc.abstractmethod
    def find_element(self, parent: Any, criteria: dict[str, Any]) -> Any:
        """Locate a single UI element matching *criteria* under *parent*.

        Parameters
        ----------
        parent:
            Root node to search under (type is backend-specific).
        criteria:
            Backend-specific search criteria (e.g. ``{"control_type": "Button"}``).

        Returns
        -------
        Any
            An opaque element handle understood by the other backend methods.

        Raises
        ------
        dolphin_desktop.ElementNotFoundError
            If no matching element is found within the configured timeout.
        """

    @abc.abstractmethod
    def click(self, element: Any, *, button: str = "left") -> None:
        """Perform a mouse click on *element*.

        Parameters
        ----------
        element:
            Element handle returned by :meth:`find_element`.
        button:
            Mouse button: ``"left"`` (default), ``"right"``, or ``"middle"``.
        """

    @abc.abstractmethod
    def type_text(self, element: Any, text: str) -> None:
        """Type *text* into *element*.

        Parameters
        ----------
        element:
            Element handle returned by :meth:`find_element`.
        text:
            Text to type (sent as keyboard events).
        """

    @abc.abstractmethod
    def get_tree(self, root: Any, *, depth: int | None = None) -> dict[str, Any]:
        """Return the accessibility/element tree rooted at *root* as a dict.

        Parameters
        ----------
        root:
            Root node to start from (type is backend-specific).
        depth:
            Maximum depth to traverse; ``None`` means unlimited.

        Returns
        -------
        dict
            Nested dict with keys ``"name"``, ``"role"``, ``"children"``, etc.
        """

    @abc.abstractmethod
    def screenshot(self, element: Any | None = None) -> Any:
        """Capture a screenshot.

        Parameters
        ----------
        element:
            Element to capture, or ``None`` for the full screen.

        Returns
        -------
        PIL.Image.Image
            Screenshot image.
        """

    def is_available(self) -> bool:
        """Return ``True`` if this backend can run on the current system.

        The default implementation returns ``True``; subclasses that depend on
        optional packages or specific platforms should override this.
        """
        return True

    def description(self) -> str:
        """One-line human-readable description (shown by ``dolphin info backends``)."""
        doc = type(self).__doc__
        return doc.split("\n")[0].strip() if doc else ""

    def __repr__(self) -> str:
        return f"{type(self).__name__}(id={self.id!r})"

find_element abstractmethod

find_element(parent: Any, criteria: dict[str, Any]) -> Any

Locate a single UI element matching criteria under parent.

Parameters:

Name Type Description Default
parent Any

Root node to search under (type is backend-specific).

required
criteria dict[str, Any]

Backend-specific search criteria (e.g. {"control_type": "Button"}).

required

Returns:

Type Description
Any

An opaque element handle understood by the other backend methods.

Raises:

Type Description
ElementNotFoundError

If no matching element is found within the configured timeout.

Source code in src\dolphin_desktop\_backend.py
@abc.abstractmethod
def find_element(self, parent: Any, criteria: dict[str, Any]) -> Any:
    """Locate a single UI element matching *criteria* under *parent*.

    Parameters
    ----------
    parent:
        Root node to search under (type is backend-specific).
    criteria:
        Backend-specific search criteria (e.g. ``{"control_type": "Button"}``).

    Returns
    -------
    Any
        An opaque element handle understood by the other backend methods.

    Raises
    ------
    dolphin_desktop.ElementNotFoundError
        If no matching element is found within the configured timeout.
    """

click abstractmethod

click(element: Any, *, button: str = 'left') -> None

Perform a mouse click on element.

Parameters:

Name Type Description Default
element Any

Element handle returned by :meth:find_element.

required
button str

Mouse button: "left" (default), "right", or "middle".

'left'
Source code in src\dolphin_desktop\_backend.py
@abc.abstractmethod
def click(self, element: Any, *, button: str = "left") -> None:
    """Perform a mouse click on *element*.

    Parameters
    ----------
    element:
        Element handle returned by :meth:`find_element`.
    button:
        Mouse button: ``"left"`` (default), ``"right"``, or ``"middle"``.
    """

type_text abstractmethod

type_text(element: Any, text: str) -> None

Type text into element.

Parameters:

Name Type Description Default
element Any

Element handle returned by :meth:find_element.

required
text str

Text to type (sent as keyboard events).

required
Source code in src\dolphin_desktop\_backend.py
@abc.abstractmethod
def type_text(self, element: Any, text: str) -> None:
    """Type *text* into *element*.

    Parameters
    ----------
    element:
        Element handle returned by :meth:`find_element`.
    text:
        Text to type (sent as keyboard events).
    """

get_tree abstractmethod

get_tree(
    root: Any, *, depth: int | None = None
) -> dict[str, Any]

Return the accessibility/element tree rooted at root as a dict.

Parameters:

Name Type Description Default
root Any

Root node to start from (type is backend-specific).

required
depth int | None

Maximum depth to traverse; None means unlimited.

None

Returns:

Type Description
dict

Nested dict with keys "name", "role", "children", etc.

Source code in src\dolphin_desktop\_backend.py
@abc.abstractmethod
def get_tree(self, root: Any, *, depth: int | None = None) -> dict[str, Any]:
    """Return the accessibility/element tree rooted at *root* as a dict.

    Parameters
    ----------
    root:
        Root node to start from (type is backend-specific).
    depth:
        Maximum depth to traverse; ``None`` means unlimited.

    Returns
    -------
    dict
        Nested dict with keys ``"name"``, ``"role"``, ``"children"``, etc.
    """

screenshot abstractmethod

screenshot(element: Any | None = None) -> Any

Capture a screenshot.

Parameters:

Name Type Description Default
element Any | None

Element to capture, or None for the full screen.

None

Returns:

Type Description
Image

Screenshot image.

Source code in src\dolphin_desktop\_backend.py
@abc.abstractmethod
def screenshot(self, element: Any | None = None) -> Any:
    """Capture a screenshot.

    Parameters
    ----------
    element:
        Element to capture, or ``None`` for the full screen.

    Returns
    -------
    PIL.Image.Image
        Screenshot image.
    """

is_available

is_available() -> bool

Return True if this backend can run on the current system.

The default implementation returns True; subclasses that depend on optional packages or specific platforms should override this.

Source code in src\dolphin_desktop\_backend.py
def is_available(self) -> bool:
    """Return ``True`` if this backend can run on the current system.

    The default implementation returns ``True``; subclasses that depend on
    optional packages or specific platforms should override this.
    """
    return True

description

description() -> str

One-line human-readable description (shown by dolphin info backends).

Source code in src\dolphin_desktop\_backend.py
def description(self) -> str:
    """One-line human-readable description (shown by ``dolphin info backends``)."""
    doc = type(self).__doc__
    return doc.split("\n")[0].strip() if doc else ""

Button

Bases: Element

A clickable button control.

Usage::

window.button(name="OK").click()
assert window.button(name="Apply").is_enabled()
window.button(name="Cancel").wait_until_enabled()
Source code in src\dolphin_desktop\_element.py
class Button(Element):
    """A clickable button control.

    Usage::

        window.button(name="OK").click()
        assert window.button(name="Apply").is_enabled()
        window.button(name="Cancel").wait_until_enabled()
    """

CDPBackend

Bases: Backend

Chrome DevTools Protocol backend - reserved, not yet implemented.

Source code in src\dolphin_desktop\_backend.py
class CDPBackend(Backend):
    """Chrome DevTools Protocol backend - reserved, not yet implemented."""

    id = "cdp"
    platform = "any"

    def _raise(self) -> None:
        raise NotImplementedError(_STUB_MSG.format(name="CDPBackend"))

    def find_element(self, parent: Any, criteria: dict[str, Any]) -> Any:
        self._raise()

    def click(self, element: Any, *, button: str = "left") -> None:
        self._raise()

    def type_text(self, element: Any, text: str) -> None:
        self._raise()

    def get_tree(self, root: Any, *, depth: int | None = None) -> dict[str, Any]:
        self._raise()
        return {}

    def screenshot(self, element: Any | None = None) -> Any:
        self._raise()

    def is_available(self) -> bool:
        return False

CheckBox

Bases: Element

A check box control.

Usage::

window.check_box(name="Remember me").check()
assert window.check_box(name="Remember me").is_checked()
window.check_box(name="Send updates").uncheck()
Source code in src\dolphin_desktop\_element.py
class CheckBox(Element):
    """A check box control.

    Usage::

        window.check_box(name="Remember me").check()
        assert window.check_box(name="Remember me").is_checked()
        window.check_box(name="Send updates").uncheck()
    """

Clipboard

Static helper for Windows clipboard operations.

Usage::

Clipboard.set_text("hello world")
text = Clipboard.get_text()
img  = Clipboard.get_image()   # PIL.Image or None
Clipboard.clear()
Source code in src\dolphin_desktop\_clipboard.py
class Clipboard:
    """Static helper for Windows clipboard operations.

    Usage::

        Clipboard.set_text("hello world")
        text = Clipboard.get_text()
        img  = Clipboard.get_image()   # PIL.Image or None
        Clipboard.clear()
    """

    @staticmethod
    def get_text() -> str:
        """Read CF_UNICODETEXT from the clipboard; returns empty string if none."""
        import win32clipboard  # type: ignore[import-untyped]
        import win32con  # type: ignore[import-untyped]

        win32clipboard.OpenClipboard()
        try:
            try:
                return win32clipboard.GetClipboardData(win32con.CF_UNICODETEXT)
            except Exception:
                return ""
        finally:
            win32clipboard.CloseClipboard()

    @staticmethod
    def set_text(text: str) -> None:
        """Write *text* to the clipboard as CF_UNICODETEXT."""
        import win32clipboard  # type: ignore[import-untyped]
        import win32con  # type: ignore[import-untyped]

        win32clipboard.OpenClipboard()
        try:
            win32clipboard.EmptyClipboard()
            win32clipboard.SetClipboardData(win32con.CF_UNICODETEXT, text)
        finally:
            win32clipboard.CloseClipboard()

    @staticmethod
    def get_image():  # -> PIL.Image.Image | None
        """Read CF_DIB or CF_BITMAP from clipboard; returns None if not present."""
        import io

        import win32clipboard  # type: ignore[import-untyped]
        import win32con  # type: ignore[import-untyped]
        from PIL import Image, ImageGrab

        win32clipboard.OpenClipboard()
        try:
            if win32clipboard.IsClipboardFormatAvailable(win32con.CF_DIB):
                data = win32clipboard.GetClipboardData(win32con.CF_DIB)
                # CF_DIB is a BITMAPINFOHEADER + pixel data — wrap in BMP file header
                bmp_header = (
                    b"BM"
                    + (len(data) + 14).to_bytes(4, "little")
                    + b"\x00\x00\x00\x00"
                    + (14 + 40).to_bytes(4, "little")
                )
                return Image.open(io.BytesIO(bmp_header + data))
            if win32clipboard.IsClipboardFormatAvailable(win32con.CF_BITMAP):
                # Fallback: use ImageGrab which handles the conversion
                return ImageGrab.grabclipboard()
            return None
        except Exception:
            return None
        finally:
            win32clipboard.CloseClipboard()

    @staticmethod
    def clear() -> None:
        """Empty the clipboard."""
        import win32clipboard  # type: ignore[import-untyped]

        win32clipboard.OpenClipboard()
        try:
            win32clipboard.EmptyClipboard()
        finally:
            win32clipboard.CloseClipboard()

get_text staticmethod

get_text() -> str

Read CF_UNICODETEXT from the clipboard; returns empty string if none.

Source code in src\dolphin_desktop\_clipboard.py
@staticmethod
def get_text() -> str:
    """Read CF_UNICODETEXT from the clipboard; returns empty string if none."""
    import win32clipboard  # type: ignore[import-untyped]
    import win32con  # type: ignore[import-untyped]

    win32clipboard.OpenClipboard()
    try:
        try:
            return win32clipboard.GetClipboardData(win32con.CF_UNICODETEXT)
        except Exception:
            return ""
    finally:
        win32clipboard.CloseClipboard()

set_text staticmethod

set_text(text: str) -> None

Write text to the clipboard as CF_UNICODETEXT.

Source code in src\dolphin_desktop\_clipboard.py
@staticmethod
def set_text(text: str) -> None:
    """Write *text* to the clipboard as CF_UNICODETEXT."""
    import win32clipboard  # type: ignore[import-untyped]
    import win32con  # type: ignore[import-untyped]

    win32clipboard.OpenClipboard()
    try:
        win32clipboard.EmptyClipboard()
        win32clipboard.SetClipboardData(win32con.CF_UNICODETEXT, text)
    finally:
        win32clipboard.CloseClipboard()

get_image staticmethod

get_image()

Read CF_DIB or CF_BITMAP from clipboard; returns None if not present.

Source code in src\dolphin_desktop\_clipboard.py
@staticmethod
def get_image():  # -> PIL.Image.Image | None
    """Read CF_DIB or CF_BITMAP from clipboard; returns None if not present."""
    import io

    import win32clipboard  # type: ignore[import-untyped]
    import win32con  # type: ignore[import-untyped]
    from PIL import Image, ImageGrab

    win32clipboard.OpenClipboard()
    try:
        if win32clipboard.IsClipboardFormatAvailable(win32con.CF_DIB):
            data = win32clipboard.GetClipboardData(win32con.CF_DIB)
            # CF_DIB is a BITMAPINFOHEADER + pixel data — wrap in BMP file header
            bmp_header = (
                b"BM"
                + (len(data) + 14).to_bytes(4, "little")
                + b"\x00\x00\x00\x00"
                + (14 + 40).to_bytes(4, "little")
            )
            return Image.open(io.BytesIO(bmp_header + data))
        if win32clipboard.IsClipboardFormatAvailable(win32con.CF_BITMAP):
            # Fallback: use ImageGrab which handles the conversion
            return ImageGrab.grabclipboard()
        return None
    except Exception:
        return None
    finally:
        win32clipboard.CloseClipboard()

clear staticmethod

clear() -> None

Empty the clipboard.

Source code in src\dolphin_desktop\_clipboard.py
@staticmethod
def clear() -> None:
    """Empty the clipboard."""
    import win32clipboard  # type: ignore[import-untyped]

    win32clipboard.OpenClipboard()
    try:
        win32clipboard.EmptyClipboard()
    finally:
        win32clipboard.CloseClipboard()

ComboBox

Bases: Element

A combo box (drop-down list) control.

Usage::

window.combo_box(name="Language").select_item("English")
window.combo_box().expand()
current = window.combo_box().selected_item()
Source code in src\dolphin_desktop\_element.py
class ComboBox(Element):
    """A combo box (drop-down list) control.

    Usage::

        window.combo_box(name="Language").select_item("English")
        window.combo_box().expand()
        current = window.combo_box().selected_item()
    """

    def expand(self) -> ComboBox:
        """Open the dropdown list."""
        try:
            self._resolve().expand()
        except Exception:
            self._resolve().click_input()
        return self

    def collapse(self) -> ComboBox:
        """Close the dropdown list."""
        try:
            self._resolve().collapse()
        except Exception:
            pass
        return self

    def selected_item(self) -> str:
        """Return the text of the currently selected item."""
        element = self._resolve()
        try:
            return element.selected_item()
        except Exception:
            return element.window_text()

expand

expand() -> ComboBox

Open the dropdown list.

Source code in src\dolphin_desktop\_element.py
def expand(self) -> ComboBox:
    """Open the dropdown list."""
    try:
        self._resolve().expand()
    except Exception:
        self._resolve().click_input()
    return self

collapse

collapse() -> ComboBox

Close the dropdown list.

Source code in src\dolphin_desktop\_element.py
def collapse(self) -> ComboBox:
    """Close the dropdown list."""
    try:
        self._resolve().collapse()
    except Exception:
        pass
    return self

selected_item

selected_item() -> str

Return the text of the currently selected item.

Source code in src\dolphin_desktop\_element.py
def selected_item(self) -> str:
    """Return the text of the currently selected item."""
    element = self._resolve()
    try:
        return element.selected_item()
    except Exception:
        return element.window_text()

Desktop

Factory for :class:Application instances.

Usage::

desktop = Desktop()
app = desktop.launch("notepad.exe")
# or
app = desktop.connect(title_re=".*Notepad")

Parameters:

Name Type Description Default
backend str

"uia" (default) uses Microsoft UI Automation — works with UWP, WPF, and modern Win32 apps. "win32" uses the legacy Win32 API and is better for older applications.

'uia'
hidden bool | None

True — launch AUT processes on a hidden desktop (DolphinHidden) so windows are invisible on the physical display. Full UIA support requires the test process itself to run on that desktop — use dolphin-run pytest tests/ for that.

False — always use the visible default desktop.

None (default) — auto-detect: activate hidden mode when the DOLPHIN_HEADLESS=1 environment variable is set (injected by dolphin-run) or when has_interactive_station() returns False (non-interactive CI without an RDP virtual display).

None
Source code in src\dolphin_desktop\_desktop.py
class Desktop:
    """Factory for :class:`Application` instances.

    Usage::

        desktop = Desktop()
        app = desktop.launch("notepad.exe")
        # or
        app = desktop.connect(title_re=".*Notepad")

    Parameters
    ----------
    backend:
        ``"uia"`` (default) uses Microsoft UI Automation — works with UWP,
        WPF, and modern Win32 apps.  ``"win32"`` uses the legacy Win32 API
        and is better for older applications.
    hidden:
        ``True``  — launch AUT processes on a hidden desktop (``DolphinHidden``)
        so windows are invisible on the physical display.  Full UIA support
        requires the test process itself to run on that desktop — use
        ``dolphin-run pytest tests/`` for that.

        ``False`` — always use the visible default desktop.

        ``None`` (default) — auto-detect: activate hidden mode when the
        ``DOLPHIN_HEADLESS=1`` environment variable is set (injected by
        ``dolphin-run``) or when ``has_interactive_station()`` returns
        ``False`` (non-interactive CI without an RDP virtual display).
    """

    def __init__(self, backend: str = "uia", hidden: bool | None = None) -> None:
        self._backend = backend
        self._hidden_mode = hidden
        self._hDesk: int | None = None
        self._hidden_initialized = False

        # resolve auto-detect once at construction time
        if hidden is None:
            try:
                from ._runner import has_interactive_station

                env_headless = os.environ.get("DOLPHIN_HEADLESS") == "1"
                self._resolved_hidden: bool = env_headless or not has_interactive_station()
            except Exception:
                self._resolved_hidden = False
        else:
            self._resolved_hidden = hidden

    @property
    def _is_hidden(self) -> bool:
        """Effective headless mode flag."""
        return self._resolved_hidden

    def _ensure_hidden_mode(self) -> None:
        """Lazy init: create hidden desktop and switch thread if not already there."""
        if self._hidden_initialized:
            return
        self._hidden_initialized = True

        from ._runner import (
            DESKTOP_NAME,
            close_desktop,
            create_hidden_desktop,
            get_current_desktop_name,
            switch_thread_to_desktop,
        )

        if get_current_desktop_name() == DESKTOP_NAME:
            # Already on DolphinHidden — launched via dolphin-run.  No setup needed.
            return

        self._hDesk = create_hidden_desktop()
        try:
            switch_thread_to_desktop(self._hDesk)
        except OSError as exc:
            # Thread may have already created windows (COM/UIA initialised).
            # Apps will still be launched on the hidden desktop, but IUIAutomation
            # on this thread may not see them.  Use dolphin-run for full support.
            warnings.warn(
                f"Headless mode: SetThreadDesktop failed ({exc}). "
                "IUIAutomation may not see hidden-desktop windows. "
                "Run tests via 'dolphin-run pytest tests/' for full headless support.",
                stacklevel=4,
            )
        import atexit

        atexit.register(close_desktop, self._hDesk)

    # ------------------------------------------------------------------
    # Launch / connect
    # ------------------------------------------------------------------

    def launch(
        self,
        cmd: str,
        *,
        timeout: float = 10.0,
        work_dir: str | None = None,
        startup_delay: float = 0.5,
    ) -> Application:
        """Start a new process and return an :class:`Application`.

        Parameters
        ----------
        cmd:
            Command line to execute (e.g. ``"notepad.exe"`` or
            ``r"C:\\Windows\\notepad.exe my_file.txt"``).
        timeout:
            Maximum seconds to wait for the process to start.
        work_dir:
            Optional working directory for the new process.
        startup_delay:
            Seconds to wait after the process starts before returning.
            Useful for single-instance apps (e.g. Windows 11 Notepad) that
            hand off to an existing process — a brief pause lets the target
            window appear before :meth:`Application.window` is called.
        """
        if self._is_hidden:
            return self._launch_hidden(
                cmd, timeout=timeout, work_dir=work_dir, startup_delay=startup_delay
            )

        try:
            app = _PyWinApp(backend=self._backend)
            # wait_for_idle=False: packaged/store apps do not support
            # WaitForInputIdle and would raise RuntimeWarning otherwise.
            app.start(cmd, timeout=timeout, wait_for_idle=False, work_dir=work_dir)
        except Exception as exc:
            raise ApplicationError(f"Failed to launch {cmd!r}: {exc}") from exc
        if startup_delay > 0:
            time.sleep(startup_delay)
        return Application(app, backend=self._backend)

    def _launch_hidden(
        self,
        cmd: str,
        *,
        timeout: float,
        work_dir: str | None,
        startup_delay: float,
    ) -> Application:
        """Launch *cmd* on the hidden desktop and connect pywinauto by PID."""
        from ._runner import close_process_handle, launch_cmd_on_desktop

        self._ensure_hidden_mode()
        try:
            pid, h_process = launch_cmd_on_desktop(cmd, work_dir=work_dir)
            close_process_handle(h_process)
        except OSError as exc:
            raise ApplicationError(f"Failed to launch {cmd!r} on hidden desktop: {exc}") from exc

        if startup_delay > 0:
            time.sleep(startup_delay)

        try:
            app = _PyWinApp(backend=self._backend)
            app.connect(process=pid, timeout=timeout)
        except Exception as exc:
            raise ApplicationError(
                f"Failed to connect to hidden-desktop process (pid={pid}): {exc}"
            ) from exc

        return Application(app, backend=self._backend)

    def connect(
        self,
        *,
        title: str | None = None,
        title_re: str | None = None,
        process: int | None = None,
        path: str | None = None,
        class_name: str | None = None,
        found_index: int = 0,
        timeout: float = 10.0,
    ) -> Application:
        """Connect to an already-running process.

        At least one search criterion must be provided.

        When the criteria match more than one window (common for single-instance
        apps such as Windows 11 Notepad, where several windows share a class
        name or title pattern), *found_index* selects which match to use — the
        first one (``0``) by default. Without this, pywinauto raises
        ``ElementAmbiguousError`` on multiple matches.
        """
        criteria: dict[str, Any] = {}
        if title is not None:
            criteria["title"] = title
        if title_re is not None:
            criteria["title_re"] = title_re
        if process is not None:
            criteria["process"] = process
        if path is not None:
            criteria["path"] = path
        if class_name is not None:
            criteria["class_name"] = class_name

        if not criteria:
            raise ValueError("At least one search criterion must be provided")

        # process/path connect by an unambiguous identifier; found_index only
        # applies to the title/class_name lookup path, where several windows can
        # match. Adding it there picks the Nth match instead of raising.
        if process is None and path is None:
            criteria["found_index"] = found_index

        try:
            app = _PyWinApp(backend=self._backend)
            app.connect(timeout=timeout, **criteria)
        except Exception as exc:
            raise ApplicationError(f"Failed to connect to application {criteria!r}: {exc}") from exc
        return Application(app, backend=self._backend)

    @classmethod
    def for_legacy_apps(cls, **kwargs: Any) -> Desktop:
        """Win32 backend — better for Delphi/VCL, MFC, older WinForms."""
        return cls(backend="win32", **kwargs)

    def launch_qt(self, cmd: str, **kwargs: Any) -> Application:
        """Launch a Qt app with QT_ACCESSIBILITY=1."""
        import os

        os.environ.setdefault("QT_ACCESSIBILITY", "1")
        return self.launch(cmd, **kwargs)

    def launch_java(self, cmd: str, **kwargs: Any) -> Application:
        """Enable Java Access Bridge then launch a Java app."""
        from ._java import JavaAccessBridge, _JABSession

        JavaAccessBridge.ensure_enabled()
        # Initialize JAB client session BEFORE launching so it can receive
        # the WM_COPYDATA handshake broadcast by javaaccessbridge.dll on startup.
        session = _JABSession.get_or_create()
        app = self.launch(cmd, **kwargs)
        # Pump Windows messages to process the JAB handshake from the new JVM.
        session.pump(60, 0.05)  # ~3 s
        return app

    def launch_electron(self, cmd: str, **kwargs: Any) -> Application:
        """Launch an Electron/CEF app with accessibility enabled."""
        if "--force-renderer-accessibility" not in cmd:
            cmd = cmd.rstrip() + " --force-renderer-accessibility"
        return self.launch(cmd, **kwargs)

    def launch_cef(self, cmd: str, **kwargs: Any) -> Application:
        """Launch a standalone CEF (Chromium Embedded Framework) app.

        Attempts to enable UIA accessibility by appending
        ``--force-renderer-accessibility`` to the command line.  This flag is
        respected by CEF-based apps that pass unknown CLI arguments through to
        the Chromium layer (e.g. some enterprise launchers, Battle.net Launcher).

        .. warning::
            Apps that wrap CEF in their own proprietary launcher (Spotify,
            Steam) typically **ignore** this flag.  For those apps, UIA
            coverage is limited to what the app's own accessibility layer
            exposes — see ``docs/cef.md`` for workarounds using
            :class:`~dolphin_desktop.ImageLocator`.

        Usage::

            app = desktop.launch_cef(r"C:\\path\\to\\cef_app.exe", timeout=20)
            win = app.window(title_re=".*My CEF App.*", timeout=15)
            assert app.is_cef()
        """
        if "--force-renderer-accessibility" not in cmd:
            cmd = cmd.rstrip() + " --force-renderer-accessibility"
        return self.launch(cmd, **kwargs)

    def launch_webview2(self, cmd: str, **kwargs: Any) -> Application:
        """Launch a WebView2-hosted WPF/WinForms app.

        Edge WebView2 exposes a full UIA accessibility tree by default — no
        special command-line flags are required.  This method is a semantic
        wrapper over :meth:`launch` that documents intent and mirrors the
        ``launch_electron`` / ``launch_java`` / ``launch_qt`` family.
        """
        return self.launch(cmd, **kwargs)

    def find_process(
        self,
        *,
        name: str | None = None,
        pid: int | None = None,
        title: str | None = None,
        title_re: str | None = None,
    ) -> Application | None:
        """Return an Application connected to a matching running process, or None."""
        criteria: dict[str, Any] = {}
        if name is not None:
            criteria["path"] = name
        if pid is not None:
            criteria["process"] = pid
        if title is not None:
            criteria["title"] = title
        if title_re is not None:
            criteria["title_re"] = title_re

        if not criteria:
            raise ValueError("At least one search criterion must be provided")

        try:
            return self.connect(**criteria)
        except ApplicationError:
            return None

    def __repr__(self) -> str:
        hidden_str = "" if self._hidden_mode is None else f", hidden={self._is_hidden}"
        return f"Desktop(backend={self._backend!r}{hidden_str})"

launch

launch(
    cmd: str,
    *,
    timeout: float = 10.0,
    work_dir: str | None = None,
    startup_delay: float = 0.5,
) -> Application

Start a new process and return an :class:Application.

Parameters:

Name Type Description Default
cmd str

Command line to execute (e.g. "notepad.exe" or r"C:\Windows\notepad.exe my_file.txt").

required
timeout float

Maximum seconds to wait for the process to start.

10.0
work_dir str | None

Optional working directory for the new process.

None
startup_delay float

Seconds to wait after the process starts before returning. Useful for single-instance apps (e.g. Windows 11 Notepad) that hand off to an existing process — a brief pause lets the target window appear before :meth:Application.window is called.

0.5
Source code in src\dolphin_desktop\_desktop.py
def launch(
    self,
    cmd: str,
    *,
    timeout: float = 10.0,
    work_dir: str | None = None,
    startup_delay: float = 0.5,
) -> Application:
    """Start a new process and return an :class:`Application`.

    Parameters
    ----------
    cmd:
        Command line to execute (e.g. ``"notepad.exe"`` or
        ``r"C:\\Windows\\notepad.exe my_file.txt"``).
    timeout:
        Maximum seconds to wait for the process to start.
    work_dir:
        Optional working directory for the new process.
    startup_delay:
        Seconds to wait after the process starts before returning.
        Useful for single-instance apps (e.g. Windows 11 Notepad) that
        hand off to an existing process — a brief pause lets the target
        window appear before :meth:`Application.window` is called.
    """
    if self._is_hidden:
        return self._launch_hidden(
            cmd, timeout=timeout, work_dir=work_dir, startup_delay=startup_delay
        )

    try:
        app = _PyWinApp(backend=self._backend)
        # wait_for_idle=False: packaged/store apps do not support
        # WaitForInputIdle and would raise RuntimeWarning otherwise.
        app.start(cmd, timeout=timeout, wait_for_idle=False, work_dir=work_dir)
    except Exception as exc:
        raise ApplicationError(f"Failed to launch {cmd!r}: {exc}") from exc
    if startup_delay > 0:
        time.sleep(startup_delay)
    return Application(app, backend=self._backend)

connect

connect(
    *,
    title: str | None = None,
    title_re: str | None = None,
    process: int | None = None,
    path: str | None = None,
    class_name: str | None = None,
    found_index: int = 0,
    timeout: float = 10.0,
) -> Application

Connect to an already-running process.

At least one search criterion must be provided.

When the criteria match more than one window (common for single-instance apps such as Windows 11 Notepad, where several windows share a class name or title pattern), found_index selects which match to use — the first one (0) by default. Without this, pywinauto raises ElementAmbiguousError on multiple matches.

Source code in src\dolphin_desktop\_desktop.py
def connect(
    self,
    *,
    title: str | None = None,
    title_re: str | None = None,
    process: int | None = None,
    path: str | None = None,
    class_name: str | None = None,
    found_index: int = 0,
    timeout: float = 10.0,
) -> Application:
    """Connect to an already-running process.

    At least one search criterion must be provided.

    When the criteria match more than one window (common for single-instance
    apps such as Windows 11 Notepad, where several windows share a class
    name or title pattern), *found_index* selects which match to use — the
    first one (``0``) by default. Without this, pywinauto raises
    ``ElementAmbiguousError`` on multiple matches.
    """
    criteria: dict[str, Any] = {}
    if title is not None:
        criteria["title"] = title
    if title_re is not None:
        criteria["title_re"] = title_re
    if process is not None:
        criteria["process"] = process
    if path is not None:
        criteria["path"] = path
    if class_name is not None:
        criteria["class_name"] = class_name

    if not criteria:
        raise ValueError("At least one search criterion must be provided")

    # process/path connect by an unambiguous identifier; found_index only
    # applies to the title/class_name lookup path, where several windows can
    # match. Adding it there picks the Nth match instead of raising.
    if process is None and path is None:
        criteria["found_index"] = found_index

    try:
        app = _PyWinApp(backend=self._backend)
        app.connect(timeout=timeout, **criteria)
    except Exception as exc:
        raise ApplicationError(f"Failed to connect to application {criteria!r}: {exc}") from exc
    return Application(app, backend=self._backend)

for_legacy_apps classmethod

for_legacy_apps(**kwargs: Any) -> Desktop

Win32 backend — better for Delphi/VCL, MFC, older WinForms.

Source code in src\dolphin_desktop\_desktop.py
@classmethod
def for_legacy_apps(cls, **kwargs: Any) -> Desktop:
    """Win32 backend — better for Delphi/VCL, MFC, older WinForms."""
    return cls(backend="win32", **kwargs)

launch_qt

launch_qt(cmd: str, **kwargs: Any) -> Application

Launch a Qt app with QT_ACCESSIBILITY=1.

Source code in src\dolphin_desktop\_desktop.py
def launch_qt(self, cmd: str, **kwargs: Any) -> Application:
    """Launch a Qt app with QT_ACCESSIBILITY=1."""
    import os

    os.environ.setdefault("QT_ACCESSIBILITY", "1")
    return self.launch(cmd, **kwargs)

launch_java

launch_java(cmd: str, **kwargs: Any) -> Application

Enable Java Access Bridge then launch a Java app.

Source code in src\dolphin_desktop\_desktop.py
def launch_java(self, cmd: str, **kwargs: Any) -> Application:
    """Enable Java Access Bridge then launch a Java app."""
    from ._java import JavaAccessBridge, _JABSession

    JavaAccessBridge.ensure_enabled()
    # Initialize JAB client session BEFORE launching so it can receive
    # the WM_COPYDATA handshake broadcast by javaaccessbridge.dll on startup.
    session = _JABSession.get_or_create()
    app = self.launch(cmd, **kwargs)
    # Pump Windows messages to process the JAB handshake from the new JVM.
    session.pump(60, 0.05)  # ~3 s
    return app

launch_electron

launch_electron(cmd: str, **kwargs: Any) -> Application

Launch an Electron/CEF app with accessibility enabled.

Source code in src\dolphin_desktop\_desktop.py
def launch_electron(self, cmd: str, **kwargs: Any) -> Application:
    """Launch an Electron/CEF app with accessibility enabled."""
    if "--force-renderer-accessibility" not in cmd:
        cmd = cmd.rstrip() + " --force-renderer-accessibility"
    return self.launch(cmd, **kwargs)

launch_cef

launch_cef(cmd: str, **kwargs: Any) -> Application

Launch a standalone CEF (Chromium Embedded Framework) app.

Attempts to enable UIA accessibility by appending --force-renderer-accessibility to the command line. This flag is respected by CEF-based apps that pass unknown CLI arguments through to the Chromium layer (e.g. some enterprise launchers, Battle.net Launcher).

.. warning:: Apps that wrap CEF in their own proprietary launcher (Spotify, Steam) typically ignore this flag. For those apps, UIA coverage is limited to what the app's own accessibility layer exposes — see docs/cef.md for workarounds using :class:~dolphin_desktop.ImageLocator.

Usage::

app = desktop.launch_cef(r"C:\path\to\cef_app.exe", timeout=20)
win = app.window(title_re=".*My CEF App.*", timeout=15)
assert app.is_cef()
Source code in src\dolphin_desktop\_desktop.py
def launch_cef(self, cmd: str, **kwargs: Any) -> Application:
    """Launch a standalone CEF (Chromium Embedded Framework) app.

    Attempts to enable UIA accessibility by appending
    ``--force-renderer-accessibility`` to the command line.  This flag is
    respected by CEF-based apps that pass unknown CLI arguments through to
    the Chromium layer (e.g. some enterprise launchers, Battle.net Launcher).

    .. warning::
        Apps that wrap CEF in their own proprietary launcher (Spotify,
        Steam) typically **ignore** this flag.  For those apps, UIA
        coverage is limited to what the app's own accessibility layer
        exposes — see ``docs/cef.md`` for workarounds using
        :class:`~dolphin_desktop.ImageLocator`.

    Usage::

        app = desktop.launch_cef(r"C:\\path\\to\\cef_app.exe", timeout=20)
        win = app.window(title_re=".*My CEF App.*", timeout=15)
        assert app.is_cef()
    """
    if "--force-renderer-accessibility" not in cmd:
        cmd = cmd.rstrip() + " --force-renderer-accessibility"
    return self.launch(cmd, **kwargs)

launch_webview2

launch_webview2(cmd: str, **kwargs: Any) -> Application

Launch a WebView2-hosted WPF/WinForms app.

Edge WebView2 exposes a full UIA accessibility tree by default — no special command-line flags are required. This method is a semantic wrapper over :meth:launch that documents intent and mirrors the launch_electron / launch_java / launch_qt family.

Source code in src\dolphin_desktop\_desktop.py
def launch_webview2(self, cmd: str, **kwargs: Any) -> Application:
    """Launch a WebView2-hosted WPF/WinForms app.

    Edge WebView2 exposes a full UIA accessibility tree by default — no
    special command-line flags are required.  This method is a semantic
    wrapper over :meth:`launch` that documents intent and mirrors the
    ``launch_electron`` / ``launch_java`` / ``launch_qt`` family.
    """
    return self.launch(cmd, **kwargs)

find_process

find_process(
    *,
    name: str | None = None,
    pid: int | None = None,
    title: str | None = None,
    title_re: str | None = None,
) -> Application | None

Return an Application connected to a matching running process, or None.

Source code in src\dolphin_desktop\_desktop.py
def find_process(
    self,
    *,
    name: str | None = None,
    pid: int | None = None,
    title: str | None = None,
    title_re: str | None = None,
) -> Application | None:
    """Return an Application connected to a matching running process, or None."""
    criteria: dict[str, Any] = {}
    if name is not None:
        criteria["path"] = name
    if pid is not None:
        criteria["process"] = pid
    if title is not None:
        criteria["title"] = title
    if title_re is not None:
        criteria["title_re"] = title_re

    if not criteria:
        raise ValueError("At least one search criterion must be provided")

    try:
        return self.connect(**criteria)
    except ApplicationError:
        return None

DolphinError

Bases: Exception

Base exception for dolphin_desktop.

Source code in src\dolphin_desktop\_exceptions.py
class DolphinError(Exception):
    """Base exception for dolphin_desktop."""

Edit

Bases: Element

A single-line or multi-line text input control.

Usage::

window.edit(name="Username").type_text("admin")
window.edit(name="Password").set_text("s3cr3t")
text = window.edit().text()
Source code in src\dolphin_desktop\_element.py
class Edit(Element):
    """A single-line or multi-line text input control.

    Usage::

        window.edit(name="Username").type_text("admin")
        window.edit(name="Password").set_text("s3cr3t")
        text = window.edit().text()
    """

Element

Bases: Locator

Base class for all specialized UI controls.

Extends :class:Locator with a named-control type. Use the factory methods on :class:Window rather than instantiating directly:

Usage::

btn = window.button(name="OK")
btn.click()

edit = window.edit(name="File name")
edit.set_text("report.xlsx")
Source code in src\dolphin_desktop\_element.py
class Element(Locator):
    """Base class for all specialized UI controls.

    Extends :class:`Locator` with a named-control type.  Use the factory
    methods on :class:`Window` rather than instantiating directly:

    Usage::

        btn = window.button(name="OK")
        btn.click()

        edit = window.edit(name="File name")
        edit.set_text("report.xlsx")
    """

ElementNotFoundError

Bases: DolphinError

Raised when an element cannot be found within the timeout.

Source code in src\dolphin_desktop\_exceptions.py
class ElementNotFoundError(DolphinError):
    """Raised when an element cannot be found within the timeout."""

ExcelApp

COM wrapper for Microsoft Excel.

Source code in src\dolphin_desktop\_office.py
class ExcelApp:
    """COM wrapper for Microsoft Excel."""

    def __init__(self, _com: Any) -> None:
        self._com = _com

    @classmethod
    def open(cls, path: str | Path, *, visible: bool = True) -> ExcelApp:
        """Open a workbook from *path* and return an ExcelApp."""
        client = _require_win32com()
        xl = client.Dispatch("Excel.Application")
        xl.Visible = visible
        xl.Workbooks.Open(str(Path(path).resolve()))
        return cls(xl)

    @classmethod
    def connect(cls) -> ExcelApp:
        """Connect to an already-running Excel instance."""
        client = _require_win32com()
        try:
            xl = client.GetActiveObject("Excel.Application")
        except Exception as exc:
            raise RuntimeError("No running Excel instance found.") from exc
        return cls(xl)

    @property
    def active_workbook(self) -> ExcelWorkbook:
        """The currently active workbook."""
        return ExcelWorkbook(self._com.ActiveWorkbook)

    @property
    def active_sheet(self) -> ExcelSheet:
        """The currently active worksheet."""
        return ExcelSheet(self._com.ActiveSheet)

    def quit(self, *, save_changes: bool = False) -> None:
        """Quit Excel, optionally saving all open workbooks."""
        self._com.DisplayAlerts = False
        if save_changes:
            for wb in self._com.Workbooks:
                wb.Save()
        self._com.Quit()

    def __enter__(self) -> ExcelApp:
        return self

    def __exit__(self, *_: Any) -> None:
        self.quit(save_changes=False)

active_workbook property

active_workbook: ExcelWorkbook

The currently active workbook.

active_sheet property

active_sheet: ExcelSheet

The currently active worksheet.

open classmethod

open(path: str | Path, *, visible: bool = True) -> ExcelApp

Open a workbook from path and return an ExcelApp.

Source code in src\dolphin_desktop\_office.py
@classmethod
def open(cls, path: str | Path, *, visible: bool = True) -> ExcelApp:
    """Open a workbook from *path* and return an ExcelApp."""
    client = _require_win32com()
    xl = client.Dispatch("Excel.Application")
    xl.Visible = visible
    xl.Workbooks.Open(str(Path(path).resolve()))
    return cls(xl)

connect classmethod

connect() -> ExcelApp

Connect to an already-running Excel instance.

Source code in src\dolphin_desktop\_office.py
@classmethod
def connect(cls) -> ExcelApp:
    """Connect to an already-running Excel instance."""
    client = _require_win32com()
    try:
        xl = client.GetActiveObject("Excel.Application")
    except Exception as exc:
        raise RuntimeError("No running Excel instance found.") from exc
    return cls(xl)

quit

quit(*, save_changes: bool = False) -> None

Quit Excel, optionally saving all open workbooks.

Source code in src\dolphin_desktop\_office.py
def quit(self, *, save_changes: bool = False) -> None:
    """Quit Excel, optionally saving all open workbooks."""
    self._com.DisplayAlerts = False
    if save_changes:
        for wb in self._com.Workbooks:
            wb.Save()
    self._com.Quit()

FileDialog

Helper for interacting with Windows Open/Save file dialogs.

Source code in src\dolphin_desktop\_dialogs.py
class FileDialog:
    """Helper for interacting with Windows Open/Save file dialogs."""

    def __init__(self, _window) -> None:
        self._win = _window

    @staticmethod
    def wait_for(timeout: float = 10.0) -> FileDialog:
        """Wait for any open/save dialog to appear and return a FileDialog."""
        return FileDialog(_find_dialog_window(timeout))

    def set_path(self, path: str) -> FileDialog:
        """Type a file path into the filename field and press Enter."""
        from pywinauto.keyboard import send_keys  # type: ignore[import-untyped]

        # Try to find an Edit control named 'File name' or similar
        edit = None
        for ctrl in self._win.children():
            try:
                if ctrl.class_name() in ("Edit", "RichEdit20W", "RICHEDIT50W"):
                    edit = ctrl
                    break
            except Exception:
                continue

        if edit is not None:
            try:
                edit.set_focus()
                edit.set_edit_text(path)
                # Do NOT press Enter here — that would already submit/close the
                # dialog, leaving confirm() with nothing to click (and the closed
                # dialog could be picked up as a stale #32770 by later waits).
                # Submission is the caller's job via confirm().
                return self
            except Exception:
                pass

        # Fallback: Ctrl+L to open address bar then type the path. The address
        # bar requires Enter to commit the typed location.
        self._win.set_focus()
        send_keys("^l")
        time.sleep(0.1)
        send_keys(path, with_spaces=True)
        send_keys("{ENTER}")
        return self

    def _dialog_gone(self) -> bool:
        """True if the underlying dialog window no longer exists/visible."""
        try:
            return not self._win.exists() or not self._win.is_visible()
        except Exception:
            return True

    def _click_by_auto_id(self, auto_id: str) -> bool:
        """Click the dialog control with the given AutomationId; True if clicked.

        Standard #32770 dialogs expose their default buttons by Win32 control id
        (IDOK="1", IDCANCEL="2"). Targeting the id avoids the ambiguity of the
        title-based lookup, where the Win11 file picker has several Buttons named
        "Otwórz" (the DropDown arrows) and the real accept control is a
        SplitButton — so control_type="Button" + title matched the wrong ones.

        The id alone can still be ambiguous (the picker exposes two elements with
        auto_id "1"/"2"), so we pin it to the control type of the actual button —
        a SplitButton (Open with its dropdown) or a plain Button (Save/Cancel).
        """
        for control_type in ("SplitButton", "Button"):
            try:
                self._win.child_window(auto_id=auto_id, control_type=control_type).click_input()
                return True
            except Exception:
                continue
        return False

    def confirm(self) -> None:
        """Click the Open / Save button."""
        # IDOK == "1": the dialog's default accept button (Open/Save/Otwórz/…).
        if self._click_by_auto_id("1"):
            return
        confirm_titles = ("Open", "Save", "Otwórz", "Zapisz", "OK")
        for title in confirm_titles:
            try:
                btn = self._win.child_window(title=title, control_type="Button")
                btn.click_input()
                return
            except Exception:
                continue
        # If the dialog already closed (e.g. submitted via Enter in the address
        # bar fallback), there is nothing left to confirm — treat as success.
        if self._dialog_gone():
            return
        raise RuntimeError("Could not find a confirm button in the file dialog")

    def cancel(self) -> None:
        """Click the Cancel button."""
        # IDCANCEL == "2".
        if self._click_by_auto_id("2"):
            return
        for title in ("Cancel", "Anuluj"):
            try:
                btn = self._win.child_window(title=title, control_type="Button")
                btn.click_input()
                return
            except Exception:
                continue
        if self._dialog_gone():
            return
        raise RuntimeError("Could not find a Cancel button in the file dialog")

wait_for staticmethod

wait_for(timeout: float = 10.0) -> FileDialog

Wait for any open/save dialog to appear and return a FileDialog.

Source code in src\dolphin_desktop\_dialogs.py
@staticmethod
def wait_for(timeout: float = 10.0) -> FileDialog:
    """Wait for any open/save dialog to appear and return a FileDialog."""
    return FileDialog(_find_dialog_window(timeout))

set_path

set_path(path: str) -> FileDialog

Type a file path into the filename field and press Enter.

Source code in src\dolphin_desktop\_dialogs.py
def set_path(self, path: str) -> FileDialog:
    """Type a file path into the filename field and press Enter."""
    from pywinauto.keyboard import send_keys  # type: ignore[import-untyped]

    # Try to find an Edit control named 'File name' or similar
    edit = None
    for ctrl in self._win.children():
        try:
            if ctrl.class_name() in ("Edit", "RichEdit20W", "RICHEDIT50W"):
                edit = ctrl
                break
        except Exception:
            continue

    if edit is not None:
        try:
            edit.set_focus()
            edit.set_edit_text(path)
            # Do NOT press Enter here — that would already submit/close the
            # dialog, leaving confirm() with nothing to click (and the closed
            # dialog could be picked up as a stale #32770 by later waits).
            # Submission is the caller's job via confirm().
            return self
        except Exception:
            pass

    # Fallback: Ctrl+L to open address bar then type the path. The address
    # bar requires Enter to commit the typed location.
    self._win.set_focus()
    send_keys("^l")
    time.sleep(0.1)
    send_keys(path, with_spaces=True)
    send_keys("{ENTER}")
    return self

confirm

confirm() -> None

Click the Open / Save button.

Source code in src\dolphin_desktop\_dialogs.py
def confirm(self) -> None:
    """Click the Open / Save button."""
    # IDOK == "1": the dialog's default accept button (Open/Save/Otwórz/…).
    if self._click_by_auto_id("1"):
        return
    confirm_titles = ("Open", "Save", "Otwórz", "Zapisz", "OK")
    for title in confirm_titles:
        try:
            btn = self._win.child_window(title=title, control_type="Button")
            btn.click_input()
            return
        except Exception:
            continue
    # If the dialog already closed (e.g. submitted via Enter in the address
    # bar fallback), there is nothing left to confirm — treat as success.
    if self._dialog_gone():
        return
    raise RuntimeError("Could not find a confirm button in the file dialog")

cancel

cancel() -> None

Click the Cancel button.

Source code in src\dolphin_desktop\_dialogs.py
def cancel(self) -> None:
    """Click the Cancel button."""
    # IDCANCEL == "2".
    if self._click_by_auto_id("2"):
        return
    for title in ("Cancel", "Anuluj"):
        try:
            btn = self._win.child_window(title=title, control_type="Button")
            btn.click_input()
            return
        except Exception:
            continue
    if self._dialog_gone():
        return
    raise RuntimeError("Could not find a Cancel button in the file dialog")

ImageBackend

Bases: Backend

Image / template-matching backend - pixel-level fallback (requires [vision]).

Source code in src\dolphin_desktop\_backend.py
class ImageBackend(Backend):
    """Image / template-matching backend - pixel-level fallback (requires [vision])."""

    id = "image"
    platform = "any"

    def find_element(self, parent: Any, criteria: dict[str, Any]) -> Any:
        from ._image import ImageLocator

        template = criteria.get("template")
        if not template:
            raise ValueError("ImageBackend.find_element requires criteria['template']")
        return ImageLocator(template, threshold=criteria.get("threshold", 0.8))

    def click(self, element: Any, *, button: str = "left") -> None:
        element.click()

    def type_text(self, element: Any, text: str) -> None:
        element.click()
        from ._keyboard import Keyboard

        Keyboard.type(text)

    def get_tree(self, root: Any, *, depth: int | None = None) -> dict[str, Any]:
        return {"name": "screen", "role": "image", "class": "", "children": []}

    def screenshot(self, element: Any | None = None) -> Any:
        from ._image import Screen

        return Screen.screenshot()

    def is_available(self) -> bool:
        try:
            import cv2  # noqa: F401

            return True
        except ImportError:
            return False

ImageLocator

Locate a template image on screen using OpenCV template matching.

Parameters:

Name Type Description Default
template str | Path

Path to the template PNG/BMP/JPG image.

required
threshold float

Minimum match confidence (0-1, default 0.85).

0.85
scales list[float] | None

List of scale factors to try during multi-scale matching (e.g. [0.8, 1.0, 1.2]). When None only scale 1.0 is used.

None
region tuple[int, int, int, int] | None

Default bounding box (left, top, right, bottom) in screen coordinates to restrict the search area. Each method's own region parameter overrides this default.

None
Source code in src\dolphin_desktop\_image.py
class ImageLocator:
    """Locate a template image on screen using OpenCV template matching.

    Parameters
    ----------
    template:
        Path to the template PNG/BMP/JPG image.
    threshold:
        Minimum match confidence (0-1, default 0.85).
    scales:
        List of scale factors to try during multi-scale matching
        (e.g. ``[0.8, 1.0, 1.2]``).  When ``None`` only scale 1.0 is used.
    region:
        Default bounding box ``(left, top, right, bottom)`` in screen
        coordinates to restrict the search area.  Each method's own *region*
        parameter overrides this default.
    """

    def __init__(
        self,
        template: str | Path,
        threshold: float = 0.85,
        *,
        scales: list[float] | None = None,
        region: tuple[int, int, int, int] | None = None,
    ) -> None:
        self._template_path = Path(template)
        self._threshold = threshold
        self._scales: list[float] = scales if scales is not None else [1.0]
        self._region = region

    def _load_template(self):  # type: ignore[return]
        """Load the template image as a cv2 array."""
        cv2 = _require_cv2()
        tmpl = cv2.imread(str(self._template_path))
        if tmpl is None:
            raise FileNotFoundError(f"Template image not found: {self._template_path}")
        return tmpl

    def _match(
        self,
        screen,
        tmpl,
        scale: float,
    ) -> tuple[tuple[int, int] | None, int, int, float]:
        """Try matching *tmpl* at *scale* against *screen*.

        Returns ``(match_loc, tw, th, score)`` or ``(None, tw, th, score)``.
        *match_loc* is the top-left corner of the match in the (possibly cropped) screen.
        """
        cv2 = _require_cv2()
        if scale != 1.0:
            h, w = tmpl.shape[:2]
            new_w = max(1, int(w * scale))
            new_h = max(1, int(h * scale))
            tmpl_s = cv2.resize(tmpl, (new_w, new_h))
        else:
            tmpl_s = tmpl

        th, tw = tmpl_s.shape[:2]
        if th > screen.shape[0] or tw > screen.shape[1]:
            return None, tw, th, 0.0

        if _is_low_variance_image(screen) or _is_low_variance_image(tmpl_s):
            result = cv2.matchTemplate(screen, tmpl_s, cv2.TM_SQDIFF_NORMED)
            min_val, _, min_loc, _ = cv2.minMaxLoc(result)
            score = 1.0 - min_val
            if score < self._threshold:
                return None, tw, th, score
            return min_loc, tw, th, score

        result = cv2.matchTemplate(screen, tmpl_s, cv2.TM_CCOEFF_NORMED)
        _, max_val, _, max_loc = cv2.minMaxLoc(result)
        if max_val < self._threshold:
            return None, tw, th, max_val
        return max_loc, tw, th, max_val

    def _find_impl(
        self,
        effective_region: tuple[int, int, int, int] | None,
    ) -> tuple[int, int, int, int] | None:
        """Return ``(cx, cy, tw, th)`` of the best match or None."""
        tmpl = self._load_template()
        screen = _pil_to_cv(_grab(effective_region))
        rx, ry = (effective_region[0], effective_region[1]) if effective_region else (0, 0)

        best_val = -1.0
        best: tuple[int, int, int, int] | None = None

        for scale in self._scales:
            loc, tw, th, val = self._match(screen, tmpl, scale)
            if loc is not None and val > best_val:
                best_val = val
                cx = loc[0] + tw // 2 + rx
                cy = loc[1] + th // 2 + ry
                best = (cx, cy, tw, th)

        return best

    def find(
        self,
        region: tuple[int, int, int, int] | None = None,
    ) -> tuple[int, int] | None:
        """Return ``(cx, cy)`` of the best template match, or None."""
        effective = region if region is not None else self._region
        result = self._find_impl(effective)
        return (result[0], result[1]) if result is not None else None

    def find_with_size(
        self,
        region: tuple[int, int, int, int] | None = None,
    ) -> tuple[int, int, int, int] | None:
        """Return ``(cx, cy, template_w, template_h)`` or None.

        Used internally by the failover chain so that ``_ImageElement``
        has the correct bounding box.
        """
        effective = region if region is not None else self._region
        return self._find_impl(effective)

    def find_all(
        self,
        region: tuple[int, int, int, int] | None = None,
    ) -> list[tuple[int, int]]:
        """Return all match centres above threshold."""
        cv2 = _require_cv2()
        import numpy as np  # type: ignore[import-untyped]

        effective = region if region is not None else self._region
        tmpl = self._load_template()
        screen = _pil_to_cv(_grab(effective))
        rx, ry = (effective[0], effective[1]) if effective else (0, 0)

        points: list[tuple[int, int]] = []

        # For find_all use only scale 1.0 or the first scale (multi-scale deduplication
        # across scales is complex and rarely needed for enumeration).
        scale = self._scales[0]
        if scale != 1.0:
            h, w = tmpl.shape[:2]
            tmpl = cv2.resize(tmpl, (max(1, int(w * scale)), max(1, int(h * scale))))

        th, tw = tmpl.shape[:2]
        if th > screen.shape[0] or tw > screen.shape[1]:
            return points

        if _is_low_variance_image(screen) or _is_low_variance_image(tmpl):
            result = cv2.matchTemplate(screen, tmpl, cv2.TM_SQDIFF_NORMED)
            locations = np.where((1.0 - result) >= self._threshold)
        else:
            result = cv2.matchTemplate(screen, tmpl, cv2.TM_CCOEFF_NORMED)
            locations = np.where(result >= self._threshold)
        for pt in zip(locations[1], locations[0], strict=False):
            cx = pt[0] + tw // 2 + rx
            cy = pt[1] + th // 2 + ry
            points.append((cx, cy))
        return points

    def click(self, region: tuple[int, int, int, int] | None = None) -> None:
        """Click the template match centre."""
        import pywinauto.mouse as _mouse  # type: ignore[import-untyped]

        pt = self.find(region)
        if pt is None:
            raise RuntimeError(
                f"Template {self._template_path} not found on screen (threshold={self._threshold})"
            )
        _mouse.click(coords=pt)

    def double_click(self, region: tuple[int, int, int, int] | None = None) -> None:
        """Double-click the template match centre."""
        import pywinauto.mouse as _mouse  # type: ignore[import-untyped]

        pt = self.find(region)
        if pt is None:
            raise RuntimeError(
                f"Template {self._template_path} not found on screen (threshold={self._threshold})"
            )
        _mouse.double_click(coords=pt)

    def wait_for(
        self,
        timeout: float = 10.0,
        region: tuple[int, int, int, int] | None = None,
    ) -> tuple[int, int]:
        """Poll every 0.5 s until the template appears; raise on timeout."""
        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            pt = self.find(region)
            if pt is not None:
                return pt
            time.sleep(0.5)
        raise RuntimeError(f"Template {self._template_path} not found within {timeout}s")

    def exists(
        self,
        timeout: float = 0.0,
        region: tuple[int, int, int, int] | None = None,
    ) -> bool:
        """Return True if the template is found within *timeout* seconds."""
        if timeout <= 0:
            return self.find(region) is not None
        try:
            self.wait_for(timeout=timeout, region=region)
            return True
        except RuntimeError:
            return False

    def as_element(
        self,
        region: tuple[int, int, int, int] | None = None,
    ) -> _ImageElement | None:
        """Find the template and return an _ImageElement proxy, or None."""
        result = self.find_with_size(region)
        if result is None:
            return None
        cx, cy, tw, th = result
        return _ImageElement(cx, cy, tw, th)

find

find(
    region: tuple[int, int, int, int] | None = None,
) -> tuple[int, int] | None

Return (cx, cy) of the best template match, or None.

Source code in src\dolphin_desktop\_image.py
def find(
    self,
    region: tuple[int, int, int, int] | None = None,
) -> tuple[int, int] | None:
    """Return ``(cx, cy)`` of the best template match, or None."""
    effective = region if region is not None else self._region
    result = self._find_impl(effective)
    return (result[0], result[1]) if result is not None else None

find_with_size

find_with_size(
    region: tuple[int, int, int, int] | None = None,
) -> tuple[int, int, int, int] | None

Return (cx, cy, template_w, template_h) or None.

Used internally by the failover chain so that _ImageElement has the correct bounding box.

Source code in src\dolphin_desktop\_image.py
def find_with_size(
    self,
    region: tuple[int, int, int, int] | None = None,
) -> tuple[int, int, int, int] | None:
    """Return ``(cx, cy, template_w, template_h)`` or None.

    Used internally by the failover chain so that ``_ImageElement``
    has the correct bounding box.
    """
    effective = region if region is not None else self._region
    return self._find_impl(effective)

find_all

find_all(
    region: tuple[int, int, int, int] | None = None,
) -> list[tuple[int, int]]

Return all match centres above threshold.

Source code in src\dolphin_desktop\_image.py
def find_all(
    self,
    region: tuple[int, int, int, int] | None = None,
) -> list[tuple[int, int]]:
    """Return all match centres above threshold."""
    cv2 = _require_cv2()
    import numpy as np  # type: ignore[import-untyped]

    effective = region if region is not None else self._region
    tmpl = self._load_template()
    screen = _pil_to_cv(_grab(effective))
    rx, ry = (effective[0], effective[1]) if effective else (0, 0)

    points: list[tuple[int, int]] = []

    # For find_all use only scale 1.0 or the first scale (multi-scale deduplication
    # across scales is complex and rarely needed for enumeration).
    scale = self._scales[0]
    if scale != 1.0:
        h, w = tmpl.shape[:2]
        tmpl = cv2.resize(tmpl, (max(1, int(w * scale)), max(1, int(h * scale))))

    th, tw = tmpl.shape[:2]
    if th > screen.shape[0] or tw > screen.shape[1]:
        return points

    if _is_low_variance_image(screen) or _is_low_variance_image(tmpl):
        result = cv2.matchTemplate(screen, tmpl, cv2.TM_SQDIFF_NORMED)
        locations = np.where((1.0 - result) >= self._threshold)
    else:
        result = cv2.matchTemplate(screen, tmpl, cv2.TM_CCOEFF_NORMED)
        locations = np.where(result >= self._threshold)
    for pt in zip(locations[1], locations[0], strict=False):
        cx = pt[0] + tw // 2 + rx
        cy = pt[1] + th // 2 + ry
        points.append((cx, cy))
    return points

click

click(
    region: tuple[int, int, int, int] | None = None,
) -> None

Click the template match centre.

Source code in src\dolphin_desktop\_image.py
def click(self, region: tuple[int, int, int, int] | None = None) -> None:
    """Click the template match centre."""
    import pywinauto.mouse as _mouse  # type: ignore[import-untyped]

    pt = self.find(region)
    if pt is None:
        raise RuntimeError(
            f"Template {self._template_path} not found on screen (threshold={self._threshold})"
        )
    _mouse.click(coords=pt)

double_click

double_click(
    region: tuple[int, int, int, int] | None = None,
) -> None

Double-click the template match centre.

Source code in src\dolphin_desktop\_image.py
def double_click(self, region: tuple[int, int, int, int] | None = None) -> None:
    """Double-click the template match centre."""
    import pywinauto.mouse as _mouse  # type: ignore[import-untyped]

    pt = self.find(region)
    if pt is None:
        raise RuntimeError(
            f"Template {self._template_path} not found on screen (threshold={self._threshold})"
        )
    _mouse.double_click(coords=pt)

wait_for

wait_for(
    timeout: float = 10.0,
    region: tuple[int, int, int, int] | None = None,
) -> tuple[int, int]

Poll every 0.5 s until the template appears; raise on timeout.

Source code in src\dolphin_desktop\_image.py
def wait_for(
    self,
    timeout: float = 10.0,
    region: tuple[int, int, int, int] | None = None,
) -> tuple[int, int]:
    """Poll every 0.5 s until the template appears; raise on timeout."""
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        pt = self.find(region)
        if pt is not None:
            return pt
        time.sleep(0.5)
    raise RuntimeError(f"Template {self._template_path} not found within {timeout}s")

exists

exists(
    timeout: float = 0.0,
    region: tuple[int, int, int, int] | None = None,
) -> bool

Return True if the template is found within timeout seconds.

Source code in src\dolphin_desktop\_image.py
def exists(
    self,
    timeout: float = 0.0,
    region: tuple[int, int, int, int] | None = None,
) -> bool:
    """Return True if the template is found within *timeout* seconds."""
    if timeout <= 0:
        return self.find(region) is not None
    try:
        self.wait_for(timeout=timeout, region=region)
        return True
    except RuntimeError:
        return False

as_element

as_element(
    region: tuple[int, int, int, int] | None = None,
) -> _ImageElement | None

Find the template and return an _ImageElement proxy, or None.

Source code in src\dolphin_desktop\_image.py
def as_element(
    self,
    region: tuple[int, int, int, int] | None = None,
) -> _ImageElement | None:
    """Find the template and return an _ImageElement proxy, or None."""
    result = self.find_with_size(region)
    if result is None:
        return None
    cx, cy, tw, th = result
    return _ImageElement(cx, cy, tw, th)

JavaAccessBridge

Utilities for enabling and checking the Java Access Bridge.

Source code in src\dolphin_desktop\_java.py
class JavaAccessBridge:
    """Utilities for enabling and checking the Java Access Bridge."""

    @staticmethod
    def is_enabled() -> bool:
        """Return True if Java Access Bridge appears to be active."""
        import winreg  # type: ignore[import-untyped]

        # Check the Accessibility ATs registry key for jabswitch
        try:
            key = winreg.OpenKey(
                winreg.HKEY_CURRENT_USER,
                r"Software\Microsoft\Windows NT\CurrentVersion\Accessibility\ATs",
            )
            with key:
                i = 0
                while True:
                    try:
                        name, _value, _type = winreg.EnumValue(key, i)
                        if "jabswitch" in name.lower() or "jab" in name.lower():
                            return True
                        i += 1
                    except OSError:
                        break
        except OSError:
            pass

        # Check the Configuration value (set by jabswitch /enable on modern JDK)
        try:
            key = winreg.OpenKey(
                winreg.HKEY_CURRENT_USER,
                r"Software\Microsoft\Windows NT\CurrentVersion\Accessibility",
            )
            with key:
                try:
                    val, _ = winreg.QueryValueEx(key, "Configuration")
                    low = val.lower()
                    if "javaaccessbridge" in low or "oracle_javaaccessbridge" in low:
                        return True
                except OSError:
                    pass
        except OSError:
            pass

        # Fallback: check for windowsaccessbridge-64.dll in common locations
        java_home = JavaAccessBridge.java_home()
        if java_home:
            for name in ("WindowsAccessBridge-64.dll", "windowsaccessbridge-64.dll"):
                if os.path.isfile(os.path.join(java_home, "bin", name)):
                    return True

        windir = os.environ.get("WINDIR", r"C:\Windows")
        for sub in ("SysWOW64", "System32"):
            for name in ("WindowsAccessBridge-64.dll", "windowsaccessbridge-64.dll"):
                if os.path.isfile(os.path.join(windir, sub, name)):
                    return True

        return False

    @staticmethod
    def enable() -> None:
        """Run jabswitch.exe /enable; raises RuntimeError on failure."""
        java_home = JavaAccessBridge.java_home()
        jabswitch = "jabswitch.exe"
        if java_home:
            candidate = os.path.join(java_home, "bin", "jabswitch.exe")
            if os.path.isfile(candidate):
                jabswitch = candidate
        try:
            result = subprocess.run(
                [jabswitch, "/enable"],
                capture_output=True,
                timeout=15,
            )
        except FileNotFoundError as exc:
            raise RuntimeError(
                "jabswitch.exe not found — ensure a JRE/JDK with Java Access Bridge is "
                "installed and jabswitch.exe is on PATH or JAVA_HOME is set."
            ) from exc
        if result.returncode != 0:
            stderr = result.stderr.decode(errors="replace")
            raise RuntimeError(f"jabswitch.exe /enable failed (exit {result.returncode}): {stderr}")

    @staticmethod
    def ensure_enabled() -> None:
        """Enable Java Access Bridge only if it is not already enabled."""
        if not JavaAccessBridge.is_enabled():
            JavaAccessBridge.enable()

    @staticmethod
    def java_home() -> str | None:
        """Return the JRE/JDK home directory, or None if not found."""
        env_home = os.environ.get("JAVA_HOME")
        if env_home and os.path.isdir(env_home):
            return env_home

        import winreg  # type: ignore[import-untyped]

        for root in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
            for sub in (
                r"SOFTWARE\JavaSoft\Java Runtime Environment",
                r"SOFTWARE\JavaSoft\JRE",
                r"SOFTWARE\JavaSoft\JDK",
            ):
                try:
                    key = winreg.OpenKey(root, sub)
                    with key:
                        try:
                            current, _ = winreg.QueryValueEx(key, "CurrentVersion")
                            ver_key = winreg.OpenKey(key, current)
                            with ver_key:
                                home, _ = winreg.QueryValueEx(ver_key, "JavaHome")
                                if home and os.path.isdir(home):
                                    return home
                        except OSError:
                            pass
                except OSError:
                    pass

        try:
            result = subprocess.run(
                ["where.exe", "java"],
                capture_output=True,
                timeout=5,
            )
            if result.returncode == 0:
                java_exe = result.stdout.decode(errors="replace").splitlines()[0].strip()
                home = os.path.dirname(os.path.dirname(java_exe))
                if os.path.isdir(home):
                    return home
        except Exception:
            pass

        return None

is_enabled staticmethod

is_enabled() -> bool

Return True if Java Access Bridge appears to be active.

Source code in src\dolphin_desktop\_java.py
@staticmethod
def is_enabled() -> bool:
    """Return True if Java Access Bridge appears to be active."""
    import winreg  # type: ignore[import-untyped]

    # Check the Accessibility ATs registry key for jabswitch
    try:
        key = winreg.OpenKey(
            winreg.HKEY_CURRENT_USER,
            r"Software\Microsoft\Windows NT\CurrentVersion\Accessibility\ATs",
        )
        with key:
            i = 0
            while True:
                try:
                    name, _value, _type = winreg.EnumValue(key, i)
                    if "jabswitch" in name.lower() or "jab" in name.lower():
                        return True
                    i += 1
                except OSError:
                    break
    except OSError:
        pass

    # Check the Configuration value (set by jabswitch /enable on modern JDK)
    try:
        key = winreg.OpenKey(
            winreg.HKEY_CURRENT_USER,
            r"Software\Microsoft\Windows NT\CurrentVersion\Accessibility",
        )
        with key:
            try:
                val, _ = winreg.QueryValueEx(key, "Configuration")
                low = val.lower()
                if "javaaccessbridge" in low or "oracle_javaaccessbridge" in low:
                    return True
            except OSError:
                pass
    except OSError:
        pass

    # Fallback: check for windowsaccessbridge-64.dll in common locations
    java_home = JavaAccessBridge.java_home()
    if java_home:
        for name in ("WindowsAccessBridge-64.dll", "windowsaccessbridge-64.dll"):
            if os.path.isfile(os.path.join(java_home, "bin", name)):
                return True

    windir = os.environ.get("WINDIR", r"C:\Windows")
    for sub in ("SysWOW64", "System32"):
        for name in ("WindowsAccessBridge-64.dll", "windowsaccessbridge-64.dll"):
            if os.path.isfile(os.path.join(windir, sub, name)):
                return True

    return False

enable staticmethod

enable() -> None

Run jabswitch.exe /enable; raises RuntimeError on failure.

Source code in src\dolphin_desktop\_java.py
@staticmethod
def enable() -> None:
    """Run jabswitch.exe /enable; raises RuntimeError on failure."""
    java_home = JavaAccessBridge.java_home()
    jabswitch = "jabswitch.exe"
    if java_home:
        candidate = os.path.join(java_home, "bin", "jabswitch.exe")
        if os.path.isfile(candidate):
            jabswitch = candidate
    try:
        result = subprocess.run(
            [jabswitch, "/enable"],
            capture_output=True,
            timeout=15,
        )
    except FileNotFoundError as exc:
        raise RuntimeError(
            "jabswitch.exe not found — ensure a JRE/JDK with Java Access Bridge is "
            "installed and jabswitch.exe is on PATH or JAVA_HOME is set."
        ) from exc
    if result.returncode != 0:
        stderr = result.stderr.decode(errors="replace")
        raise RuntimeError(f"jabswitch.exe /enable failed (exit {result.returncode}): {stderr}")

ensure_enabled staticmethod

ensure_enabled() -> None

Enable Java Access Bridge only if it is not already enabled.

Source code in src\dolphin_desktop\_java.py
@staticmethod
def ensure_enabled() -> None:
    """Enable Java Access Bridge only if it is not already enabled."""
    if not JavaAccessBridge.is_enabled():
        JavaAccessBridge.enable()

java_home staticmethod

java_home() -> str | None

Return the JRE/JDK home directory, or None if not found.

Source code in src\dolphin_desktop\_java.py
@staticmethod
def java_home() -> str | None:
    """Return the JRE/JDK home directory, or None if not found."""
    env_home = os.environ.get("JAVA_HOME")
    if env_home and os.path.isdir(env_home):
        return env_home

    import winreg  # type: ignore[import-untyped]

    for root in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
        for sub in (
            r"SOFTWARE\JavaSoft\Java Runtime Environment",
            r"SOFTWARE\JavaSoft\JRE",
            r"SOFTWARE\JavaSoft\JDK",
        ):
            try:
                key = winreg.OpenKey(root, sub)
                with key:
                    try:
                        current, _ = winreg.QueryValueEx(key, "CurrentVersion")
                        ver_key = winreg.OpenKey(key, current)
                        with ver_key:
                            home, _ = winreg.QueryValueEx(ver_key, "JavaHome")
                            if home and os.path.isdir(home):
                                return home
                    except OSError:
                        pass
            except OSError:
                pass

    try:
        result = subprocess.run(
            ["where.exe", "java"],
            capture_output=True,
            timeout=5,
        )
        if result.returncode == 0:
            java_exe = result.stdout.decode(errors="replace").splitlines()[0].strip()
            home = os.path.dirname(os.path.dirname(java_exe))
            if os.path.isdir(home):
                return home
    except Exception:
        pass

    return None

Keyboard

Send keystrokes to the currently focused window.

Key syntax follows pywinauto conventions::

Keyboard.press("^a")          # Ctrl+A — select all
Keyboard.press("^s")          # Ctrl+S — save
Keyboard.press("{ENTER}")     # Enter key
Keyboard.press("+{F10}")      # Shift+F10 — context menu
Keyboard.type("Hello World")  # type plain text with spaces
Keyboard.hotkey("ctrl", "c")  # Ctrl+C — copy

Special key tokens: {ENTER}, {TAB}, {ESC}, {BACK}, {DELETE}, {F1}{F12}, {UP}, {DOWN}, {HOME}, {END}.

Modifiers: ^ ctrl, + shift, % alt.

Source code in src\dolphin_desktop\_keyboard.py
class Keyboard:
    """Send keystrokes to the currently focused window.

    Key syntax follows pywinauto conventions::

        Keyboard.press("^a")          # Ctrl+A — select all
        Keyboard.press("^s")          # Ctrl+S — save
        Keyboard.press("{ENTER}")     # Enter key
        Keyboard.press("+{F10}")      # Shift+F10 — context menu
        Keyboard.type("Hello World")  # type plain text with spaces
        Keyboard.hotkey("ctrl", "c")  # Ctrl+C — copy

    Special key tokens: ``{ENTER}``, ``{TAB}``, ``{ESC}``, ``{BACK}``,
    ``{DELETE}``, ``{F1}``…``{F12}``, ``{UP}``, ``{DOWN}``, ``{HOME}``, ``{END}``.

    Modifiers: ``^`` ctrl, ``+`` shift, ``%`` alt.
    """

    @staticmethod
    def press(keys: str) -> None:
        """Send keys using pywinauto key syntax."""
        _send_keys(keys)

    @staticmethod
    def type(text: str) -> None:
        """Type plain text including spaces and special characters."""
        _send_keys(text, with_spaces=True, with_tabs=True, with_newlines=True)

    @staticmethod
    def hotkey(*keys: str) -> None:
        """Press a key combination, e.g. hotkey('ctrl', 'c')."""
        modifiers = {"ctrl": "^", "shift": "+", "alt": "%"}
        combo = ""
        for k in keys:
            combo += modifiers.get(k.lower(), f"{{{k.upper()}}}")
        _send_keys(combo)

press staticmethod

press(keys: str) -> None

Send keys using pywinauto key syntax.

Source code in src\dolphin_desktop\_keyboard.py
@staticmethod
def press(keys: str) -> None:
    """Send keys using pywinauto key syntax."""
    _send_keys(keys)

type staticmethod

type(text: str) -> None

Type plain text including spaces and special characters.

Source code in src\dolphin_desktop\_keyboard.py
@staticmethod
def type(text: str) -> None:
    """Type plain text including spaces and special characters."""
    _send_keys(text, with_spaces=True, with_tabs=True, with_newlines=True)

hotkey staticmethod

hotkey(*keys: str) -> None

Press a key combination, e.g. hotkey('ctrl', 'c').

Source code in src\dolphin_desktop\_keyboard.py
@staticmethod
def hotkey(*keys: str) -> None:
    """Press a key combination, e.g. hotkey('ctrl', 'c')."""
    modifiers = {"ctrl": "^", "shift": "+", "alt": "%"}
    combo = ""
    for k in keys:
        combo += modifiers.get(k.lower(), f"{{{k.upper()}}}")
    _send_keys(combo)

LinuxATSPIBackend

Bases: Backend

Linux AT-SPI2 accessibility backend - reserved, not yet implemented.

Source code in src\dolphin_desktop\_backend.py
class LinuxATSPIBackend(Backend):
    """Linux AT-SPI2 accessibility backend - reserved, not yet implemented."""

    id = "linux"
    platform = "linux"

    def _raise(self) -> None:
        raise NotImplementedError(_STUB_MSG.format(name="LinuxATSPIBackend"))

    def find_element(self, parent: Any, criteria: dict[str, Any]) -> Any:
        self._raise()

    def click(self, element: Any, *, button: str = "left") -> None:
        self._raise()

    def type_text(self, element: Any, text: str) -> None:
        self._raise()

    def get_tree(self, root: Any, *, depth: int | None = None) -> dict[str, Any]:
        self._raise()
        return {}

    def screenshot(self, element: Any | None = None) -> Any:
        self._raise()

    def is_available(self) -> bool:
        return False

ListBox

Bases: Element

A list box control.

Usage::

lb = window.list_box()
lb.select_item("Option A")
names = lb.items()
selected = lb.selected_items()
Source code in src\dolphin_desktop\_element.py
class ListBox(Element):
    """A list box control.

    Usage::

        lb = window.list_box()
        lb.select_item("Option A")
        names = lb.items()
        selected = lb.selected_items()
    """

    def items(self) -> list[str]:
        """Return the text of all list items."""
        element = self._resolve()
        children = element.children(control_type="ListItem")
        return [c.window_text() for c in children]

    def selected_items(self) -> list[str]:
        """Return the text of currently selected list items."""
        element = self._resolve()
        result = []
        for child in element.children(control_type="ListItem"):
            try:
                if child.is_selected():
                    result.append(child.window_text())
            except Exception:
                # is_selected() not available — fall back to toggle state
                try:
                    if child.get_toggle_state():
                        result.append(child.window_text())
                except Exception:
                    pass
        return result

items

items() -> list[str]

Return the text of all list items.

Source code in src\dolphin_desktop\_element.py
def items(self) -> list[str]:
    """Return the text of all list items."""
    element = self._resolve()
    children = element.children(control_type="ListItem")
    return [c.window_text() for c in children]

selected_items

selected_items() -> list[str]

Return the text of currently selected list items.

Source code in src\dolphin_desktop\_element.py
def selected_items(self) -> list[str]:
    """Return the text of currently selected list items."""
    element = self._resolve()
    result = []
    for child in element.children(control_type="ListItem"):
        try:
            if child.is_selected():
                result.append(child.window_text())
        except Exception:
            # is_selected() not available — fall back to toggle state
            try:
                if child.get_toggle_state():
                    result.append(child.window_text())
            except Exception:
                pass
    return result

Locator

Represents a way to find one or more UI elements.

Locators are lazy — they do not search for elements until an action or assertion method is called. This mirrors Playwright's Locator API.

Usage::

btn = window.get_by_title("OK", control_type="Button")
btn.click()

edit = window.get_by_role("Edit")
edit.type_text("hello")
assert edit.text() == "hello"
Source code in src\dolphin_desktop\_locator.py
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
class Locator:
    """Represents a way to find one or more UI elements.

    Locators are lazy — they do not search for elements until an action or
    assertion method is called.  This mirrors Playwright's Locator API.

    Usage::

        btn = window.get_by_title("OK", control_type="Button")
        btn.click()

        edit = window.get_by_role("Edit")
        edit.type_text("hello")
        assert edit.text() == "hello"
    """

    def __init__(
        self,
        parent: Window | Locator,
        **criteria: Any,
    ) -> None:
        self._parent = parent
        self._fallback: list[dict[str, Any]] = list(criteria.pop("fallback", None) or [])
        self._image_fallback: Any = criteria.pop("image_fallback", None)
        self._criteria = criteria
        self._timeout: float = _get_timeout()

    # ------------------------------------------------------------------
    # Configuration
    # ------------------------------------------------------------------

    def timeout(self, seconds: float) -> Locator:
        """Return a new Locator with a different timeout (does not mutate self)."""
        clone = Locator(
            self._parent,
            fallback=self._fallback,
            image_fallback=self._image_fallback,
            **self._criteria,
        )
        clone._timeout = seconds
        return clone

    # ------------------------------------------------------------------
    # Chaining
    # ------------------------------------------------------------------

    def locator(self, **criteria: Any) -> Locator:
        """Find a descendant element matching *criteria* inside this element."""
        return Locator(self, **criteria)

    def nth(self, index: int) -> Locator:
        """Select the nth match (0-based) from a set of matching elements."""
        return Locator(self._parent, **{**self._criteria, "found_index": index})

    # ------------------------------------------------------------------
    # Resolution (internal)
    # ------------------------------------------------------------------

    def _get_parent_spec(self) -> Any:
        if isinstance(self._parent, Locator):
            return self._parent._resolve()
        return self._parent._get_spec()

    def _resolve(self) -> Any:
        """Find and wait for the element, raise on timeout."""
        parent_spec = self._get_parent_spec()

        # Try primary selector
        primary_exc: Exception | None = None
        try:
            spec = parent_spec.child_window(**self._criteria)
            spec.wait("exists visible", timeout=self._timeout)
            return spec
        except Exception as exc:
            primary_exc = exc

        # Try fallbacks in definition order (immediate check — primary already timed out)
        from . import _selfheal

        for fb in self._fallback:
            try:
                fb_spec = parent_spec.child_window(**fb)
                fb_spec.wait("visible", timeout=0)
                _selfheal.record_fallback(self._criteria, fb)
                return fb_spec
            except Exception:
                continue

        # Last resort: image-based fallback
        if self._image_fallback is not None:
            try:
                result = self._image_fallback.find_with_size()
                if result is not None:
                    cx, cy, tw, th = result
                    from ._image import _ImageElement

                    _selfheal.record_fallback(
                        self._criteria,
                        {"image": str(self._image_fallback._template_path)},
                    )
                    return _ImageElement(cx, cy, tw, th)
            except Exception:
                pass

        # TreeWalker fallback: IUIAutomation::FindAll misses some elements exposed
        # via TreeWalker (e.g. ToolbarWindow32 button children in Notepad++).
        tree_result = _tree_walk_find(parent_spec, self._criteria)
        if tree_result is not None:
            return tree_result

        # All selectors exhausted
        tree = _dump_tree(parent_spec)
        raise ElementNotFoundError(
            f"Element {self._criteria!r} not found after {self._timeout}s. Last seen tree:\n{tree}"
        ) from primary_exc

    # ------------------------------------------------------------------
    # Actions
    # ------------------------------------------------------------------

    def click(self) -> Locator:
        element = None
        try:
            element = self._resolve()
            element.click_input()
        except Exception as exc:
            _trace_step("click", self._criteria, element=element, error=str(exc))
            raise
        _trace_step("click", self._criteria, element=element)
        return self

    def double_click(self) -> Locator:
        element = None
        try:
            element = self._resolve()
            element.double_click_input()
        except Exception as exc:
            _trace_step("double_click", self._criteria, element=element, error=str(exc))
            raise
        _trace_step("double_click", self._criteria, element=element)
        return self

    def right_click(self) -> Locator:
        element = None
        try:
            element = self._resolve()
            element.right_click_input()
        except Exception as exc:
            _trace_step("right_click", self._criteria, element=element, error=str(exc))
            raise
        _trace_step("right_click", self._criteria, element=element)
        return self

    def type_text(
        self,
        text: str,
        *,
        with_spaces: bool = True,
        pause: float = 0.05,
    ) -> Locator:
        """Type text character by character (sends WM_CHAR events).

        The default pause of 0.05 s between keystrokes matches pywinauto's own
        default and prevents missed/doubled keys in modern WinUI/XAML controls.
        """
        element = None
        try:
            element = self._resolve()
            element.type_keys(text, with_spaces=with_spaces, pause=pause)
        except Exception as exc:
            _trace_step("type_text", self._criteria, element=element, error=str(exc))
            raise
        _trace_step("type_text", self._criteria, element=element)
        return self

    def set_text(self, text: str) -> Locator:
        """Replace the entire text content of an edit control.

        Falls back to select-all + keyboard input for Document/RichEdit controls
        (e.g. Windows 11 Notepad) that do not support IValueProvider.SetValue.
        """
        element = None
        try:
            element = self._resolve()
            try:
                element.set_edit_text(text)
                _trace_step("set_text", self._criteria, element=element)
                return self
            except Exception:
                pass
            element.set_focus()
            time.sleep(0.05)
            _send_keys("^a")
            if text:
                element.type_keys(text, with_spaces=True, pause=0.05)
            else:
                _send_keys("{DELETE}")
        except Exception as exc:
            _trace_step("set_text", self._criteria, element=element, error=str(exc))
            raise
        _trace_step("set_text", self._criteria, element=element)
        return self

    def clear(self) -> Locator:
        """Clear the text of an edit control."""
        element = None
        try:
            element = self._resolve()
            try:
                element.set_edit_text("")
                _trace_step("clear", self._criteria, element=element)
                return self
            except Exception:
                pass
            element.set_focus()
            time.sleep(0.05)
            _send_keys("^a{DELETE}")
        except Exception as exc:
            _trace_step("clear", self._criteria, element=element, error=str(exc))
            raise
        _trace_step("clear", self._criteria, element=element)
        return self

    def press_key(self, key: str) -> Locator:
        """Send a key sequence to the element using pywinauto key syntax."""
        element = None
        try:
            element = self._resolve()
            element.type_keys(key)
        except Exception as exc:
            _trace_step("press_key", self._criteria, element=element, error=str(exc))
            raise
        _trace_step("press_key", self._criteria, element=element)
        return self

    def select_item(self, item: str | int) -> Locator:
        """Select an item in a list/combo box by text or 0-based index."""
        element = None
        try:
            element = self._resolve()
        except Exception as exc:
            _trace_step("select_item", self._criteria, error=str(exc))
            raise
        try:
            # Win32 backend and UIA ComboBox expose select(item).
            element.select(item)
        except TypeError:
            # UIA ListBox: UIAWrapper.select() takes no arguments.
            # Find the child ListItem and call select() on it directly.
            if isinstance(item, int):
                element.children(control_type="ListItem")[item].select()
            else:
                element.child_window(title=item, control_type="ListItem").wrapper_object().select()
        except ValueError:
            # WinForms ComboBox (UIA): pywinauto's select() raises ValueError from
            # selected_index() when no item is pre-selected and ISelectionItemProvider
            # is unavailable on child elements.  Fall back to expand → invoke.
            if isinstance(item, int):
                try:
                    element.expand()
                    time.sleep(0.05)
                except Exception:
                    pass
                items = element.children(control_type="ListItem")
                if not items:
                    # WinForms pattern: items live under a List child
                    lists = element.children(control_type="List")
                    if lists:
                        items = lists[0].children(control_type="ListItem")
                if items:
                    items[item].invoke()
                else:
                    element.descendants(control_type="ListItem")[item].click_input()
            else:
                element.child_window(title=item, control_type="ListItem").wrapper_object().select()
        except (IndexError, Exception):
            # Win32 combo box (e.g. VCL TUIStateAwareComboBox): not recognised by
            # pywinauto as ComboBoxWrapper → no select() method → use CB_* messages.
            try:
                import win32gui

                hwnd = element.handle
                if isinstance(item, str):
                    idx = win32gui.SendMessage(hwnd, 0x0158, -1, item)  # CB_FINDSTRINGEXACT
                    if idx == -1:
                        idx = win32gui.SendMessage(hwnd, 0x014C, -1, item)  # CB_FINDSTRING
                else:
                    idx = item
                if idx != -1:
                    win32gui.SendMessage(hwnd, 0x014E, idx, 0)  # CB_SETCURSEL
                    # Notify parent of selection change (VCL needs CBN_SELCHANGE = 1)
                    parent_hwnd = win32gui.GetParent(hwnd)
                    ctrl_id = win32gui.GetDlgCtrlID(hwnd)
                    win32gui.SendMessage(
                        parent_hwnd, 0x0111, (1 << 16) | ctrl_id, hwnd
                    )  # WM_COMMAND/CBN_SELCHANGE
                    _trace_step("select_item", self._criteria, element=element)
                    return self
            except Exception:
                pass
            # Qt ComboBox: the dropdown opens as a detached popup window in the UIA
            # tree — child_window() finds nothing.  After expand(), scan the process's
            # top-level windows for a popup that contains ListItems.
            _select_via_popup(element, item, self._timeout)
        _trace_step("select_item", self._criteria, element=element)
        return self

    def check(self) -> Locator:
        """Check a checkbox."""
        element = None
        try:
            element = self._resolve()
            if _get_check_state(element) == 0:
                _toggle_element(element)
        except Exception as exc:
            _trace_step("check", self._criteria, element=element, error=str(exc))
            raise
        _trace_step("check", self._criteria, element=element)
        return self

    def uncheck(self) -> Locator:
        """Uncheck a checkbox."""
        element = None
        try:
            element = self._resolve()
            if _get_check_state(element) != 0:
                _toggle_element(element)
        except Exception as exc:
            _trace_step("uncheck", self._criteria, element=element, error=str(exc))
            raise
        _trace_step("uncheck", self._criteria, element=element)
        return self

    def focus(self) -> Locator:
        self._resolve().set_focus()
        return self

    def scroll_into_view(self) -> Locator:
        element = self._resolve()
        # UIA wrappers have no scroll_into_view(); use the ScrollItemPattern
        # (iface_scroll_item) when available, falling back to set_focus(), which
        # also brings the element into view for most scrollable containers.
        try:
            element.iface_scroll_item.ScrollIntoView()
        except Exception:
            try:
                element.set_focus()
            except Exception:
                pass
        return self

    # ------------------------------------------------------------------
    # Queries
    # ------------------------------------------------------------------

    def text(self) -> str:
        """Return the text content of the element.

        For standard Edit controls this is window_text().  For Document/RichEdit
        controls (e.g. Windows 11 Notepad RichEditD2DPT) window_text() returns
        an empty string, so we fall back to select-all + clipboard.
        """
        element = self._resolve()
        t = element.window_text()
        if t:
            return t
        try:
            t = element.get_value()
            if t:
                return t
        except Exception:
            pass
        return _read_text_via_clipboard(element)

    def value(self) -> str:
        """Return the value of an edit/spinner control."""
        element = self._resolve()
        try:
            return element.get_value()
        except AttributeError:
            return element.window_text()

    def is_visible(self) -> bool:
        try:
            return bool(self.timeout(0)._resolve().is_visible())
        except Exception:
            return False

    def is_enabled(self) -> bool:
        try:
            return bool(self.timeout(0)._resolve().is_enabled())
        except Exception:
            return False

    def is_checked(self) -> bool:
        return _get_check_state(self._resolve()) == 1

    def exists(self, timeout: float = 0.0) -> bool:
        """Return True if the element exists (and is visible) within *timeout* seconds."""
        try:
            self.timeout(timeout)._resolve()
            return True
        except Exception:
            return False

    def bounding_box(self) -> dict[str, int]:
        """Return {left, top, right, bottom, width, height} in screen coords."""
        rect = self._resolve().rectangle()
        return {
            "left": rect.left,
            "top": rect.top,
            "right": rect.right,
            "bottom": rect.bottom,
            "width": rect.right - rect.left,
            "height": rect.bottom - rect.top,
        }

    # ------------------------------------------------------------------
    # Waiting
    # ------------------------------------------------------------------

    def wait_for(self, *, state: str = "visible", timeout: float | None = None) -> Locator:
        """Wait until the element reaches *state* ('visible', 'enabled', 'exists')."""
        t = timeout if timeout is not None else self._timeout
        parent_spec = self._get_parent_spec()
        try:
            spec = parent_spec.child_window(**self._criteria)
            spec.wait(state, timeout=t)
        except Exception as exc:
            raise WaitTimeoutError(
                f"Element {self._criteria!r} did not reach state '{state}' after {t}s"
            ) from exc
        return self

    def wait_until_hidden(self, timeout: float = 10.0) -> Locator:
        """Wait until the element is no longer visible."""
        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            if not self.is_visible():
                return self
            time.sleep(0.1)
        raise WaitTimeoutError(f"Element {self._criteria!r} still visible after {timeout}s")

    def wait_until_enabled(self, timeout: float = 10.0) -> Locator:
        """Wait until the element is enabled."""
        return self.wait_for(state="enabled", timeout=timeout)

    # ------------------------------------------------------------------
    # Screenshot
    # ------------------------------------------------------------------

    def screenshot(self, path: str | Path | None = None) -> Image:
        """Capture the element as a PIL Image, optionally saving to *path*."""
        img = self._resolve().capture_as_image()
        if path:
            img.save(path)
        return img

    # ------------------------------------------------------------------
    # Collections
    # ------------------------------------------------------------------

    def hover(self) -> Locator:
        """Move the mouse pointer over the element's centre."""
        import pywinauto.mouse as _mouse  # type: ignore[import-untyped]

        bb = self.bounding_box()
        cx = bb["left"] + bb["width"] // 2
        cy = bb["top"] + bb["height"] // 2
        _mouse.move(coords=(cx, cy))
        return self

    def drag_to(
        self,
        target: Locator | tuple[int, int],
        *,
        duration: float = 0.5,
        button: str = "left",
    ) -> Locator:
        """Drag from this element's centre to *target*."""
        import pywinauto.mouse as _mouse  # type: ignore[import-untyped]

        bb = self.bounding_box()
        src_x = bb["left"] + bb["width"] // 2
        src_y = bb["top"] + bb["height"] // 2

        if isinstance(target, tuple):
            dst_x, dst_y = target
        else:
            tbb = target.bounding_box()
            dst_x = tbb["left"] + tbb["width"] // 2
            dst_y = tbb["top"] + tbb["height"] // 2

        steps = 30
        step_sleep = duration / steps

        _mouse.press(button=button, coords=(src_x, src_y))
        for i in range(1, steps + 1):
            ix = src_x + (dst_x - src_x) * i // steps
            iy = src_y + (dst_y - src_y) * i // steps
            _mouse.move(coords=(ix, iy))
            time.sleep(step_sleep)
        _mouse.release(button=button, coords=(dst_x, dst_y))
        return self

    def scroll(self, direction: str = "down", amount: int = 3) -> Locator:
        """Scroll at the element's centre. direction: 'up' or 'down'."""
        import warnings

        import pywinauto.mouse as _mouse  # type: ignore[import-untyped]

        bb = self.bounding_box()
        cx = bb["left"] + bb["width"] // 2
        cy = bb["top"] + bb["height"] // 2

        if direction == "up":
            wheel_dist = amount
        elif direction == "down":
            wheel_dist = -amount
        else:
            warnings.warn(
                f"scroll direction {direction!r} is not supported; ignoring.",
                stacklevel=2,
            )
            return self

        _mouse.scroll(coords=(cx, cy), wheel_dist=wheel_dist)
        return self

    def get_attribute(self, name: str) -> Any:
        """Return an attribute of the underlying element_info by *name*."""
        info = self._resolve().element_info
        return getattr(info, name, None)

    def select_text(self) -> Locator:
        """Select all text in the element (focus + Ctrl+A)."""
        self._resolve().set_focus()
        time.sleep(0.05)
        _send_keys("^a")
        return self

    def all(self, *, depth: int | None = None) -> list[Locator]:
        """Return all matching elements as a list of Locators.

        If *depth* is None, only direct children are returned.
        If *depth* is given (including 0), descendants up to that depth are returned.
        """
        parent_spec = self._get_parent_spec()
        try:
            if depth is None:
                elements = parent_spec.children(**self._criteria)
            else:
                elements = parent_spec.descendants(depth=depth, **self._criteria)
        except Exception:
            elements = []
        return [_ResolvedLocator(el) for el in elements]

    def count(self) -> int:
        return len(self.all())

    def __repr__(self) -> str:
        return f"Locator({self._criteria!r})"

timeout

timeout(seconds: float) -> Locator

Return a new Locator with a different timeout (does not mutate self).

Source code in src\dolphin_desktop\_locator.py
def timeout(self, seconds: float) -> Locator:
    """Return a new Locator with a different timeout (does not mutate self)."""
    clone = Locator(
        self._parent,
        fallback=self._fallback,
        image_fallback=self._image_fallback,
        **self._criteria,
    )
    clone._timeout = seconds
    return clone

locator

locator(**criteria: Any) -> Locator

Find a descendant element matching criteria inside this element.

Source code in src\dolphin_desktop\_locator.py
def locator(self, **criteria: Any) -> Locator:
    """Find a descendant element matching *criteria* inside this element."""
    return Locator(self, **criteria)

nth

nth(index: int) -> Locator

Select the nth match (0-based) from a set of matching elements.

Source code in src\dolphin_desktop\_locator.py
def nth(self, index: int) -> Locator:
    """Select the nth match (0-based) from a set of matching elements."""
    return Locator(self._parent, **{**self._criteria, "found_index": index})

type_text

type_text(
    text: str,
    *,
    with_spaces: bool = True,
    pause: float = 0.05,
) -> Locator

Type text character by character (sends WM_CHAR events).

The default pause of 0.05 s between keystrokes matches pywinauto's own default and prevents missed/doubled keys in modern WinUI/XAML controls.

Source code in src\dolphin_desktop\_locator.py
def type_text(
    self,
    text: str,
    *,
    with_spaces: bool = True,
    pause: float = 0.05,
) -> Locator:
    """Type text character by character (sends WM_CHAR events).

    The default pause of 0.05 s between keystrokes matches pywinauto's own
    default and prevents missed/doubled keys in modern WinUI/XAML controls.
    """
    element = None
    try:
        element = self._resolve()
        element.type_keys(text, with_spaces=with_spaces, pause=pause)
    except Exception as exc:
        _trace_step("type_text", self._criteria, element=element, error=str(exc))
        raise
    _trace_step("type_text", self._criteria, element=element)
    return self

set_text

set_text(text: str) -> Locator

Replace the entire text content of an edit control.

Falls back to select-all + keyboard input for Document/RichEdit controls (e.g. Windows 11 Notepad) that do not support IValueProvider.SetValue.

Source code in src\dolphin_desktop\_locator.py
def set_text(self, text: str) -> Locator:
    """Replace the entire text content of an edit control.

    Falls back to select-all + keyboard input for Document/RichEdit controls
    (e.g. Windows 11 Notepad) that do not support IValueProvider.SetValue.
    """
    element = None
    try:
        element = self._resolve()
        try:
            element.set_edit_text(text)
            _trace_step("set_text", self._criteria, element=element)
            return self
        except Exception:
            pass
        element.set_focus()
        time.sleep(0.05)
        _send_keys("^a")
        if text:
            element.type_keys(text, with_spaces=True, pause=0.05)
        else:
            _send_keys("{DELETE}")
    except Exception as exc:
        _trace_step("set_text", self._criteria, element=element, error=str(exc))
        raise
    _trace_step("set_text", self._criteria, element=element)
    return self

clear

clear() -> Locator

Clear the text of an edit control.

Source code in src\dolphin_desktop\_locator.py
def clear(self) -> Locator:
    """Clear the text of an edit control."""
    element = None
    try:
        element = self._resolve()
        try:
            element.set_edit_text("")
            _trace_step("clear", self._criteria, element=element)
            return self
        except Exception:
            pass
        element.set_focus()
        time.sleep(0.05)
        _send_keys("^a{DELETE}")
    except Exception as exc:
        _trace_step("clear", self._criteria, element=element, error=str(exc))
        raise
    _trace_step("clear", self._criteria, element=element)
    return self

press_key

press_key(key: str) -> Locator

Send a key sequence to the element using pywinauto key syntax.

Source code in src\dolphin_desktop\_locator.py
def press_key(self, key: str) -> Locator:
    """Send a key sequence to the element using pywinauto key syntax."""
    element = None
    try:
        element = self._resolve()
        element.type_keys(key)
    except Exception as exc:
        _trace_step("press_key", self._criteria, element=element, error=str(exc))
        raise
    _trace_step("press_key", self._criteria, element=element)
    return self

select_item

select_item(item: str | int) -> Locator

Select an item in a list/combo box by text or 0-based index.

Source code in src\dolphin_desktop\_locator.py
def select_item(self, item: str | int) -> Locator:
    """Select an item in a list/combo box by text or 0-based index."""
    element = None
    try:
        element = self._resolve()
    except Exception as exc:
        _trace_step("select_item", self._criteria, error=str(exc))
        raise
    try:
        # Win32 backend and UIA ComboBox expose select(item).
        element.select(item)
    except TypeError:
        # UIA ListBox: UIAWrapper.select() takes no arguments.
        # Find the child ListItem and call select() on it directly.
        if isinstance(item, int):
            element.children(control_type="ListItem")[item].select()
        else:
            element.child_window(title=item, control_type="ListItem").wrapper_object().select()
    except ValueError:
        # WinForms ComboBox (UIA): pywinauto's select() raises ValueError from
        # selected_index() when no item is pre-selected and ISelectionItemProvider
        # is unavailable on child elements.  Fall back to expand → invoke.
        if isinstance(item, int):
            try:
                element.expand()
                time.sleep(0.05)
            except Exception:
                pass
            items = element.children(control_type="ListItem")
            if not items:
                # WinForms pattern: items live under a List child
                lists = element.children(control_type="List")
                if lists:
                    items = lists[0].children(control_type="ListItem")
            if items:
                items[item].invoke()
            else:
                element.descendants(control_type="ListItem")[item].click_input()
        else:
            element.child_window(title=item, control_type="ListItem").wrapper_object().select()
    except (IndexError, Exception):
        # Win32 combo box (e.g. VCL TUIStateAwareComboBox): not recognised by
        # pywinauto as ComboBoxWrapper → no select() method → use CB_* messages.
        try:
            import win32gui

            hwnd = element.handle
            if isinstance(item, str):
                idx = win32gui.SendMessage(hwnd, 0x0158, -1, item)  # CB_FINDSTRINGEXACT
                if idx == -1:
                    idx = win32gui.SendMessage(hwnd, 0x014C, -1, item)  # CB_FINDSTRING
            else:
                idx = item
            if idx != -1:
                win32gui.SendMessage(hwnd, 0x014E, idx, 0)  # CB_SETCURSEL
                # Notify parent of selection change (VCL needs CBN_SELCHANGE = 1)
                parent_hwnd = win32gui.GetParent(hwnd)
                ctrl_id = win32gui.GetDlgCtrlID(hwnd)
                win32gui.SendMessage(
                    parent_hwnd, 0x0111, (1 << 16) | ctrl_id, hwnd
                )  # WM_COMMAND/CBN_SELCHANGE
                _trace_step("select_item", self._criteria, element=element)
                return self
        except Exception:
            pass
        # Qt ComboBox: the dropdown opens as a detached popup window in the UIA
        # tree — child_window() finds nothing.  After expand(), scan the process's
        # top-level windows for a popup that contains ListItems.
        _select_via_popup(element, item, self._timeout)
    _trace_step("select_item", self._criteria, element=element)
    return self

check

check() -> Locator

Check a checkbox.

Source code in src\dolphin_desktop\_locator.py
def check(self) -> Locator:
    """Check a checkbox."""
    element = None
    try:
        element = self._resolve()
        if _get_check_state(element) == 0:
            _toggle_element(element)
    except Exception as exc:
        _trace_step("check", self._criteria, element=element, error=str(exc))
        raise
    _trace_step("check", self._criteria, element=element)
    return self

uncheck

uncheck() -> Locator

Uncheck a checkbox.

Source code in src\dolphin_desktop\_locator.py
def uncheck(self) -> Locator:
    """Uncheck a checkbox."""
    element = None
    try:
        element = self._resolve()
        if _get_check_state(element) != 0:
            _toggle_element(element)
    except Exception as exc:
        _trace_step("uncheck", self._criteria, element=element, error=str(exc))
        raise
    _trace_step("uncheck", self._criteria, element=element)
    return self

text

text() -> str

Return the text content of the element.

For standard Edit controls this is window_text(). For Document/RichEdit controls (e.g. Windows 11 Notepad RichEditD2DPT) window_text() returns an empty string, so we fall back to select-all + clipboard.

Source code in src\dolphin_desktop\_locator.py
def text(self) -> str:
    """Return the text content of the element.

    For standard Edit controls this is window_text().  For Document/RichEdit
    controls (e.g. Windows 11 Notepad RichEditD2DPT) window_text() returns
    an empty string, so we fall back to select-all + clipboard.
    """
    element = self._resolve()
    t = element.window_text()
    if t:
        return t
    try:
        t = element.get_value()
        if t:
            return t
    except Exception:
        pass
    return _read_text_via_clipboard(element)

value

value() -> str

Return the value of an edit/spinner control.

Source code in src\dolphin_desktop\_locator.py
def value(self) -> str:
    """Return the value of an edit/spinner control."""
    element = self._resolve()
    try:
        return element.get_value()
    except AttributeError:
        return element.window_text()

exists

exists(timeout: float = 0.0) -> bool

Return True if the element exists (and is visible) within timeout seconds.

Source code in src\dolphin_desktop\_locator.py
def exists(self, timeout: float = 0.0) -> bool:
    """Return True if the element exists (and is visible) within *timeout* seconds."""
    try:
        self.timeout(timeout)._resolve()
        return True
    except Exception:
        return False

bounding_box

bounding_box() -> dict[str, int]

Return {left, top, right, bottom, width, height} in screen coords.

Source code in src\dolphin_desktop\_locator.py
def bounding_box(self) -> dict[str, int]:
    """Return {left, top, right, bottom, width, height} in screen coords."""
    rect = self._resolve().rectangle()
    return {
        "left": rect.left,
        "top": rect.top,
        "right": rect.right,
        "bottom": rect.bottom,
        "width": rect.right - rect.left,
        "height": rect.bottom - rect.top,
    }

wait_for

wait_for(
    *, state: str = "visible", timeout: float | None = None
) -> Locator

Wait until the element reaches state ('visible', 'enabled', 'exists').

Source code in src\dolphin_desktop\_locator.py
def wait_for(self, *, state: str = "visible", timeout: float | None = None) -> Locator:
    """Wait until the element reaches *state* ('visible', 'enabled', 'exists')."""
    t = timeout if timeout is not None else self._timeout
    parent_spec = self._get_parent_spec()
    try:
        spec = parent_spec.child_window(**self._criteria)
        spec.wait(state, timeout=t)
    except Exception as exc:
        raise WaitTimeoutError(
            f"Element {self._criteria!r} did not reach state '{state}' after {t}s"
        ) from exc
    return self

wait_until_hidden

wait_until_hidden(timeout: float = 10.0) -> Locator

Wait until the element is no longer visible.

Source code in src\dolphin_desktop\_locator.py
def wait_until_hidden(self, timeout: float = 10.0) -> Locator:
    """Wait until the element is no longer visible."""
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        if not self.is_visible():
            return self
        time.sleep(0.1)
    raise WaitTimeoutError(f"Element {self._criteria!r} still visible after {timeout}s")

wait_until_enabled

wait_until_enabled(timeout: float = 10.0) -> Locator

Wait until the element is enabled.

Source code in src\dolphin_desktop\_locator.py
def wait_until_enabled(self, timeout: float = 10.0) -> Locator:
    """Wait until the element is enabled."""
    return self.wait_for(state="enabled", timeout=timeout)

screenshot

screenshot(path: str | Path | None = None) -> Image

Capture the element as a PIL Image, optionally saving to path.

Source code in src\dolphin_desktop\_locator.py
def screenshot(self, path: str | Path | None = None) -> Image:
    """Capture the element as a PIL Image, optionally saving to *path*."""
    img = self._resolve().capture_as_image()
    if path:
        img.save(path)
    return img

hover

hover() -> Locator

Move the mouse pointer over the element's centre.

Source code in src\dolphin_desktop\_locator.py
def hover(self) -> Locator:
    """Move the mouse pointer over the element's centre."""
    import pywinauto.mouse as _mouse  # type: ignore[import-untyped]

    bb = self.bounding_box()
    cx = bb["left"] + bb["width"] // 2
    cy = bb["top"] + bb["height"] // 2
    _mouse.move(coords=(cx, cy))
    return self

drag_to

drag_to(
    target: Locator | tuple[int, int],
    *,
    duration: float = 0.5,
    button: str = "left",
) -> Locator

Drag from this element's centre to target.

Source code in src\dolphin_desktop\_locator.py
def drag_to(
    self,
    target: Locator | tuple[int, int],
    *,
    duration: float = 0.5,
    button: str = "left",
) -> Locator:
    """Drag from this element's centre to *target*."""
    import pywinauto.mouse as _mouse  # type: ignore[import-untyped]

    bb = self.bounding_box()
    src_x = bb["left"] + bb["width"] // 2
    src_y = bb["top"] + bb["height"] // 2

    if isinstance(target, tuple):
        dst_x, dst_y = target
    else:
        tbb = target.bounding_box()
        dst_x = tbb["left"] + tbb["width"] // 2
        dst_y = tbb["top"] + tbb["height"] // 2

    steps = 30
    step_sleep = duration / steps

    _mouse.press(button=button, coords=(src_x, src_y))
    for i in range(1, steps + 1):
        ix = src_x + (dst_x - src_x) * i // steps
        iy = src_y + (dst_y - src_y) * i // steps
        _mouse.move(coords=(ix, iy))
        time.sleep(step_sleep)
    _mouse.release(button=button, coords=(dst_x, dst_y))
    return self

scroll

scroll(direction: str = 'down', amount: int = 3) -> Locator

Scroll at the element's centre. direction: 'up' or 'down'.

Source code in src\dolphin_desktop\_locator.py
def scroll(self, direction: str = "down", amount: int = 3) -> Locator:
    """Scroll at the element's centre. direction: 'up' or 'down'."""
    import warnings

    import pywinauto.mouse as _mouse  # type: ignore[import-untyped]

    bb = self.bounding_box()
    cx = bb["left"] + bb["width"] // 2
    cy = bb["top"] + bb["height"] // 2

    if direction == "up":
        wheel_dist = amount
    elif direction == "down":
        wheel_dist = -amount
    else:
        warnings.warn(
            f"scroll direction {direction!r} is not supported; ignoring.",
            stacklevel=2,
        )
        return self

    _mouse.scroll(coords=(cx, cy), wheel_dist=wheel_dist)
    return self

get_attribute

get_attribute(name: str) -> Any

Return an attribute of the underlying element_info by name.

Source code in src\dolphin_desktop\_locator.py
def get_attribute(self, name: str) -> Any:
    """Return an attribute of the underlying element_info by *name*."""
    info = self._resolve().element_info
    return getattr(info, name, None)

select_text

select_text() -> Locator

Select all text in the element (focus + Ctrl+A).

Source code in src\dolphin_desktop\_locator.py
def select_text(self) -> Locator:
    """Select all text in the element (focus + Ctrl+A)."""
    self._resolve().set_focus()
    time.sleep(0.05)
    _send_keys("^a")
    return self

all

all(*, depth: int | None = None) -> list[Locator]

Return all matching elements as a list of Locators.

If depth is None, only direct children are returned. If depth is given (including 0), descendants up to that depth are returned.

Source code in src\dolphin_desktop\_locator.py
def all(self, *, depth: int | None = None) -> list[Locator]:
    """Return all matching elements as a list of Locators.

    If *depth* is None, only direct children are returned.
    If *depth* is given (including 0), descendants up to that depth are returned.
    """
    parent_spec = self._get_parent_spec()
    try:
        if depth is None:
            elements = parent_spec.children(**self._criteria)
        else:
            elements = parent_spec.descendants(depth=depth, **self._criteria)
    except Exception:
        elements = []
    return [_ResolvedLocator(el) for el in elements]

MacOSAccessibilityBackend

Bases: Backend

macOS Accessibility API backend - reserved, not yet implemented.

Source code in src\dolphin_desktop\_backend.py
class MacOSAccessibilityBackend(Backend):
    """macOS Accessibility API backend - reserved, not yet implemented."""

    id = "macos"
    platform = "macos"

    def _raise(self) -> None:
        raise NotImplementedError(_STUB_MSG.format(name="MacOSAccessibilityBackend"))

    def find_element(self, parent: Any, criteria: dict[str, Any]) -> Any:
        self._raise()

    def click(self, element: Any, *, button: str = "left") -> None:
        self._raise()

    def type_text(self, element: Any, text: str) -> None:
        self._raise()

    def get_tree(self, root: Any, *, depth: int | None = None) -> dict[str, Any]:
        self._raise()
        return {}  # unreachable — satisfies type checker

    def screenshot(self, element: Any | None = None) -> Any:
        self._raise()

    def is_available(self) -> bool:
        return False

Menu

Bases: Element

A menu bar item or top-level menu.

Usage::

window.menu("File").item("Save").click()
window.menu("Edit").item("Find...").click()
window.menu("View").item("Zoom").item("100%").click()
Source code in src\dolphin_desktop\_element.py
class Menu(Element):
    """A menu bar item or top-level menu.

    Usage::

        window.menu("File").item("Save").click()
        window.menu("Edit").item("Find...").click()
        window.menu("View").item("Zoom").item("100%").click()
    """

    def item(self, text: str) -> MenuItem:
        """Return a locator for a child menu item by its visible text.

        The parent menu is automatically opened before the item is resolved.
        Supports chaining for nested submenus::

            window.menu("View").item("Zoom").item("100%").click()
        """
        return MenuItem(self, title=text, control_type="MenuItem")

item

item(text: str) -> MenuItem

Return a locator for a child menu item by its visible text.

The parent menu is automatically opened before the item is resolved. Supports chaining for nested submenus::

window.menu("View").item("Zoom").item("100%").click()
Source code in src\dolphin_desktop\_element.py
def item(self, text: str) -> MenuItem:
    """Return a locator for a child menu item by its visible text.

    The parent menu is automatically opened before the item is resolved.
    Supports chaining for nested submenus::

        window.menu("View").item("Zoom").item("100%").click()
    """
    return MenuItem(self, title=text, control_type="MenuItem")

MenuItem

Bases: Locator

A menu item that automatically opens its parent menu on resolution.

Created via :meth:Menu.item; not typically instantiated directly.

Clicking resolves the parent menu first (expanding it), then resolves the menu item itself. Works for both in-tree UIA menus and detached popup windows (e.g. Win32 context menus).

Supports chaining into nested submenus::

window.menu("View").item("Zoom").item("100%").click()
Source code in src\dolphin_desktop\_element.py
class MenuItem(Locator):
    """A menu item that automatically opens its parent menu on resolution.

    Created via :meth:`Menu.item`; not typically instantiated directly.

    Clicking resolves the parent menu first (expanding it), then resolves
    the menu item itself.  Works for both in-tree UIA menus and detached
    popup windows (e.g. Win32 context menus).

    Supports chaining into nested submenus::

        window.menu("View").item("Zoom").item("100%").click()
    """

    def item(self, text: str) -> MenuItem:
        """Return a locator for a nested submenu item by its visible text."""
        return MenuItem(self, title=text, control_type="MenuItem")

    def _resolve(self) -> Any:
        if isinstance(self._parent, (Menu, MenuItem)):
            # Open the parent menu by clicking it.
            parent_el = self._parent._resolve()
            try:
                parent_el.click_input()
            except Exception:
                pass
            time.sleep(0.15)
            # First, try to find the item as a direct child of the parent element
            # (covers Win32 menus and UIA in-tree menus).
            try:
                spec = parent_el.child_window(**self._criteria)
                spec.wait("exists visible", timeout=self._timeout)
                return spec
            except Exception:
                pass
            # Fallback: search the root window scope for detached popup menus.
            root = self._root_window_spec()
            try:
                spec = root.child_window(**self._criteria)
                spec.wait("exists visible", timeout=self._timeout)
                return spec
            except Exception as exc:
                raise ElementNotFoundError(
                    f"MenuItem {self._criteria!r} not found after {self._timeout}s"
                ) from exc
        return super()._resolve()

    def _root_window_spec(self) -> Any:
        p: Any = self._parent
        while isinstance(p, Locator):
            p = p._parent
        return p._get_spec()

item

item(text: str) -> MenuItem

Return a locator for a nested submenu item by its visible text.

Source code in src\dolphin_desktop\_element.py
def item(self, text: str) -> MenuItem:
    """Return a locator for a nested submenu item by its visible text."""
    return MenuItem(self, title=text, control_type="MenuItem")

MessageBox

Helper for interacting with Windows MessageBox dialogs.

Source code in src\dolphin_desktop\_dialogs.py
class MessageBox:
    """Helper for interacting with Windows MessageBox dialogs."""

    def __init__(self, _window) -> None:
        self._win = _window

    @staticmethod
    def wait_for(timeout: float = 10.0) -> MessageBox:
        """Wait for a MessageBox (#32770) dialog and return a MessageBox."""
        return MessageBox(_find_window("#32770", None, timeout))

    def text(self) -> str:
        """Return the message text from the Static control."""
        for ctrl in self._win.children():
            try:
                if ctrl.class_name() == "Static":
                    t = ctrl.window_text()
                    if t:
                        return t
            except Exception:
                continue
        return ""

    def click_ok(self) -> None:
        """Click the OK button."""
        self.click("OK")

    def click_cancel(self) -> None:
        """Click the Cancel button."""
        self.click("Cancel", "Anuluj")

    def click_yes(self) -> None:
        """Click the Yes button."""
        self.click("Yes", "Tak")

    def click_no(self) -> None:
        """Click the No button."""
        self.click("No", "Nie")

    def click(self, *button_texts: str) -> None:
        """Click a button by its text label.

        Accepts several candidate labels and clicks the first one present, so
        the same call works regardless of the Windows display language
        (e.g. ``"Yes"`` / ``"Tak"``).
        """
        for title in button_texts:
            try:
                btn = self._win.child_window(title=title, control_type="Button")
                btn.click_input()
                return
            except Exception:
                continue
        labels = ", ".join(repr(t) for t in button_texts)
        raise RuntimeError(f"No button matching {labels} found in MessageBox")

wait_for staticmethod

wait_for(timeout: float = 10.0) -> MessageBox

Wait for a MessageBox (#32770) dialog and return a MessageBox.

Source code in src\dolphin_desktop\_dialogs.py
@staticmethod
def wait_for(timeout: float = 10.0) -> MessageBox:
    """Wait for a MessageBox (#32770) dialog and return a MessageBox."""
    return MessageBox(_find_window("#32770", None, timeout))

text

text() -> str

Return the message text from the Static control.

Source code in src\dolphin_desktop\_dialogs.py
def text(self) -> str:
    """Return the message text from the Static control."""
    for ctrl in self._win.children():
        try:
            if ctrl.class_name() == "Static":
                t = ctrl.window_text()
                if t:
                    return t
        except Exception:
            continue
    return ""

click_ok

click_ok() -> None

Click the OK button.

Source code in src\dolphin_desktop\_dialogs.py
def click_ok(self) -> None:
    """Click the OK button."""
    self.click("OK")

click_cancel

click_cancel() -> None

Click the Cancel button.

Source code in src\dolphin_desktop\_dialogs.py
def click_cancel(self) -> None:
    """Click the Cancel button."""
    self.click("Cancel", "Anuluj")

click_yes

click_yes() -> None

Click the Yes button.

Source code in src\dolphin_desktop\_dialogs.py
def click_yes(self) -> None:
    """Click the Yes button."""
    self.click("Yes", "Tak")

click_no

click_no() -> None

Click the No button.

Source code in src\dolphin_desktop\_dialogs.py
def click_no(self) -> None:
    """Click the No button."""
    self.click("No", "Nie")

click

click(*button_texts: str) -> None

Click a button by its text label.

Accepts several candidate labels and clicks the first one present, so the same call works regardless of the Windows display language (e.g. "Yes" / "Tak").

Source code in src\dolphin_desktop\_dialogs.py
def click(self, *button_texts: str) -> None:
    """Click a button by its text label.

    Accepts several candidate labels and clicks the first one present, so
    the same call works regardless of the Windows display language
    (e.g. ``"Yes"`` / ``"Tak"``).
    """
    for title in button_texts:
        try:
            btn = self._win.child_window(title=title, control_type="Button")
            btn.click_input()
            return
        except Exception:
            continue
    labels = ", ".join(repr(t) for t in button_texts)
    raise RuntimeError(f"No button matching {labels} found in MessageBox")

Mouse

Control the mouse at absolute screen coordinates.

Usage::

Mouse.click(100, 200)
Mouse.right_click(500, 300)
Mouse.double_click(400, 150)
Mouse.move(x=640, y=480)
Mouse.scroll(x=400, y=300, wheel_dist=3)
Source code in src\dolphin_desktop\_mouse.py
class Mouse:
    """Control the mouse at absolute screen coordinates.

    Usage::

        Mouse.click(100, 200)
        Mouse.right_click(500, 300)
        Mouse.double_click(400, 150)
        Mouse.move(x=640, y=480)
        Mouse.scroll(x=400, y=300, wheel_dist=3)
    """

    @staticmethod
    def click(x: int, y: int, button: str = "left") -> None:
        _mouse.click(button=button, coords=(x, y))

    @staticmethod
    def double_click(x: int, y: int, button: str = "left") -> None:
        _mouse.double_click(button=button, coords=(x, y))

    @staticmethod
    def right_click(x: int, y: int) -> None:
        _mouse.right_click(coords=(x, y))

    @staticmethod
    def move(x: int, y: int) -> None:
        _mouse.move(coords=(x, y))

    @staticmethod
    def scroll(x: int, y: int, wheel_dist: int) -> None:
        """Scroll at (x, y). Positive wheel_dist scrolls up."""
        _mouse.scroll(coords=(x, y), wheel_dist=wheel_dist)

    @staticmethod
    def press(x: int, y: int, button: str = "left") -> None:
        _mouse.press(button=button, coords=(x, y))

    @staticmethod
    def release(x: int, y: int, button: str = "left") -> None:
        _mouse.release(button=button, coords=(x, y))

scroll staticmethod

scroll(x: int, y: int, wheel_dist: int) -> None

Scroll at (x, y). Positive wheel_dist scrolls up.

Source code in src\dolphin_desktop\_mouse.py
@staticmethod
def scroll(x: int, y: int, wheel_dist: int) -> None:
    """Scroll at (x, y). Positive wheel_dist scrolls up."""
    _mouse.scroll(coords=(x, y), wheel_dist=wheel_dist)

RadioButton

Bases: Element

A radio button control.

Usage::

window.radio_button(name="Option A").select()
assert window.radio_button(name="Option A").is_checked()
Source code in src\dolphin_desktop\_element.py
class RadioButton(Element):
    """A radio button control.

    Usage::

        window.radio_button(name="Option A").select()
        assert window.radio_button(name="Option A").is_checked()
    """

    def select(self) -> RadioButton:
        """Select this radio button (alias for :meth:`check`)."""
        return self.check()  # type: ignore[return-value]

select

select() -> RadioButton

Select this radio button (alias for :meth:check).

Source code in src\dolphin_desktop\_element.py
def select(self) -> RadioButton:
    """Select this radio button (alias for :meth:`check`)."""
    return self.check()  # type: ignore[return-value]

RecordedAction dataclass

A single recorded user action.

Source code in src\dolphin_desktop\_recorder.py
@dataclass
class RecordedAction:
    """A single recorded user action."""

    kind: str  # "click" | "double_click" | "right_click" | "type_text" | "press_key"
    window_title: str
    window_class: str
    selector: dict[str, str]  # suggested_selector-style dict
    data: str = ""  # text for type_text; key spec for press_key
    x: int = 0
    y: int = 0
    timestamp: float = field(default_factory=time.time)

Recorder

Records mouse/keyboard interactions and generates dolphin Python test code.

Parameters:

Name Type Description Default
app str | None

Optional title fragment — only events in windows whose title contains this string are recorded. If None, all windows are captured.

None
backend str

pywinauto backend used in the generated code (default "uia").

'uia'
stop_key int

Virtual key code of the stop key (default 0x7B = F12). Must be combined with Ctrl.

123
Source code in src\dolphin_desktop\_recorder.py
class Recorder:
    """Records mouse/keyboard interactions and generates dolphin Python test code.

    Parameters
    ----------
    app:
        Optional title fragment — only events in windows whose title contains
        this string are recorded.  If ``None``, all windows are captured.
    backend:
        pywinauto backend used in the generated code (default ``"uia"``).
    stop_key:
        Virtual key code of the stop key (default ``0x7B`` = F12).
        Must be combined with Ctrl.
    """

    def __init__(
        self,
        app: str | None = None,
        *,
        backend: str = "uia",
        stop_key: int = 0x7B,
    ) -> None:
        self.app = app
        self.backend = backend
        self._stop_vk = stop_key

        self._actions: list[RecordedAction] = []
        self._pending_text: list[str] = []
        self._pending_selector: dict[str, str] = {}
        self._pending_win_title: str = ""
        self._pending_win_class: str = ""

        self._evt_queue: queue.Queue[tuple[Any, ...] | None] = queue.Queue()
        self._stopped = threading.Event()
        self._hook_thread_id: int = 0

        # Strong references — prevent GC while hooks are active
        self._mouse_cb: Any = None
        self._kbd_cb: Any = None

        self._hook_thread: threading.Thread | None = None
        self._proc_thread: threading.Thread | None = None

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def start(self) -> None:
        """Install global low-level hooks and start recording."""
        self._stopped.clear()
        self._hook_thread = threading.Thread(
            target=self._hook_loop, daemon=True, name="dolphin-rec-hook"
        )
        self._hook_thread.start()
        self._proc_thread = threading.Thread(
            target=self._process_loop, daemon=True, name="dolphin-rec-proc"
        )
        self._proc_thread.start()

    def stop(self) -> None:
        """Stop recording and unhook."""
        if not self._stopped.is_set():
            if self._hook_thread_id:
                _user32.PostThreadMessageW(self._hook_thread_id, _WM_QUIT, 0, 0)
            self._evt_queue.put(None)
            self._stopped.set()

    def wait(self, timeout: float | None = None) -> bool:
        """Block until ``stop()`` is called or the stop key is pressed."""
        return self._stopped.wait(timeout)

    def actions(self) -> list[RecordedAction]:
        """Return a copy of recorded actions (flushes any pending text first)."""
        self._flush_text()
        return list(self._actions)

    def generate_code(
        self,
        output: str | Path | None = None,
        *,
        func_name: str = "test_recorded",
    ) -> str:
        """Generate Python test code from recorded actions.

        Parameters
        ----------
        output:
            If given, the generated code is written to this path.
        func_name:
            Name of the generated test function.

        Returns
        -------
        str
            The generated Python source (also written to *output* if provided).
        """
        self._flush_text()
        code = "\n".join(self._build_code(self._actions, func_name)) + "\n"
        if output is not None:
            Path(output).write_text(code, encoding="utf-8")
        return code

    # ------------------------------------------------------------------
    # Hook thread
    # ------------------------------------------------------------------

    def _hook_loop(self) -> None:
        self._hook_thread_id = ctypes.windll.kernel32.GetCurrentThreadId()
        evt_queue = self._evt_queue
        stop_vk = self._stop_vk
        stopped = self._stopped

        def _mouse_proc(n_code: int, w_param: int, l_param: int) -> int:
            if n_code == _HC_ACTION and w_param in (
                _WM_LBUTTONDOWN,
                _WM_LBUTTONDBLCLK,
                _WM_RBUTTONDOWN,
            ):
                ms = ctypes.cast(l_param, ctypes.POINTER(_MSLLHOOKSTRUCT)).contents
                kind = {
                    _WM_LBUTTONDOWN: "click",
                    _WM_LBUTTONDBLCLK: "double_click",
                    _WM_RBUTTONDOWN: "right_click",
                }[w_param]
                try:
                    evt_queue.put_nowait(("mouse", kind, ms.pt.x, ms.pt.y, time.time()))
                except queue.Full:
                    pass
            return _user32.CallNextHookEx(None, n_code, w_param, l_param)

        def _kbd_proc(n_code: int, w_param: int, l_param: int) -> int:
            if n_code == _HC_ACTION and w_param in (_WM_KEYDOWN, _WM_SYSKEYDOWN):
                kb = ctypes.cast(l_param, ctypes.POINTER(_KBDLLHOOKSTRUCT)).contents
                vk = int(kb.vkCode)
                if vk == stop_vk and bool(_user32.GetAsyncKeyState(_VK_CONTROL) & 0x8000):
                    stopped.set()
                    _user32.PostQuitMessage(0)
                    return _user32.CallNextHookEx(None, n_code, w_param, l_param)
                shift = bool(_user32.GetAsyncKeyState(_VK_SHIFT) & 0x8000)
                ctrl = bool(_user32.GetAsyncKeyState(_VK_CONTROL) & 0x8000)
                alt = bool(_user32.GetAsyncKeyState(_VK_MENU) & 0x8000)
                try:
                    evt_queue.put_nowait(
                        ("key", vk, int(kb.scanCode), shift, ctrl, alt, time.time())
                    )
                except queue.Full:
                    pass
            return _user32.CallNextHookEx(None, n_code, w_param, l_param)

        # Keep strong references on self so GC won't collect while hook is alive
        self._mouse_cb = _HOOKPROC(_mouse_proc)
        self._kbd_cb = _HOOKPROC(_kbd_proc)

        mouse_hook = _user32.SetWindowsHookExW(_WH_MOUSE_LL, self._mouse_cb, None, 0)
        kbd_hook = _user32.SetWindowsHookExW(_WH_KEYBOARD_LL, self._kbd_cb, None, 0)

        msg = _wt.MSG()
        while not stopped.is_set():
            ret = _user32.GetMessageW(ctypes.byref(msg), None, 0, 0)
            if ret <= 0:
                break
            _user32.TranslateMessage(ctypes.byref(msg))
            _user32.DispatchMessageW(ctypes.byref(msg))

        if mouse_hook:
            _user32.UnhookWindowsHookEx(mouse_hook)
        if kbd_hook:
            _user32.UnhookWindowsHookEx(kbd_hook)

        stopped.set()
        evt_queue.put(None)

    # ------------------------------------------------------------------
    # Processor thread
    # ------------------------------------------------------------------

    def _process_loop(self) -> None:
        while True:
            try:
                evt = self._evt_queue.get(timeout=0.5)
            except queue.Empty:
                continue
            if evt is None:
                break
            if evt[0] == "mouse":
                _, kind, x, y, ts = evt
                self._flush_text()
                self._handle_mouse(kind, x, y, ts)
            elif evt[0] == "key":
                _, vk, scan, shift, ctrl, alt, ts = evt
                self._handle_key(vk, scan, shift, ctrl, alt, ts)

    def _handle_mouse(self, kind: str, x: int, y: int, ts: float) -> None:
        try:
            import win32gui

            hwnd = win32gui.WindowFromPoint((x, y))
            root = _get_root_hwnd(hwnd)
            win_title, win_class = _get_window_info(root)
        except Exception:
            win_title, win_class = "", ""

        if self.app and self.app.lower() not in win_title.lower():
            return

        selector = _element_at_point(x, y)

        # Upgrade preceding single-click to double_click if it happened at the same spot
        if kind == "double_click" and self._actions:
            prev = self._actions[-1]
            if prev.kind == "click" and prev.x == x and prev.y == y and ts - prev.timestamp < 0.7:
                self._actions[-1] = RecordedAction(
                    kind="double_click",
                    window_title=win_title,
                    window_class=win_class,
                    selector=selector,
                    x=x,
                    y=y,
                    timestamp=ts,
                )
                self._pending_selector = selector
                self._pending_win_title = win_title
                self._pending_win_class = win_class
                return

        self._actions.append(
            RecordedAction(
                kind=kind,
                window_title=win_title,
                window_class=win_class,
                selector=selector,
                x=x,
                y=y,
                timestamp=ts,
            )
        )
        # Update pending context so subsequent keystrokes attach to the clicked element
        self._pending_selector = selector
        self._pending_win_title = win_title
        self._pending_win_class = win_class

    def _handle_key(
        self, vk: int, scan: int, shift: bool, ctrl: bool, alt: bool, ts: float
    ) -> None:
        char = _vk_to_sendkeys(vk, scan, shift, ctrl, alt)
        if char is None:
            return  # modifier-only key

        try:
            import win32gui

            hwnd = win32gui.GetForegroundWindow()
            root = _get_root_hwnd(hwnd)
            win_title, win_class = _get_window_info(root)
        except Exception:
            win_title, win_class = "", ""

        if self.app and self.app.lower() not in win_title.lower():
            self._flush_text()
            return

        if ctrl or alt:
            # Shortcut → flush accumulated text, emit press_key
            self._flush_text()
            sel = self._pending_selector or _focused_element_selector()
            self._actions.append(
                RecordedAction(
                    kind="press_key",
                    window_title=win_title,
                    window_class=win_class,
                    selector=sel,
                    data=char,
                    timestamp=ts,
                )
            )
            return

        # Printable character → accumulate for type_text
        if not self._pending_win_title:
            self._pending_win_title = win_title
            self._pending_win_class = win_class
            if not self._pending_selector:
                self._pending_selector = _focused_element_selector()
        elif win_title != self._pending_win_title:
            # Window changed mid-typing → flush and start fresh
            self._flush_text()
            self._pending_win_title = win_title
            self._pending_win_class = win_class
            self._pending_selector = _focused_element_selector()

        self._pending_text.append(char)

    def _flush_text(self) -> None:
        if not self._pending_text:
            return
        self._actions.append(
            RecordedAction(
                kind="type_text",
                window_title=self._pending_win_title,
                window_class=self._pending_win_class,
                selector=self._pending_selector,
                data="".join(self._pending_text),
            )
        )
        self._pending_text.clear()
        self._pending_win_title = ""
        self._pending_win_class = ""
        self._pending_selector = {}

    # ------------------------------------------------------------------
    # Code generation
    # ------------------------------------------------------------------

    def _build_code(self, actions: list[RecordedAction], func_name: str) -> list[str]:
        now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
        lines = [
            f"# Generated by dolphin recorder on {now}",
            "from dolphin_desktop import Desktop",
            "",
            "",
            f"def {func_name}() -> None:",
            f'    """Recorded test — generated by dolphin on {now}."""',
        ]

        # Collect unique (title, class) pairs in recording order
        seen: set[tuple[str, str]] = set()
        windows: list[tuple[str, str]] = []
        for a in actions:
            key = (a.window_title, a.window_class)
            if key not in seen:
                seen.add(key)
                windows.append(key)

        # Connection boilerplate
        lines.append(f'    desktop = Desktop(backend="{self.backend}")')
        if self.app:
            lines.append(f'    app = desktop.connect(title_re=".*{self.app}.*")')
        elif windows:
            title, cls = windows[0]
            if cls:
                lines.append(f'    app = desktop.connect(class_name="{cls}")')
            elif title:
                safe = title.replace('"', '\\"')
                lines.append(f'    app = desktop.connect(title_re=".*{safe}.*")')
            else:
                lines.append('    app = desktop.connect(title_re=".*")  # TODO: specify app')
        else:
            lines.append('    app = desktop.connect(title_re=".*")  # TODO: specify app')

        # Window variable declarations
        win_vars: dict[tuple[str, str], str] = {}
        for i, (title, cls) in enumerate(windows):
            var = "win" if i == 0 else f"win{i + 1}"
            win_vars[(title, cls)] = var
            if cls:
                lines.append(f'    {var} = app.window(class_name="{cls}")')
            elif title:
                safe = title.replace('"', '\\"')
                lines.append(f'    {var} = app.window(title_re=".*{safe}.*")')
            else:
                lines.append(f"    {var} = app.top_window()")

        if not actions:
            lines.append("    pass  # no actions recorded")
            return lines

        lines.append("")

        for action in actions:
            key = (action.window_title, action.window_class)
            win_var = win_vars.get(key, "win")
            lines.append(_action_to_line(action, win_var))

        return lines

start

start() -> None

Install global low-level hooks and start recording.

Source code in src\dolphin_desktop\_recorder.py
def start(self) -> None:
    """Install global low-level hooks and start recording."""
    self._stopped.clear()
    self._hook_thread = threading.Thread(
        target=self._hook_loop, daemon=True, name="dolphin-rec-hook"
    )
    self._hook_thread.start()
    self._proc_thread = threading.Thread(
        target=self._process_loop, daemon=True, name="dolphin-rec-proc"
    )
    self._proc_thread.start()

stop

stop() -> None

Stop recording and unhook.

Source code in src\dolphin_desktop\_recorder.py
def stop(self) -> None:
    """Stop recording and unhook."""
    if not self._stopped.is_set():
        if self._hook_thread_id:
            _user32.PostThreadMessageW(self._hook_thread_id, _WM_QUIT, 0, 0)
        self._evt_queue.put(None)
        self._stopped.set()

wait

wait(timeout: float | None = None) -> bool

Block until stop() is called or the stop key is pressed.

Source code in src\dolphin_desktop\_recorder.py
def wait(self, timeout: float | None = None) -> bool:
    """Block until ``stop()`` is called or the stop key is pressed."""
    return self._stopped.wait(timeout)

actions

actions() -> list[RecordedAction]

Return a copy of recorded actions (flushes any pending text first).

Source code in src\dolphin_desktop\_recorder.py
def actions(self) -> list[RecordedAction]:
    """Return a copy of recorded actions (flushes any pending text first)."""
    self._flush_text()
    return list(self._actions)

generate_code

generate_code(
    output: str | Path | None = None,
    *,
    func_name: str = "test_recorded",
) -> str

Generate Python test code from recorded actions.

Parameters:

Name Type Description Default
output str | Path | None

If given, the generated code is written to this path.

None
func_name str

Name of the generated test function.

'test_recorded'

Returns:

Type Description
str

The generated Python source (also written to output if provided).

Source code in src\dolphin_desktop\_recorder.py
def generate_code(
    self,
    output: str | Path | None = None,
    *,
    func_name: str = "test_recorded",
) -> str:
    """Generate Python test code from recorded actions.

    Parameters
    ----------
    output:
        If given, the generated code is written to this path.
    func_name:
        Name of the generated test function.

    Returns
    -------
    str
        The generated Python source (also written to *output* if provided).
    """
    self._flush_text()
    code = "\n".join(self._build_code(self._actions, func_name)) + "\n"
    if output is not None:
        Path(output).write_text(code, encoding="utf-8")
    return code

Screen

Static helpers for full-screen capture, colour sampling, and OCR.

Source code in src\dolphin_desktop\_image.py
class Screen:
    """Static helpers for full-screen capture, colour sampling, and OCR."""

    @staticmethod
    def screenshot(region: tuple[int, int, int, int] | None = None):
        """Return a PIL Image of the screen or *region*."""
        return _grab(region)

    @staticmethod
    def pixel_color(x: int, y: int) -> tuple[int, int, int]:
        """Return the (R, G, B) colour at screen coordinate (*x*, *y*)."""
        img = _grab((x, y, x + 1, y + 1))
        return img.getpixel((0, 0))[:3]  # type: ignore[return-value]

    @staticmethod
    def find_image(
        template: str | Path,
        threshold: float = 0.85,
        region: tuple[int, int, int, int] | None = None,
    ) -> tuple[int, int] | None:
        """Convenience wrapper: create an ImageLocator and call find()."""
        return ImageLocator(template, threshold).find(region)

    @staticmethod
    def text(region: tuple[int, int, int, int] | None = None) -> str:
        """Return OCR text of the screen or *region* via pytesseract."""
        tess = _require_tesseract()
        img = _grab(region)
        return tess.image_to_string(img)

    @staticmethod
    def find_text(
        text: str,
        region: tuple[int, int, int, int] | None = None,
    ) -> tuple[int, int] | None:
        """Return the centre (x, y) of the first bounding box containing *text*, or None."""
        tess = _require_tesseract()
        img = _grab(region)
        data = tess.image_to_data(img, output_type=tess.Output.DICT)

        for i, word in enumerate(data["text"]):
            if text.lower() in str(word).lower():
                x = data["left"][i]
                y = data["top"][i]
                w = data["width"][i]
                h = data["height"][i]
                cx = x + w // 2
                cy = y + h // 2
                if region:
                    cx += region[0]
                    cy += region[1]
                return cx, cy
        return None

screenshot staticmethod

screenshot(region: tuple[int, int, int, int] | None = None)

Return a PIL Image of the screen or region.

Source code in src\dolphin_desktop\_image.py
@staticmethod
def screenshot(region: tuple[int, int, int, int] | None = None):
    """Return a PIL Image of the screen or *region*."""
    return _grab(region)

pixel_color staticmethod

pixel_color(x: int, y: int) -> tuple[int, int, int]

Return the (R, G, B) colour at screen coordinate (x, y).

Source code in src\dolphin_desktop\_image.py
@staticmethod
def pixel_color(x: int, y: int) -> tuple[int, int, int]:
    """Return the (R, G, B) colour at screen coordinate (*x*, *y*)."""
    img = _grab((x, y, x + 1, y + 1))
    return img.getpixel((0, 0))[:3]  # type: ignore[return-value]

find_image staticmethod

find_image(
    template: str | Path,
    threshold: float = 0.85,
    region: tuple[int, int, int, int] | None = None,
) -> tuple[int, int] | None

Convenience wrapper: create an ImageLocator and call find().

Source code in src\dolphin_desktop\_image.py
@staticmethod
def find_image(
    template: str | Path,
    threshold: float = 0.85,
    region: tuple[int, int, int, int] | None = None,
) -> tuple[int, int] | None:
    """Convenience wrapper: create an ImageLocator and call find()."""
    return ImageLocator(template, threshold).find(region)

text staticmethod

text(
    region: tuple[int, int, int, int] | None = None,
) -> str

Return OCR text of the screen or region via pytesseract.

Source code in src\dolphin_desktop\_image.py
@staticmethod
def text(region: tuple[int, int, int, int] | None = None) -> str:
    """Return OCR text of the screen or *region* via pytesseract."""
    tess = _require_tesseract()
    img = _grab(region)
    return tess.image_to_string(img)

find_text staticmethod

find_text(
    text: str,
    region: tuple[int, int, int, int] | None = None,
) -> tuple[int, int] | None

Return the centre (x, y) of the first bounding box containing text, or None.

Source code in src\dolphin_desktop\_image.py
@staticmethod
def find_text(
    text: str,
    region: tuple[int, int, int, int] | None = None,
) -> tuple[int, int] | None:
    """Return the centre (x, y) of the first bounding box containing *text*, or None."""
    tess = _require_tesseract()
    img = _grab(region)
    data = tess.image_to_data(img, output_type=tess.Output.DICT)

    for i, word in enumerate(data["text"]):
        if text.lower() in str(word).lower():
            x = data["left"][i]
            y = data["top"][i]
            w = data["width"][i]
            h = data["height"][i]
            cx = x + w // 2
            cy = y + h // 2
            if region:
                cx += region[0]
                cy += region[1]
            return cx, cy
    return None

Tab

Bases: Element

A tab control (notebook / property sheet).

Usage::

window.tab().select_tab("General")
window.tab(name="Options").select_tab("Advanced")
Source code in src\dolphin_desktop\_element.py
class Tab(Element):
    """A tab control (notebook / property sheet).

    Usage::

        window.tab().select_tab("General")
        window.tab(name="Options").select_tab("Advanced")
    """

    def select_tab(self, name: str) -> Tab:
        """Select a tab page by its visible name."""
        element = self._resolve()
        try:
            element.child_window(title=name, control_type="TabItem").click_input()
            return self
        except Exception:
            pass
        for child in element.children():
            try:
                if child.window_text() == name:
                    child.click_input()
                    return self
            except Exception:
                continue
        return self

select_tab

select_tab(name: str) -> Tab

Select a tab page by its visible name.

Source code in src\dolphin_desktop\_element.py
def select_tab(self, name: str) -> Tab:
    """Select a tab page by its visible name."""
    element = self._resolve()
    try:
        element.child_window(title=name, control_type="TabItem").click_input()
        return self
    except Exception:
        pass
    for child in element.children():
        try:
            if child.window_text() == name:
                child.click_input()
                return self
        except Exception:
            continue
    return self

Toolbar

Bases: Element

A toolbar control.

Usage::

window.toolbar().button("Bold").click()
window.toolbar(name="Formatting").button("Italic").click()
Source code in src\dolphin_desktop\_element.py
class Toolbar(Element):
    """A toolbar control.

    Usage::

        window.toolbar().button("Bold").click()
        window.toolbar(name="Formatting").button("Italic").click()
    """

    def button(self, name: str) -> Button:
        """Return a :class:`Button` locator for a toolbar button by its accessible name."""
        return Button(self, title=name, control_type="Button")

button

button(name: str) -> Button

Return a :class:Button locator for a toolbar button by its accessible name.

Source code in src\dolphin_desktop\_element.py
def button(self, name: str) -> Button:
    """Return a :class:`Button` locator for a toolbar button by its accessible name."""
    return Button(self, title=name, control_type="Button")

TraceSession

Records one test run to a local directory (trace.db + screenshots/).

Source code in src\dolphin_desktop\_trace.py
class TraceSession:
    """Records one test run to a local directory (trace.db + screenshots/)."""

    def __init__(
        self,
        test_nodeid: str,
        run_dir: Path,
        mode: str = "on-failure",
    ) -> None:
        if mode not in ("off", "on-failure", "always"):
            raise ValueError(f"Invalid trace mode: {mode!r}")

        self.run_id: str = uuid.uuid4().hex[:12]
        self.test_nodeid = test_nodeid
        self.run_dir = run_dir
        self.mode = mode

        self._started_at = time.time()
        self._seq = 0
        self._closed = False

        run_dir.mkdir(parents=True, exist_ok=True)
        (run_dir / "screenshots").mkdir(exist_ok=True)

        self._db: sqlite3.Connection = sqlite3.connect(str(run_dir / "trace.db"))
        self._db.executescript(_DDL)
        if self._db.execute("SELECT COUNT(*) FROM schema_version").fetchone()[0] == 0:
            self._db.execute("INSERT INTO schema_version VALUES (?)", (SCHEMA_VERSION,))
        self._db.execute(
            "INSERT INTO runs (id, test_nodeid, started_at, status) VALUES (?, ?, ?, 'running')",
            (self.run_id, test_nodeid, self._started_at),
        )
        self._db.commit()

    # ------------------------------------------------------------------

    def record_step(
        self,
        action: str,
        selector: str | None,
        element: Any = None,
        error: str | None = None,
    ) -> None:
        """Record one action step.  In *on-failure* mode screenshots are only
        taken when the step produced an error; in *always* mode every step gets
        a screenshot."""
        if self.mode == "off" or self._closed:
            return

        self._seq += 1
        seq = self._seq
        ts = time.time() - self._started_at
        result = "error" if error else "ok"

        screenshot_file: str | None = None
        capture_shot = self.mode == "always" or bool(error)
        if capture_shot:
            try:
                fname = f"step_{seq:04d}.jpg"
                _capture_screenshot(self.run_dir / "screenshots" / fname)
                screenshot_file = fname
            except Exception:
                pass

        uia_tree: str | None = None
        if bool(error) and element is not None:
            uia_tree = _dump_uia_tree(element)

        self._db.execute(
            """INSERT INTO steps
               (run_id, seq, ts, action, selector, result, error, screenshot_file, uia_tree)
               VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
            (self.run_id, seq, ts, action, selector, result, error, screenshot_file, uia_tree),
        )
        self._db.commit()

    # ------------------------------------------------------------------

    def finish(
        self,
        status: str,
        error_message: str | None = None,
        error_traceback: str | None = None,
    ) -> None:
        """Finalise the run record.  Discards the run dir on pass in on-failure mode."""
        if self._closed:
            return
        self._closed = True

        self._db.execute(
            """UPDATE runs
               SET finished_at=?, status=?, error_message=?, error_traceback=?
               WHERE id=?""",
            (time.time(), status, error_message, error_traceback, self.run_id),
        )
        self._db.commit()
        self._db.close()

        if self.mode == "on-failure" and status == "passed":
            shutil.rmtree(self.run_dir, ignore_errors=True)

    def close_without_finish(self) -> None:
        """Close the DB connection without writing a final status (e.g. on error)."""
        if not self._closed:
            self._closed = True
            try:
                self._db.close()
            except Exception:
                pass

record_step

record_step(
    action: str,
    selector: str | None,
    element: Any = None,
    error: str | None = None,
) -> None

Record one action step. In on-failure mode screenshots are only taken when the step produced an error; in always mode every step gets a screenshot.

Source code in src\dolphin_desktop\_trace.py
def record_step(
    self,
    action: str,
    selector: str | None,
    element: Any = None,
    error: str | None = None,
) -> None:
    """Record one action step.  In *on-failure* mode screenshots are only
    taken when the step produced an error; in *always* mode every step gets
    a screenshot."""
    if self.mode == "off" or self._closed:
        return

    self._seq += 1
    seq = self._seq
    ts = time.time() - self._started_at
    result = "error" if error else "ok"

    screenshot_file: str | None = None
    capture_shot = self.mode == "always" or bool(error)
    if capture_shot:
        try:
            fname = f"step_{seq:04d}.jpg"
            _capture_screenshot(self.run_dir / "screenshots" / fname)
            screenshot_file = fname
        except Exception:
            pass

    uia_tree: str | None = None
    if bool(error) and element is not None:
        uia_tree = _dump_uia_tree(element)

    self._db.execute(
        """INSERT INTO steps
           (run_id, seq, ts, action, selector, result, error, screenshot_file, uia_tree)
           VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
        (self.run_id, seq, ts, action, selector, result, error, screenshot_file, uia_tree),
    )
    self._db.commit()

finish

finish(
    status: str,
    error_message: str | None = None,
    error_traceback: str | None = None,
) -> None

Finalise the run record. Discards the run dir on pass in on-failure mode.

Source code in src\dolphin_desktop\_trace.py
def finish(
    self,
    status: str,
    error_message: str | None = None,
    error_traceback: str | None = None,
) -> None:
    """Finalise the run record.  Discards the run dir on pass in on-failure mode."""
    if self._closed:
        return
    self._closed = True

    self._db.execute(
        """UPDATE runs
           SET finished_at=?, status=?, error_message=?, error_traceback=?
           WHERE id=?""",
        (time.time(), status, error_message, error_traceback, self.run_id),
    )
    self._db.commit()
    self._db.close()

    if self.mode == "on-failure" and status == "passed":
        shutil.rmtree(self.run_dir, ignore_errors=True)

close_without_finish

close_without_finish() -> None

Close the DB connection without writing a final status (e.g. on error).

Source code in src\dolphin_desktop\_trace.py
def close_without_finish(self) -> None:
    """Close the DB connection without writing a final status (e.g. on error)."""
    if not self._closed:
        self._closed = True
        try:
            self._db.close()
        except Exception:
            pass

Tree

Bases: Element

A tree view control.

Usage::

tree = window.tree()
tree.expand_item("Root", "Branch")
tree.select_item_by_path("Root", "Branch", "Leaf")
Source code in src\dolphin_desktop\_element.py
class Tree(Element):
    """A tree view control.

    Usage::

        tree = window.tree()
        tree.expand_item("Root", "Branch")
        tree.select_item_by_path("Root", "Branch", "Leaf")
    """

    def expand_item(self, *path: str) -> Tree:
        """Expand a tree node by its path of node titles."""
        element = self._resolve()
        for name in path:
            item = element.child_window(title=name, control_type="TreeItem")
            item.wait("exists visible", timeout=self._timeout)
            try:
                item.expand()
            except Exception:
                item.click_input()
            time.sleep(0.1)
            element = item
        return self

    def select_item_by_path(self, *path: str) -> Tree:
        """Select a tree node by path, expanding parent nodes as needed."""
        if not path:
            return self
        element = self._resolve()
        nodes = list(path)
        while len(nodes) > 1:
            name = nodes.pop(0)
            item = element.child_window(title=name, control_type="TreeItem")
            item.wait("exists visible", timeout=self._timeout)
            try:
                item.expand()
            except Exception:
                item.click_input()
            time.sleep(0.1)
            element = item
        final = element.child_window(title=nodes[0], control_type="TreeItem")
        final.wait("exists visible", timeout=self._timeout)
        try:
            final.select()
        except Exception:
            final.click_input()
        return self

expand_item

expand_item(*path: str) -> Tree

Expand a tree node by its path of node titles.

Source code in src\dolphin_desktop\_element.py
def expand_item(self, *path: str) -> Tree:
    """Expand a tree node by its path of node titles."""
    element = self._resolve()
    for name in path:
        item = element.child_window(title=name, control_type="TreeItem")
        item.wait("exists visible", timeout=self._timeout)
        try:
            item.expand()
        except Exception:
            item.click_input()
        time.sleep(0.1)
        element = item
    return self

select_item_by_path

select_item_by_path(*path: str) -> Tree

Select a tree node by path, expanding parent nodes as needed.

Source code in src\dolphin_desktop\_element.py
def select_item_by_path(self, *path: str) -> Tree:
    """Select a tree node by path, expanding parent nodes as needed."""
    if not path:
        return self
    element = self._resolve()
    nodes = list(path)
    while len(nodes) > 1:
        name = nodes.pop(0)
        item = element.child_window(title=name, control_type="TreeItem")
        item.wait("exists visible", timeout=self._timeout)
        try:
            item.expand()
        except Exception:
            item.click_input()
        time.sleep(0.1)
        element = item
    final = element.child_window(title=nodes[0], control_type="TreeItem")
    final.wait("exists visible", timeout=self._timeout)
    try:
        final.select()
    except Exception:
        final.click_input()
    return self

UIABackend

Bases: Backend

Microsoft UI Automation - default backend for modern Windows apps.

Source code in src\dolphin_desktop\_backend.py
class UIABackend(Backend):
    """Microsoft UI Automation - default backend for modern Windows apps."""

    id = "uia"
    platform = "windows"

    def find_element(self, parent: Any, criteria: dict[str, Any]) -> Any:
        return parent.child_window(**criteria)

    def click(self, element: Any, *, button: str = "left") -> None:
        if button == "right":
            element.right_click_input()
        elif button == "middle":
            element.click_input(button="middle")
        else:
            element.click_input()

    def type_text(self, element: Any, text: str) -> None:
        element.type_keys(text, with_spaces=True)

    def get_tree(self, root: Any, *, depth: int | None = None) -> dict[str, Any]:
        def _node(el: Any, remaining: int | None) -> dict[str, Any]:
            try:
                info = el.element_info
                node: dict[str, Any] = {
                    "name": info.name,
                    "role": info.control_type,
                    "class": info.class_name,
                    "children": [],
                }
            except Exception:
                return {"name": "", "role": "unknown", "class": "", "children": []}
            if remaining is None or remaining > 0:
                try:
                    next_rem = None if remaining is None else remaining - 1
                    node["children"] = [_node(c, next_rem) for c in el.children()]
                except Exception:
                    pass
            return node

        return _node(root, depth)

    def screenshot(self, element: Any | None = None) -> Any:
        if element is None:
            from PIL import ImageGrab

            return ImageGrab.grab()
        return element.capture_as_image()

    def is_available(self) -> bool:
        try:
            import pywinauto  # noqa: F401

            return sys.platform == "win32"
        except ImportError:
            return False

VideoRecorder

Records the screen to MP4 via a background ffmpeg process.

Typical lifecycle::

rec = VideoRecorder(fps=10)
rec.start()
# ... test runs ...
rec.stop()
if failed:
    rec.encode(Path("output.mp4"))
rec.discard()
Source code in src\dolphin_desktop\_video.py
class VideoRecorder:
    """Records the screen to MP4 via a background ffmpeg process.

    Typical lifecycle::

        rec = VideoRecorder(fps=10)
        rec.start()
        # ... test runs ...
        rec.stop()
        if failed:
            rec.encode(Path("output.mp4"))
        rec.discard()
    """

    def __init__(self, fps: int = 10) -> None:
        self._fps = max(1, min(fps, 30))
        self._tmpdir: Path | None = None
        self._outfile: Path | None = None
        self._proc: subprocess.Popen[bytes] | None = None
        self._started = False

    # ------------------------------------------------------------------
    # Lifecycle
    # ------------------------------------------------------------------

    def start(self) -> None:
        """Start an ffmpeg gdigrab process recording the desktop to MP4.

        Raises ``RuntimeError`` if ffmpeg cannot be found.
        Silently skips if already started.
        """
        if self._started:
            return

        ffmpeg = find_ffmpeg()
        if not ffmpeg:
            raise RuntimeError(
                "Video recording requires ffmpeg. Install ffmpeg or set the "
                "DOLPHIN_FFMPEG environment variable to the binary path."
            )

        self._tmpdir = Path(tempfile.mkdtemp(prefix="dolphin_video_"))
        self._outfile = self._tmpdir / "capture.mp4"

        cmd = [
            ffmpeg,
            "-y",
            "-f",
            "gdigrab",
            "-framerate",
            str(self._fps),
            "-i",
            "desktop",
            "-c:v",
            "libx264",
            "-preset",
            "ultrafast",
            "-pix_fmt",
            "yuv420p",  # broad player compatibility
            "-loglevel",
            "error",
            str(self._outfile),
        ]

        creationflags = _CREATE_NO_WINDOW if os.name == "nt" else 0
        self._proc = subprocess.Popen(
            cmd,
            stdin=subprocess.PIPE,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            creationflags=creationflags,
        )
        self._started = True

    def stop(self) -> None:
        """Stop the ffmpeg process, letting it finalise the MP4 container.

        Sends ``q`` on ffmpeg's stdin so the moov atom is written and the file
        stays playable; falls back to terminate/kill if it does not exit.
        """
        if not self._started:
            return
        self._started = False

        proc = self._proc
        if proc is None:
            return

        try:
            if proc.stdin is not None:
                proc.stdin.write(b"q")
                proc.stdin.flush()
                proc.stdin.close()
        except Exception:
            pass

        try:
            proc.wait(timeout=5)
        except Exception:
            proc.terminate()
            try:
                proc.wait(timeout=2)
            except Exception:
                proc.kill()

    # ------------------------------------------------------------------
    # Output
    # ------------------------------------------------------------------

    def encode(self, output_path: Path) -> Path:
        """Save the recorded MP4 to *output_path*.

        ffmpeg already produced an encoded MP4 during capture, so this simply
        copies it to the destination.  Returns *output_path* on success.
        Raises ``RuntimeError`` if nothing was recorded.
        """
        if self._outfile is None or not self._outfile.exists() or self._outfile.stat().st_size == 0:
            raise RuntimeError("No frames captured — nothing to encode.")

        output_path.parent.mkdir(parents=True, exist_ok=True)
        shutil.copyfile(self._outfile, output_path)
        return output_path

    def discard(self) -> None:
        """Delete the temporary recording directory."""
        if self._tmpdir and self._tmpdir.exists():
            shutil.rmtree(self._tmpdir, ignore_errors=True)
        self._tmpdir = None
        self._outfile = None

start

start() -> None

Start an ffmpeg gdigrab process recording the desktop to MP4.

Raises RuntimeError if ffmpeg cannot be found. Silently skips if already started.

Source code in src\dolphin_desktop\_video.py
def start(self) -> None:
    """Start an ffmpeg gdigrab process recording the desktop to MP4.

    Raises ``RuntimeError`` if ffmpeg cannot be found.
    Silently skips if already started.
    """
    if self._started:
        return

    ffmpeg = find_ffmpeg()
    if not ffmpeg:
        raise RuntimeError(
            "Video recording requires ffmpeg. Install ffmpeg or set the "
            "DOLPHIN_FFMPEG environment variable to the binary path."
        )

    self._tmpdir = Path(tempfile.mkdtemp(prefix="dolphin_video_"))
    self._outfile = self._tmpdir / "capture.mp4"

    cmd = [
        ffmpeg,
        "-y",
        "-f",
        "gdigrab",
        "-framerate",
        str(self._fps),
        "-i",
        "desktop",
        "-c:v",
        "libx264",
        "-preset",
        "ultrafast",
        "-pix_fmt",
        "yuv420p",  # broad player compatibility
        "-loglevel",
        "error",
        str(self._outfile),
    ]

    creationflags = _CREATE_NO_WINDOW if os.name == "nt" else 0
    self._proc = subprocess.Popen(
        cmd,
        stdin=subprocess.PIPE,
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        creationflags=creationflags,
    )
    self._started = True

stop

stop() -> None

Stop the ffmpeg process, letting it finalise the MP4 container.

Sends q on ffmpeg's stdin so the moov atom is written and the file stays playable; falls back to terminate/kill if it does not exit.

Source code in src\dolphin_desktop\_video.py
def stop(self) -> None:
    """Stop the ffmpeg process, letting it finalise the MP4 container.

    Sends ``q`` on ffmpeg's stdin so the moov atom is written and the file
    stays playable; falls back to terminate/kill if it does not exit.
    """
    if not self._started:
        return
    self._started = False

    proc = self._proc
    if proc is None:
        return

    try:
        if proc.stdin is not None:
            proc.stdin.write(b"q")
            proc.stdin.flush()
            proc.stdin.close()
    except Exception:
        pass

    try:
        proc.wait(timeout=5)
    except Exception:
        proc.terminate()
        try:
            proc.wait(timeout=2)
        except Exception:
            proc.kill()

encode

encode(output_path: Path) -> Path

Save the recorded MP4 to output_path.

ffmpeg already produced an encoded MP4 during capture, so this simply copies it to the destination. Returns output_path on success. Raises RuntimeError if nothing was recorded.

Source code in src\dolphin_desktop\_video.py
def encode(self, output_path: Path) -> Path:
    """Save the recorded MP4 to *output_path*.

    ffmpeg already produced an encoded MP4 during capture, so this simply
    copies it to the destination.  Returns *output_path* on success.
    Raises ``RuntimeError`` if nothing was recorded.
    """
    if self._outfile is None or not self._outfile.exists() or self._outfile.stat().st_size == 0:
        raise RuntimeError("No frames captured — nothing to encode.")

    output_path.parent.mkdir(parents=True, exist_ok=True)
    shutil.copyfile(self._outfile, output_path)
    return output_path

discard

discard() -> None

Delete the temporary recording directory.

Source code in src\dolphin_desktop\_video.py
def discard(self) -> None:
    """Delete the temporary recording directory."""
    if self._tmpdir and self._tmpdir.exists():
        shutil.rmtree(self._tmpdir, ignore_errors=True)
    self._tmpdir = None
    self._outfile = None

WaitTimeoutError

Bases: DolphinError

Raised when a wait condition is not met within the allowed time.

Source code in src\dolphin_desktop\_exceptions.py
class WaitTimeoutError(DolphinError):
    """Raised when a wait condition is not met within the allowed time."""

Win32Backend

Bases: Backend

Win32 HWND backend - best for legacy apps (MFC, VB6, Delphi/VCL).

Source code in src\dolphin_desktop\_backend.py
class Win32Backend(Backend):
    """Win32 HWND backend - best for legacy apps (MFC, VB6, Delphi/VCL)."""

    id = "win32"
    platform = "windows"

    def find_element(self, parent: Any, criteria: dict[str, Any]) -> Any:
        return parent.child_window(**criteria)

    def click(self, element: Any, *, button: str = "left") -> None:
        if button == "right":
            element.right_click_input()
        elif button == "middle":
            element.click_input(button="middle")
        else:
            element.click_input()

    def type_text(self, element: Any, text: str) -> None:
        element.type_keys(text, with_spaces=True)

    def get_tree(self, root: Any, *, depth: int | None = None) -> dict[str, Any]:
        def _node(el: Any, remaining: int | None) -> dict[str, Any]:
            try:
                node: dict[str, Any] = {
                    "name": el.window_text(),
                    "role": el.friendly_class_name(),
                    "class": el.class_name(),
                    "children": [],
                }
            except Exception:
                return {"name": "", "role": "unknown", "class": "", "children": []}
            if remaining is None or remaining > 0:
                try:
                    next_rem = None if remaining is None else remaining - 1
                    node["children"] = [_node(c, next_rem) for c in el.children()]
                except Exception:
                    pass
            return node

        return _node(root, depth)

    def screenshot(self, element: Any | None = None) -> Any:
        if element is None:
            from PIL import ImageGrab

            return ImageGrab.grab()
        return element.capture_as_image()

    def is_available(self) -> bool:
        try:
            import pywinauto  # noqa: F401

            return sys.platform == "win32"
        except ImportError:
            return False

Window

Represents a single window (top-level or dialog).

Obtain via :meth:Application.window or :meth:Application.top_window.

Usage::

window = app.window(title_re=".*Notepad")
window.get_by_role("Edit").type_text("hello")
window.screenshot("snap.png")
Source code in src\dolphin_desktop\_window.py
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
class Window:
    """Represents a single window (top-level or dialog).

    Obtain via :meth:`Application.window` or :meth:`Application.top_window`.

    Usage::

        window = app.window(title_re=".*Notepad")
        window.get_by_role("Edit").type_text("hello")
        window.screenshot("snap.png")
    """

    def __init__(self, spec: Any) -> None:
        self._spec = spec

    # ------------------------------------------------------------------
    # Locator factories  (mirror Playwright's page.getBy* API)
    # ------------------------------------------------------------------

    def locator(self, **criteria: Any) -> Any:
        """Find elements by arbitrary pywinauto criteria (title, control_type, auto_id, …)."""
        if self._is_java_window():
            from ._java import JABLocator

            return JABLocator(
                self._java_hwnd(),
                control_type=criteria.get("control_type"),
                title=criteria.get("title"),
                title_re=criteria.get("title_re"),
            )
        return Locator(self, **criteria)

    def get_by_title(self, title: str, *, control_type: str | None = None) -> Any:
        """Find an element by its accessible name / window text."""
        if self._is_java_window():
            from ._java import JABLocator

            return JABLocator(self._java_hwnd(), control_type=control_type, title=title)
        criteria: dict[str, Any] = {"title": title}
        if control_type:
            criteria["control_type"] = control_type
        return Locator(self, **criteria)

    def get_by_role(self, role: str, *, name: str | None = None) -> Any:
        """Find an element by its UIA control type (e.g. 'Button', 'Edit', 'List')."""
        if self._is_java_window():
            from ._java import JABLocator

            return JABLocator(self._java_hwnd(), control_type=role, title=name)
        criteria: dict[str, Any] = {"control_type": role}
        if name:
            criteria["title"] = name
        return Locator(self, **criteria)

    def get_by_automation_id(self, automation_id: str) -> Locator:
        """Find an element by its UIA AutomationId."""
        return Locator(self, auto_id=automation_id)

    def get_by_class(self, class_name: str) -> Locator:
        """Find an element by its Win32 class name."""
        return Locator(self, class_name=class_name)

    def get_by_text(self, text: str) -> Any:
        """Alias for get_by_title — find by exact visible text."""
        if self._is_java_window():
            from ._java import JABLocator

            return JABLocator(self._java_hwnd(), title=text)
        return Locator(self, title=text)

    # ------------------------------------------------------------------
    # Specialized control factories (shorthand for common control types)
    # ------------------------------------------------------------------

    def button(self, name: str | None = None, **kw: Any) -> Button:
        """Find a Button control.

        Usage::

            window.button(name="OK").click()
            window.button(name="Apply").is_enabled()
        """
        criteria: dict[str, Any] = {"control_type": "Button", **kw}
        if name is not None:
            criteria["title"] = name
        return Button(self, **criteria)

    def edit(self, name: str | None = None, **kw: Any) -> Edit:
        """Find an Edit (text input) control.

        Usage::

            window.edit(name="Username").type_text("admin")
            window.edit().set_text("hello")
        """
        criteria: dict[str, Any] = {"control_type": "Edit", **kw}
        if name is not None:
            criteria["title"] = name
        return Edit(self, **criteria)

    def combo_box(self, name: str | None = None, **kw: Any) -> ComboBox:
        """Find a ComboBox (drop-down) control.

        Usage::

            window.combo_box(name="Language").select_item("English")
        """
        criteria: dict[str, Any] = {"control_type": "ComboBox", **kw}
        if name is not None:
            criteria["title"] = name
        return ComboBox(self, **criteria)

    def check_box(self, name: str | None = None, **kw: Any) -> CheckBox:
        """Find a CheckBox control.

        Usage::

            window.check_box(name="Remember me").check()
        """
        criteria: dict[str, Any] = {"control_type": "CheckBox", **kw}
        if name is not None:
            criteria["title"] = name
        return CheckBox(self, **criteria)

    def radio_button(self, name: str | None = None, **kw: Any) -> RadioButton:
        """Find a RadioButton control.

        Usage::

            window.radio_button(name="Option A").select()
        """
        criteria: dict[str, Any] = {"control_type": "RadioButton", **kw}
        if name is not None:
            criteria["title"] = name
        return RadioButton(self, **criteria)

    def menu(self, title: str | None = None, **kw: Any) -> Menu:
        """Find a top-level menu item (e.g. File, Edit, View).

        Usage::

            window.menu("File").item("Save").click()
            window.menu("Edit").item("Find...").click()
        """
        criteria: dict[str, Any] = {"control_type": "MenuItem", **kw}
        if title is not None:
            criteria["title"] = title
        return Menu(self, **criteria)

    def tree(self, name: str | None = None, **kw: Any) -> Tree:
        """Find a Tree (tree view) control.

        Usage::

            window.tree().expand_item("Root", "Branch")
            window.tree().select_item_by_path("Root", "Node")
        """
        criteria: dict[str, Any] = {"control_type": "Tree", **kw}
        if name is not None:
            criteria["title"] = name
        return Tree(self, **criteria)

    def list_box(self, name: str | None = None, **kw: Any) -> ListBox:
        """Find a ListBox control.

        Usage::

            window.list_box().select_item("Option A")
            names = window.list_box().items()
        """
        criteria: dict[str, Any] = {"control_type": "List", **kw}
        if name is not None:
            criteria["title"] = name
        return ListBox(self, **criteria)

    def tab(self, name: str | None = None, **kw: Any) -> Tab:
        """Find a Tab (notebook) control.

        Usage::

            window.tab().select_tab("General")
        """
        criteria: dict[str, Any] = {"control_type": "Tab", **kw}
        if name is not None:
            criteria["title"] = name
        return Tab(self, **criteria)

    def toolbar(self, name: str | None = None, **kw: Any) -> Toolbar:
        """Find a Toolbar control.

        Usage::

            window.toolbar().button("Bold").click()
        """
        criteria: dict[str, Any] = {"control_type": "ToolBar", **kw}
        if name is not None:
            criteria["title"] = name
        return Toolbar(self, **criteria)

    def element(self, alias: str) -> Any:
        """Find an element by alias from the Object Repository.

        Looks up *alias* in the children of the current window's alias first
        (if this window was created via ``app.window("alias")``), then falls
        back to a flat top-level search.

        Usage::

            objects.load("objects/login.yaml")
            win = app.window("login_window")
            win.element("email_input").type_text("user@example.com")
        """
        from .objects import _repository

        parent_alias: str | None = getattr(self, "_alias", None)
        if parent_alias:
            entry = _repository.resolve_child(parent_alias, alias)
        else:
            entry = _repository.resolve(alias)

        return Locator(self, fallback=entry.fallback or None, **entry.selector)

    def image(
        self,
        template: str | Path,
        *,
        confidence: float = 0.85,
        scales: list[float] | None = None,
    ) -> Any:
        """Find an element by template image matching, scoped to this window's bounds.

        Uses OpenCV template matching (requires ``dolphin-desktop[vision]``).

        Usage::

            window.image("btn_ok.png").click()
            window.image("icon.png", confidence=0.9).wait_for(timeout=5)
            window.image("logo.png", scales=[0.8, 1.0, 1.2]).exists()
        """
        from ._image import ImageLocator

        try:
            bb = self.bounding_box()
            region: tuple[int, int, int, int] | None = (
                bb["left"],
                bb["top"],
                bb["right"],
                bb["bottom"],
            )
        except Exception:
            region = None

        return ImageLocator(template, threshold=confidence, scales=scales, region=region)

    def find_by_xpath(self, xpath: str) -> Locator:
        """Find an element using a simplified XPath expression.

        Supports a subset of XPath syntax for navigating the UIA element tree:

        Usage::

            window.find_by_xpath("//Button[@Name='OK']").click()
            window.find_by_xpath("//Edit[@AutomationId='tbSearch']").type_text("q")
            window.find_by_xpath("//MenuBar//MenuItem[@Name='File']")

        Supported syntax:

        * ``//Tag`` or ``/Tag`` — find descendant with that control type
        * ``[@Name='val']``         → ``title='val'``
        * ``[@AutomationId='val']`` → ``auto_id='val'``
        * ``[@ClassName='val']``    → ``class_name='val'``

        Multiple segments chain locators::

            //MenuBar//MenuItem[@Name='File']
        """
        return _parse_xpath(self, xpath)

    # ------------------------------------------------------------------
    # Window actions
    # ------------------------------------------------------------------

    def close(self) -> None:
        self._spec.close()

    def maximize(self) -> None:
        self._spec.maximize()

    def minimize(self) -> None:
        self._spec.minimize()

    def restore(self) -> None:
        self._spec.restore()

    def focus(self) -> None:
        self._spec.set_focus()

    def move(self, x: int, y: int) -> None:
        rect = self._spec.rectangle()
        self._move_window(x, y, rect.right - rect.left, rect.bottom - rect.top)

    def resize(self, width: int, height: int) -> None:
        rect = self._spec.rectangle()
        self._move_window(rect.left, rect.top, width, height)

    def _move_window(self, x: int, y: int, width: int, height: int) -> None:
        """Move/resize the window via Win32 (works on both uia and win32 backends).

        pywinauto's ``move_window`` exists only on the win32 ``HwndWrapper``; the
        UIA wrapper lacks it, so we drive ``win32gui.MoveWindow`` by HWND directly.
        """
        import win32gui  # type: ignore[import-untyped]

        win32gui.MoveWindow(self._spec.handle, x, y, width, height, True)

    # ------------------------------------------------------------------
    # Queries
    # ------------------------------------------------------------------

    def title(self) -> str:
        return self._spec.window_text()

    def exists(self) -> bool:
        return bool(self._spec.exists())

    def is_visible(self) -> bool:
        return bool(self._spec.is_visible())

    def is_active(self) -> bool:
        return bool(self._spec.is_active())

    def bounding_box(self) -> dict[str, int]:
        rect = self._spec.rectangle()
        return {
            "left": rect.left,
            "top": rect.top,
            "right": rect.right,
            "bottom": rect.bottom,
            "width": rect.right - rect.left,
            "height": rect.bottom - rect.top,
        }

    # ------------------------------------------------------------------
    # Screenshot
    # ------------------------------------------------------------------

    def screenshot(self, path: str | Path | None = None) -> Image:
        """Capture the window as a PIL Image, optionally saving to *path*."""
        img = self._spec.capture_as_image()
        if path:
            img.save(path)
        return img

    # ------------------------------------------------------------------
    # Waiting
    # ------------------------------------------------------------------

    def wait_for_close(self, timeout: float = 10.0) -> None:
        """Wait until the window is no longer visible."""
        try:
            self._spec.wait_not("visible", timeout=timeout)
        except Exception as exc:
            raise WaitTimeoutError(f"Window did not close after {timeout}s") from exc

    def wait_until_ready(self, timeout: float = 10.0) -> Window:
        """Wait until the window is ready (not busy)."""
        try:
            self._spec.wait("ready", timeout=timeout)
        except Exception as exc:
            raise WaitTimeoutError(f"Window not ready after {timeout}s") from exc
        return self

    # ------------------------------------------------------------------
    # Internal
    # ------------------------------------------------------------------

    def _is_java_window(self) -> bool:
        try:
            import win32gui  # type: ignore[import-untyped]

            return win32gui.GetClassName(self._java_hwnd()) == "SunAwtFrame"
        except Exception:
            return False

    def _java_hwnd(self) -> int:
        return self._spec.wrapper_object().handle

    def _get_spec(self) -> Any:
        return self._spec

    def __repr__(self) -> str:
        return "Window()"

locator

locator(**criteria: Any) -> Any

Find elements by arbitrary pywinauto criteria (title, control_type, auto_id, …).

Source code in src\dolphin_desktop\_window.py
def locator(self, **criteria: Any) -> Any:
    """Find elements by arbitrary pywinauto criteria (title, control_type, auto_id, …)."""
    if self._is_java_window():
        from ._java import JABLocator

        return JABLocator(
            self._java_hwnd(),
            control_type=criteria.get("control_type"),
            title=criteria.get("title"),
            title_re=criteria.get("title_re"),
        )
    return Locator(self, **criteria)

get_by_title

get_by_title(
    title: str, *, control_type: str | None = None
) -> Any

Find an element by its accessible name / window text.

Source code in src\dolphin_desktop\_window.py
def get_by_title(self, title: str, *, control_type: str | None = None) -> Any:
    """Find an element by its accessible name / window text."""
    if self._is_java_window():
        from ._java import JABLocator

        return JABLocator(self._java_hwnd(), control_type=control_type, title=title)
    criteria: dict[str, Any] = {"title": title}
    if control_type:
        criteria["control_type"] = control_type
    return Locator(self, **criteria)

get_by_role

get_by_role(role: str, *, name: str | None = None) -> Any

Find an element by its UIA control type (e.g. 'Button', 'Edit', 'List').

Source code in src\dolphin_desktop\_window.py
def get_by_role(self, role: str, *, name: str | None = None) -> Any:
    """Find an element by its UIA control type (e.g. 'Button', 'Edit', 'List')."""
    if self._is_java_window():
        from ._java import JABLocator

        return JABLocator(self._java_hwnd(), control_type=role, title=name)
    criteria: dict[str, Any] = {"control_type": role}
    if name:
        criteria["title"] = name
    return Locator(self, **criteria)

get_by_automation_id

get_by_automation_id(automation_id: str) -> Locator

Find an element by its UIA AutomationId.

Source code in src\dolphin_desktop\_window.py
def get_by_automation_id(self, automation_id: str) -> Locator:
    """Find an element by its UIA AutomationId."""
    return Locator(self, auto_id=automation_id)

get_by_class

get_by_class(class_name: str) -> Locator

Find an element by its Win32 class name.

Source code in src\dolphin_desktop\_window.py
def get_by_class(self, class_name: str) -> Locator:
    """Find an element by its Win32 class name."""
    return Locator(self, class_name=class_name)

get_by_text

get_by_text(text: str) -> Any

Alias for get_by_title — find by exact visible text.

Source code in src\dolphin_desktop\_window.py
def get_by_text(self, text: str) -> Any:
    """Alias for get_by_title — find by exact visible text."""
    if self._is_java_window():
        from ._java import JABLocator

        return JABLocator(self._java_hwnd(), title=text)
    return Locator(self, title=text)

button

button(name: str | None = None, **kw: Any) -> Button

Find a Button control.

Usage::

window.button(name="OK").click()
window.button(name="Apply").is_enabled()
Source code in src\dolphin_desktop\_window.py
def button(self, name: str | None = None, **kw: Any) -> Button:
    """Find a Button control.

    Usage::

        window.button(name="OK").click()
        window.button(name="Apply").is_enabled()
    """
    criteria: dict[str, Any] = {"control_type": "Button", **kw}
    if name is not None:
        criteria["title"] = name
    return Button(self, **criteria)

edit

edit(name: str | None = None, **kw: Any) -> Edit

Find an Edit (text input) control.

Usage::

window.edit(name="Username").type_text("admin")
window.edit().set_text("hello")
Source code in src\dolphin_desktop\_window.py
def edit(self, name: str | None = None, **kw: Any) -> Edit:
    """Find an Edit (text input) control.

    Usage::

        window.edit(name="Username").type_text("admin")
        window.edit().set_text("hello")
    """
    criteria: dict[str, Any] = {"control_type": "Edit", **kw}
    if name is not None:
        criteria["title"] = name
    return Edit(self, **criteria)

combo_box

combo_box(name: str | None = None, **kw: Any) -> ComboBox

Find a ComboBox (drop-down) control.

Usage::

window.combo_box(name="Language").select_item("English")
Source code in src\dolphin_desktop\_window.py
def combo_box(self, name: str | None = None, **kw: Any) -> ComboBox:
    """Find a ComboBox (drop-down) control.

    Usage::

        window.combo_box(name="Language").select_item("English")
    """
    criteria: dict[str, Any] = {"control_type": "ComboBox", **kw}
    if name is not None:
        criteria["title"] = name
    return ComboBox(self, **criteria)

check_box

check_box(name: str | None = None, **kw: Any) -> CheckBox

Find a CheckBox control.

Usage::

window.check_box(name="Remember me").check()
Source code in src\dolphin_desktop\_window.py
def check_box(self, name: str | None = None, **kw: Any) -> CheckBox:
    """Find a CheckBox control.

    Usage::

        window.check_box(name="Remember me").check()
    """
    criteria: dict[str, Any] = {"control_type": "CheckBox", **kw}
    if name is not None:
        criteria["title"] = name
    return CheckBox(self, **criteria)

radio_button

radio_button(
    name: str | None = None, **kw: Any
) -> RadioButton

Find a RadioButton control.

Usage::

window.radio_button(name="Option A").select()
Source code in src\dolphin_desktop\_window.py
def radio_button(self, name: str | None = None, **kw: Any) -> RadioButton:
    """Find a RadioButton control.

    Usage::

        window.radio_button(name="Option A").select()
    """
    criteria: dict[str, Any] = {"control_type": "RadioButton", **kw}
    if name is not None:
        criteria["title"] = name
    return RadioButton(self, **criteria)

menu

menu(title: str | None = None, **kw: Any) -> Menu

Find a top-level menu item (e.g. File, Edit, View).

Usage::

window.menu("File").item("Save").click()
window.menu("Edit").item("Find...").click()
Source code in src\dolphin_desktop\_window.py
def menu(self, title: str | None = None, **kw: Any) -> Menu:
    """Find a top-level menu item (e.g. File, Edit, View).

    Usage::

        window.menu("File").item("Save").click()
        window.menu("Edit").item("Find...").click()
    """
    criteria: dict[str, Any] = {"control_type": "MenuItem", **kw}
    if title is not None:
        criteria["title"] = title
    return Menu(self, **criteria)

tree

tree(name: str | None = None, **kw: Any) -> Tree

Find a Tree (tree view) control.

Usage::

window.tree().expand_item("Root", "Branch")
window.tree().select_item_by_path("Root", "Node")
Source code in src\dolphin_desktop\_window.py
def tree(self, name: str | None = None, **kw: Any) -> Tree:
    """Find a Tree (tree view) control.

    Usage::

        window.tree().expand_item("Root", "Branch")
        window.tree().select_item_by_path("Root", "Node")
    """
    criteria: dict[str, Any] = {"control_type": "Tree", **kw}
    if name is not None:
        criteria["title"] = name
    return Tree(self, **criteria)

list_box

list_box(name: str | None = None, **kw: Any) -> ListBox

Find a ListBox control.

Usage::

window.list_box().select_item("Option A")
names = window.list_box().items()
Source code in src\dolphin_desktop\_window.py
def list_box(self, name: str | None = None, **kw: Any) -> ListBox:
    """Find a ListBox control.

    Usage::

        window.list_box().select_item("Option A")
        names = window.list_box().items()
    """
    criteria: dict[str, Any] = {"control_type": "List", **kw}
    if name is not None:
        criteria["title"] = name
    return ListBox(self, **criteria)

tab

tab(name: str | None = None, **kw: Any) -> Tab

Find a Tab (notebook) control.

Usage::

window.tab().select_tab("General")
Source code in src\dolphin_desktop\_window.py
def tab(self, name: str | None = None, **kw: Any) -> Tab:
    """Find a Tab (notebook) control.

    Usage::

        window.tab().select_tab("General")
    """
    criteria: dict[str, Any] = {"control_type": "Tab", **kw}
    if name is not None:
        criteria["title"] = name
    return Tab(self, **criteria)

toolbar

toolbar(name: str | None = None, **kw: Any) -> Toolbar

Find a Toolbar control.

Usage::

window.toolbar().button("Bold").click()
Source code in src\dolphin_desktop\_window.py
def toolbar(self, name: str | None = None, **kw: Any) -> Toolbar:
    """Find a Toolbar control.

    Usage::

        window.toolbar().button("Bold").click()
    """
    criteria: dict[str, Any] = {"control_type": "ToolBar", **kw}
    if name is not None:
        criteria["title"] = name
    return Toolbar(self, **criteria)

element

element(alias: str) -> Any

Find an element by alias from the Object Repository.

Looks up alias in the children of the current window's alias first (if this window was created via app.window("alias")), then falls back to a flat top-level search.

Usage::

objects.load("objects/login.yaml")
win = app.window("login_window")
win.element("email_input").type_text("user@example.com")
Source code in src\dolphin_desktop\_window.py
def element(self, alias: str) -> Any:
    """Find an element by alias from the Object Repository.

    Looks up *alias* in the children of the current window's alias first
    (if this window was created via ``app.window("alias")``), then falls
    back to a flat top-level search.

    Usage::

        objects.load("objects/login.yaml")
        win = app.window("login_window")
        win.element("email_input").type_text("user@example.com")
    """
    from .objects import _repository

    parent_alias: str | None = getattr(self, "_alias", None)
    if parent_alias:
        entry = _repository.resolve_child(parent_alias, alias)
    else:
        entry = _repository.resolve(alias)

    return Locator(self, fallback=entry.fallback or None, **entry.selector)

image

image(
    template: str | Path,
    *,
    confidence: float = 0.85,
    scales: list[float] | None = None,
) -> Any

Find an element by template image matching, scoped to this window's bounds.

Uses OpenCV template matching (requires dolphin-desktop[vision]).

Usage::

window.image("btn_ok.png").click()
window.image("icon.png", confidence=0.9).wait_for(timeout=5)
window.image("logo.png", scales=[0.8, 1.0, 1.2]).exists()
Source code in src\dolphin_desktop\_window.py
def image(
    self,
    template: str | Path,
    *,
    confidence: float = 0.85,
    scales: list[float] | None = None,
) -> Any:
    """Find an element by template image matching, scoped to this window's bounds.

    Uses OpenCV template matching (requires ``dolphin-desktop[vision]``).

    Usage::

        window.image("btn_ok.png").click()
        window.image("icon.png", confidence=0.9).wait_for(timeout=5)
        window.image("logo.png", scales=[0.8, 1.0, 1.2]).exists()
    """
    from ._image import ImageLocator

    try:
        bb = self.bounding_box()
        region: tuple[int, int, int, int] | None = (
            bb["left"],
            bb["top"],
            bb["right"],
            bb["bottom"],
        )
    except Exception:
        region = None

    return ImageLocator(template, threshold=confidence, scales=scales, region=region)

find_by_xpath

find_by_xpath(xpath: str) -> Locator

Find an element using a simplified XPath expression.

Supports a subset of XPath syntax for navigating the UIA element tree:

Usage::

window.find_by_xpath("//Button[@Name='OK']").click()
window.find_by_xpath("//Edit[@AutomationId='tbSearch']").type_text("q")
window.find_by_xpath("//MenuBar//MenuItem[@Name='File']")

Supported syntax:

  • //Tag or /Tag — find descendant with that control type
  • [@Name='val']title='val'
  • [@AutomationId='val']auto_id='val'
  • [@ClassName='val']class_name='val'

Multiple segments chain locators::

//MenuBar//MenuItem[@Name='File']
Source code in src\dolphin_desktop\_window.py
def find_by_xpath(self, xpath: str) -> Locator:
    """Find an element using a simplified XPath expression.

    Supports a subset of XPath syntax for navigating the UIA element tree:

    Usage::

        window.find_by_xpath("//Button[@Name='OK']").click()
        window.find_by_xpath("//Edit[@AutomationId='tbSearch']").type_text("q")
        window.find_by_xpath("//MenuBar//MenuItem[@Name='File']")

    Supported syntax:

    * ``//Tag`` or ``/Tag`` — find descendant with that control type
    * ``[@Name='val']``         → ``title='val'``
    * ``[@AutomationId='val']`` → ``auto_id='val'``
    * ``[@ClassName='val']``    → ``class_name='val'``

    Multiple segments chain locators::

        //MenuBar//MenuItem[@Name='File']
    """
    return _parse_xpath(self, xpath)

screenshot

screenshot(path: str | Path | None = None) -> Image

Capture the window as a PIL Image, optionally saving to path.

Source code in src\dolphin_desktop\_window.py
def screenshot(self, path: str | Path | None = None) -> Image:
    """Capture the window as a PIL Image, optionally saving to *path*."""
    img = self._spec.capture_as_image()
    if path:
        img.save(path)
    return img

wait_for_close

wait_for_close(timeout: float = 10.0) -> None

Wait until the window is no longer visible.

Source code in src\dolphin_desktop\_window.py
def wait_for_close(self, timeout: float = 10.0) -> None:
    """Wait until the window is no longer visible."""
    try:
        self._spec.wait_not("visible", timeout=timeout)
    except Exception as exc:
        raise WaitTimeoutError(f"Window did not close after {timeout}s") from exc

wait_until_ready

wait_until_ready(timeout: float = 10.0) -> Window

Wait until the window is ready (not busy).

Source code in src\dolphin_desktop\_window.py
def wait_until_ready(self, timeout: float = 10.0) -> Window:
    """Wait until the window is ready (not busy)."""
    try:
        self._spec.wait("ready", timeout=timeout)
    except Exception as exc:
        raise WaitTimeoutError(f"Window not ready after {timeout}s") from exc
    return self

WindowNotFoundError

Bases: DolphinError

Raised when a window cannot be found.

Source code in src\dolphin_desktop\_exceptions.py
class WindowNotFoundError(DolphinError):
    """Raised when a window cannot be found."""

WordApp

COM wrapper for Microsoft Word.

Source code in src\dolphin_desktop\_office.py
class WordApp:
    """COM wrapper for Microsoft Word."""

    def __init__(self, _com: Any) -> None:
        self._com = _com

    @classmethod
    def open(cls, path: str | Path, *, visible: bool = True) -> WordApp:
        """Open a document from *path* and return a WordApp."""
        client = _require_win32com()
        wd = client.Dispatch("Word.Application")
        wd.Visible = visible
        wd.Documents.Open(str(Path(path).resolve()))
        return cls(wd)

    @classmethod
    def connect(cls) -> WordApp:
        """Connect to an already-running Word instance."""
        client = _require_win32com()
        try:
            wd = client.GetActiveObject("Word.Application")
        except Exception as exc:
            raise RuntimeError("No running Word instance found.") from exc
        return cls(wd)

    @property
    def active_document(self) -> WordDocument:
        """The currently active document."""
        return WordDocument(self._com.ActiveDocument)

    def quit(self, *, save_changes: bool = False) -> None:
        """Quit Word, optionally saving all open documents."""
        self._com.DisplayAlerts = False
        if save_changes:
            for doc in self._com.Documents:
                doc.Save()
        self._com.Quit()

    def __enter__(self) -> WordApp:
        return self

    def __exit__(self, *_: Any) -> None:
        self.quit(save_changes=False)

active_document property

active_document: WordDocument

The currently active document.

open classmethod

open(path: str | Path, *, visible: bool = True) -> WordApp

Open a document from path and return a WordApp.

Source code in src\dolphin_desktop\_office.py
@classmethod
def open(cls, path: str | Path, *, visible: bool = True) -> WordApp:
    """Open a document from *path* and return a WordApp."""
    client = _require_win32com()
    wd = client.Dispatch("Word.Application")
    wd.Visible = visible
    wd.Documents.Open(str(Path(path).resolve()))
    return cls(wd)

connect classmethod

connect() -> WordApp

Connect to an already-running Word instance.

Source code in src\dolphin_desktop\_office.py
@classmethod
def connect(cls) -> WordApp:
    """Connect to an already-running Word instance."""
    client = _require_win32com()
    try:
        wd = client.GetActiveObject("Word.Application")
    except Exception as exc:
        raise RuntimeError("No running Word instance found.") from exc
    return cls(wd)

quit

quit(*, save_changes: bool = False) -> None

Quit Word, optionally saving all open documents.

Source code in src\dolphin_desktop\_office.py
def quit(self, *, save_changes: bool = False) -> None:
    """Quit Word, optionally saving all open documents."""
    self._com.DisplayAlerts = False
    if save_changes:
        for doc in self._com.Documents:
            doc.Save()
    self._com.Quit()

config

config(
    *,
    timeout: float | None = None,
    poll_interval: float | None = None,
    trace_mode: str | None = None,
    video_mode: str | None = None,
    video_fps: int | None = None,
    log_level: str | None = None,
    retry_count: int | None = None,
) -> None

Set global dolphin defaults.

Args: timeout: Default element-wait timeout in seconds (default 10.0). Also settable via the DOLPHIN_TIMEOUT environment variable or the --dolphin-timeout pytest CLI option. poll_interval: Polling interval for retry loops, in seconds (default 0.1). trace_mode: Trace capture mode — "off", "on-failure" (default), or "always". Also settable via DOLPHIN_TRACE env var or the --dolphin-trace pytest CLI option. video_mode: Video recording mode — "off", "keepfailedonly" (default), or "keepall". Also settable via DOLPHIN_VIDEO env var or the --dolphin-video pytest CLI option. video_fps: Capture frame rate in frames/second (default 10, max 30). Also settable via DOLPHIN_VIDEO_FPS env var. log_level: Logging verbosity — "DEBUG", "INFO" (default), or "ERROR". Also settable via DOLPHIN_LOG_LEVEL env var or the --dolphin-log-level pytest CLI option. retry_count: Number of times to retry a test that fails with a transient dolphin error (ElementNotFoundError / WaitTimeoutError). Default 0 (no retry). Also settable via DOLPHIN_RETRY env var or the --dolphin-retry pytest CLI option.

Source code in src\dolphin_desktop\_config.py
def config(
    *,
    timeout: float | None = None,
    poll_interval: float | None = None,
    trace_mode: str | None = None,
    video_mode: str | None = None,
    video_fps: int | None = None,
    log_level: str | None = None,
    retry_count: int | None = None,
) -> None:
    """Set global dolphin defaults.

    Args:
        timeout: Default element-wait timeout in seconds (default 10.0).
            Also settable via the ``DOLPHIN_TIMEOUT`` environment variable or
            the ``--dolphin-timeout`` pytest CLI option.
        poll_interval: Polling interval for retry loops, in seconds (default 0.1).
        trace_mode: Trace capture mode — ``"off"``, ``"on-failure"`` (default),
            or ``"always"``.  Also settable via ``DOLPHIN_TRACE`` env var or
            the ``--dolphin-trace`` pytest CLI option.
        video_mode: Video recording mode — ``"off"``, ``"keepfailedonly"`` (default),
            or ``"keepall"``.  Also settable via ``DOLPHIN_VIDEO`` env var or
            the ``--dolphin-video`` pytest CLI option.
        video_fps: Capture frame rate in frames/second (default 10, max 30).
            Also settable via ``DOLPHIN_VIDEO_FPS`` env var.
        log_level: Logging verbosity — ``"DEBUG"``, ``"INFO"`` (default), or
            ``"ERROR"``.  Also settable via ``DOLPHIN_LOG_LEVEL`` env var or
            the ``--dolphin-log-level`` pytest CLI option.
        retry_count: Number of times to retry a test that fails with a transient
            dolphin error (``ElementNotFoundError`` / ``WaitTimeoutError``).
            Default ``0`` (no retry).  Also settable via ``DOLPHIN_RETRY`` env var
            or the ``--dolphin-retry`` pytest CLI option.
    """
    if timeout is not None:
        _defaults["timeout"] = float(timeout)
    if poll_interval is not None:
        _defaults["poll_interval"] = float(poll_interval)
    if trace_mode is not None:
        if trace_mode not in ("off", "on-failure", "always"):
            raise ValueError(f"Invalid trace_mode: {trace_mode!r}")
        _defaults["trace_mode"] = trace_mode
    if video_mode is not None:
        from ._video import VALID_MODES

        if video_mode not in VALID_MODES:
            raise ValueError(f"Invalid video_mode: {video_mode!r}")
        _defaults["video_mode"] = video_mode
    if video_fps is not None:
        _defaults["video_fps"] = int(video_fps)
    if log_level is not None:
        _defaults["log_level"] = log_level.upper()
    if retry_count is not None:
        _defaults["retry_count"] = int(retry_count)

get_logger

get_logger(name: str = 'dolphin') -> logging.Logger

Return logging.getLogger("dolphin") or a named child logger.

Source code in src\dolphin_desktop\_logging.py
def get_logger(name: str = "dolphin") -> logging.Logger:
    """Return ``logging.getLogger("dolphin")`` or a named child logger."""
    if name == "dolphin":
        return logging.getLogger("dolphin")
    return logging.getLogger(f"dolphin_desktop.{name}")

list_backends

list_backends() -> list[dict[str, Any]]

Return info dicts for all registered backends.

Each dict has keys: id, platform, class, available, description, source ("built-in" or entry-point value).

Plugin backends from dolphin_desktop.backends entry points are included automatically.

Source code in src\dolphin_desktop\_backend.py
def list_backends() -> list[dict[str, Any]]:
    """Return info dicts for all registered backends.

    Each dict has keys: ``id``, ``platform``, ``class``, ``available``,
    ``description``, ``source`` (``"built-in"`` or entry-point value).

    Plugin backends from ``dolphin_desktop.backends`` entry points are
    included automatically.
    """
    _load_plugins()

    built_in_ids = {cls.id for cls in _BUILT_IN}

    result = []
    for bid, cls in sorted(_REGISTRY.items()):
        instance = cls()
        result.append(
            {
                "id": bid,
                "platform": cls.platform,
                "class": f"{cls.__module__}.{cls.__qualname__}",
                "available": instance.is_available(),
                "description": instance.description(),
                "source": "built-in" if bid in built_in_ids else "plugin",
            }
        )
    return result

register_backend

register_backend(cls: type[Backend]) -> type[Backend]

Register a :class:Backend subclass in the global registry.

This is the programmatic alternative to the dolphin_desktop.backends entry-point mechanism — useful for in-process backend registration in tests or extension packages that don't install an entry point.

Returns cls unchanged so it can be used as a decorator::

@dolphin_desktop._backend.register
class MyBackend(Backend):
    id = "my_backend"
    ...
Source code in src\dolphin_desktop\_backend.py
def register(cls: type[Backend]) -> type[Backend]:
    """Register a :class:`Backend` subclass in the global registry.

    This is the programmatic alternative to the ``dolphin_desktop.backends``
    entry-point mechanism — useful for in-process backend registration in tests
    or extension packages that don't install an entry point.

    Returns *cls* unchanged so it can be used as a decorator::

        @dolphin_desktop._backend.register
        class MyBackend(Backend):
            id = "my_backend"
            ...
    """
    _REGISTRY[cls.id] = cls
    return cls

resolve_backend

resolve_backend(backend_id: str) -> Backend

Return an instantiated :class:Backend for backend_id.

Parameters:

Name Type Description Default
backend_id str

One of the registered backend IDs ("uia", "win32", "image", or any plugin-registered ID), or "auto" to let dolphin pick the best available backend for the current platform.

required

Raises:

Type Description
ValueError

If backend_id is not found in the registry.

Source code in src\dolphin_desktop\_backend.py
def resolve(backend_id: str) -> Backend:
    """Return an instantiated :class:`Backend` for *backend_id*.

    Parameters
    ----------
    backend_id:
        One of the registered backend IDs (``"uia"``, ``"win32"``,
        ``"image"``, or any plugin-registered ID), or ``"auto"`` to let
        dolphin pick the best available backend for the current platform.

    Raises
    ------
    ValueError
        If *backend_id* is not found in the registry.
    """
    _load_plugins()

    if backend_id == "auto":
        return _auto_detect()

    if backend_id not in _REGISTRY:
        available = sorted(_REGISTRY)
        raise ValueError(
            f"Unknown backend {backend_id!r}.  "
            f"Available backends: {available}.  "
            "Third-party backends can be added via the "
            "'dolphin_desktop.backends' entry-point group."
        )

    return _REGISTRY[backend_id]()

selfheal_stats

selfheal_stats(
    n: int = 10, file: Path | None = None
) -> list[dict[str, Any]]

Return the last n fallback events from the telemetry log.

Args: n: Maximum number of recent records to return. file: Path to the JSONL telemetry file. Defaults to the value of DOLPHIN_SELFHEAL_FILE env var or .dolphin-selfheal.jsonl.

Returns: List of event dicts with keys ts, test, primary, fallback.

Source code in src\dolphin_desktop\_selfheal.py
def selfheal_stats(n: int = 10, file: Path | None = None) -> list[dict[str, Any]]:
    """Return the last *n* fallback events from the telemetry log.

    Args:
        n: Maximum number of recent records to return.
        file: Path to the JSONL telemetry file.  Defaults to the value of
            ``DOLPHIN_SELFHEAL_FILE`` env var or ``.dolphin-selfheal.jsonl``.

    Returns:
        List of event dicts with keys ``ts``, ``test``, ``primary``, ``fallback``.
    """
    path = file if file is not None else _STATS_FILE
    if not path.exists():
        return []
    records: list[dict[str, Any]] = []
    try:
        for line in path.read_text(encoding="utf-8").splitlines():
            line = line.strip()
            if line:
                records.append(json.loads(line))
    except (OSError, json.JSONDecodeError):
        pass
    return records[-n:]

setup_logging

setup_logging(level: str | None = None) -> None

Configure the dolphin root logger. Idempotent — safe to call repeatedly.

Source code in src\dolphin_desktop\_logging.py
def setup_logging(level: str | None = None) -> None:
    """Configure the ``dolphin`` root logger.  Idempotent — safe to call repeatedly."""
    logger = logging.getLogger("dolphin")
    if logger.handlers:
        return

    level_str = level or os.environ.get("DOLPHIN_LOG_LEVEL", "INFO")
    level_int = getattr(logging, level_str.upper(), logging.INFO)

    handler = logging.StreamHandler()
    handler.setFormatter(
        _RedactingFormatter(
            "%(asctime)s [dolphin] %(levelname)-5s %(message)s",
            datefmt="%H:%M:%S",
        )
    )
    handler.setLevel(level_int)
    logger.setLevel(level_int)
    logger.addHandler(handler)
    logger.propagate = False

telemetry_capture_exception

telemetry_capture_exception(exc: BaseException) -> None

Send a dolphin-internal exception to Sentry. No-op when telemetry is disabled.

Source code in src\dolphin_desktop\_telemetry.py
def capture_exception(exc: BaseException) -> None:
    """Send a dolphin-internal exception to Sentry.  No-op when telemetry is disabled."""
    if not _initialized:
        return
    try:
        import sentry_sdk  # type: ignore[import]

        sentry_sdk.capture_exception(exc)
    except Exception:
        pass

telemetry_enabled

telemetry_enabled() -> bool

Return True when Sentry telemetry is active.

Source code in src\dolphin_desktop\_telemetry.py
def is_enabled() -> bool:
    """Return ``True`` when Sentry telemetry is active."""
    return _initialized

telemetry_init

telemetry_init() -> None

Initialize Sentry telemetry. Idempotent; silent if disabled or not installed.

Source code in src\dolphin_desktop\_telemetry.py
def init() -> None:
    """Initialize Sentry telemetry.  Idempotent; silent if disabled or not installed."""
    global _initialized
    if _initialized:
        return
    if os.environ.get("DOLPHIN_TELEMETRY", "").lower() != "on":
        return
    if not _DOLPHIN_DSN:
        return
    try:
        import sentry_sdk  # type: ignore[import]

        sentry_sdk.init(
            dsn=_DOLPHIN_DSN,
            traces_sample_rate=0.0,
            before_send=_before_send,
        )
        _initialized = True
    except ImportError:
        pass

trace_generate_html

trace_generate_html(run_dir: Path) -> Path

Read trace.db in run_dir, write trace.html, and return its path.

Source code in src\dolphin_desktop\_trace.py
def generate_html(run_dir: Path) -> Path:
    """Read trace.db in *run_dir*, write trace.html, and return its path."""
    db = sqlite3.connect(str(run_dir / "trace.db"))
    db.row_factory = sqlite3.Row
    try:
        run_row = db.execute("SELECT * FROM runs LIMIT 1").fetchone()
        if run_row is None:
            raise FileNotFoundError(f"No trace data found in {run_dir}")
        run = dict(run_row)
        steps = [
            dict(r)
            for r in db.execute(
                "SELECT * FROM steps WHERE run_id=? ORDER BY seq", (run["id"],)
            ).fetchall()
        ]
    finally:
        db.close()

    html = _render_html(run, steps)
    out = run_dir / "trace.html"
    out.write_text(html, encoding="utf-8")
    return out

write_crash_dump

write_crash_dump(
    exc: BaseException | None = None,
    output_dir: Path | None = None,
    extra: dict[str, Any] | None = None,
) -> Path

Write crash-<timestamp>.zip with full diagnostics.

Args: exc: The exception that triggered the dump (None → captures current stack). output_dir: Where to write the ZIP (default: dolphin-crashes/). extra: Additional key/value pairs written verbatim to extra.txt.

Returns: Absolute path of the written ZIP file.

Source code in src\dolphin_desktop\_crash.py
def write_crash_dump(
    exc: BaseException | None = None,
    output_dir: Path | None = None,
    extra: dict[str, Any] | None = None,
) -> Path:
    """Write ``crash-<timestamp>.zip`` with full diagnostics.

    Args:
        exc: The exception that triggered the dump (``None`` → captures current stack).
        output_dir: Where to write the ZIP (default: ``dolphin-crashes/``).
        extra: Additional key/value pairs written verbatim to ``extra.txt``.

    Returns:
        Absolute path of the written ZIP file.
    """
    ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
    out = output_dir or _DEFAULT_DIR
    out.mkdir(parents=True, exist_ok=True)
    zip_path = out / f"crash-{ts}.zip"

    with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
        zf.writestr("stack.txt", _fmt_stack(exc))
        zf.writestr("uia_tree.txt", _fmt_uia_tree())
        zf.writestr("environment.txt", _fmt_env())
        if extra:
            body = "\n".join(f"{k}: {v}" for k, v in extra.items())
            zf.writestr("extra.txt", body)

    return zip_path.resolve()