Skip to content

Locator

Locator is the core of Dolphin's API. It represents a lazy reference to a UI element - the element is not searched until an action or query method is called.


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]

Actions

loc.click()
loc.double_click()
loc.right_click()
loc.type_text("hello world")
loc.set_text("hello world")     # direct value set, no keystrokes
loc.clear()
loc.press_key("{ENTER}")
loc.press_key("^a")             # Ctrl+A
loc.select_item("Option A")     # ComboBox / ListBox
loc.check()
loc.uncheck()
loc.focus()
loc.select_text()               # Ctrl+A on the element
loc.scroll_into_view()

Mouse actions

loc.hover()
loc.scroll("up", amount=3)
loc.scroll("down", amount=3)
loc.drag_to(target_locator, duration=0.5, button="left")

Queries

loc.text()          # str - element text / value
loc.value()         # str - value property
loc.is_visible()    # bool
loc.is_enabled()    # bool
loc.is_checked()    # bool
loc.exists()        # bool - True if element is found (no wait)
loc.count()         # int - number of matching elements
loc.bounding_box()  # dict with x, y, width, height
loc.get_attribute("AutomationId")

Waiting

loc.wait_for(state="exists", timeout=10)
loc.wait_for(state="visible", timeout=10)
loc.wait_for(state="enabled", timeout=10)
loc.wait_until_hidden(timeout=10)
loc.wait_until_enabled(timeout=10)

Collections

# All direct children matching the locator
items = loc.all()             # depth=None means direct children
items = loc.all(depth=3)      # descendants up to depth 3

# Nth item (0-based)
third = loc.nth(2)

# Count
n = loc.count()

Chaining

# Find a descendant inside this locator
inner = loc.locator(control_type="Button", title="OK")

# Change timeout (returns new locator, does not mutate)
slow_loc = loc.timeout(30)

Fallback selectors

loc = win.locator(
    auto_id="btnSubmit",
    fallback=[
        {"auto_id": "btnSave"},
        {"title": "Submit"},
    ],
    image_fallback="templates/submit.png",
)

Screenshot

img = loc.screenshot()              # PIL.Image of this element
img = loc.screenshot("elem.png")    # save to file