robotframework-desktop 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- DesktopLibrary/__init__.py +278 -0
- DesktopLibrary/context.py +272 -0
- DesktopLibrary/cursor.py +243 -0
- DesktopLibrary/engines/__init__.py +4 -0
- DesktopLibrary/engines/image.py +174 -0
- DesktopLibrary/engines/ui.py +192 -0
- DesktopLibrary/geometry.py +187 -0
- DesktopLibrary/keywords/__init__.py +23 -0
- DesktopLibrary/keywords/application.py +149 -0
- DesktopLibrary/keywords/base.py +90 -0
- DesktopLibrary/keywords/config.py +159 -0
- DesktopLibrary/keywords/cursor.py +201 -0
- DesktopLibrary/keywords/element.py +260 -0
- DesktopLibrary/keywords/find.py +136 -0
- DesktopLibrary/keywords/keyboard.py +171 -0
- DesktopLibrary/keywords/mouse.py +357 -0
- DesktopLibrary/keywords/ocr.py +166 -0
- DesktopLibrary/keywords/region.py +288 -0
- DesktopLibrary/keywords/wait.py +178 -0
- DesktopLibrary/locator.py +223 -0
- DesktopLibrary/overlay.py +180 -0
- robotframework_desktop-0.1.0.dist-info/METADATA +215 -0
- robotframework_desktop-0.1.0.dist-info/RECORD +25 -0
- robotframework_desktop-0.1.0.dist-info/WHEEL +5 -0
- robotframework_desktop-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
if (
|
|
4
|
+
sys.platform == 'win32'
|
|
5
|
+
): # make screen, mouse and UIA coordinates consistent
|
|
6
|
+
import ctypes
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
ctypes.windll.shcore.SetProcessDpiAwareness(2)
|
|
10
|
+
except Exception:
|
|
11
|
+
try:
|
|
12
|
+
ctypes.windll.user32.SetProcessDPIAware()
|
|
13
|
+
except Exception:
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
import pyautogui # noqa: E402
|
|
17
|
+
from robotlibcore import DynamicCore # noqa: E402
|
|
18
|
+
|
|
19
|
+
from .context import ElementNotFound, LibraryContext # noqa: E402
|
|
20
|
+
from .geometry import Match, Region # noqa: E402
|
|
21
|
+
from .keywords import ( # noqa: E402
|
|
22
|
+
ApplicationKeywords,
|
|
23
|
+
ConfigKeywords,
|
|
24
|
+
CursorKeywords,
|
|
25
|
+
ElementKeywords,
|
|
26
|
+
FindKeywords,
|
|
27
|
+
KeyboardKeywords,
|
|
28
|
+
MouseKeywords,
|
|
29
|
+
OcrKeywords,
|
|
30
|
+
RegionKeywords,
|
|
31
|
+
WaitKeywords,
|
|
32
|
+
)
|
|
33
|
+
from .locator import Locator, parse_locator # noqa: E402
|
|
34
|
+
|
|
35
|
+
__version__ = '0.1.0'
|
|
36
|
+
__all__ = [
|
|
37
|
+
'DesktopLibrary',
|
|
38
|
+
'ElementNotFound',
|
|
39
|
+
'Locator',
|
|
40
|
+
'Match',
|
|
41
|
+
'Region',
|
|
42
|
+
'parse_locator',
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
pyautogui.PAUSE = 0.05
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class DesktopLibrary(DynamicCore):
|
|
49
|
+
"""DesktopLibrary is a Robot Framework library for Windows desktop
|
|
50
|
+
automation. It combines image recognition (``pyautogui`` + OpenCV) with
|
|
51
|
+
UI Automation (``pywinauto``) for WPF, WinForms, UWP and Win32 applications.
|
|
52
|
+
|
|
53
|
+
Keywords are *hybrid*: the engine used is chosen by the format of the
|
|
54
|
+
``element`` argument.
|
|
55
|
+
|
|
56
|
+
= Element locators =
|
|
57
|
+
|
|
58
|
+
== Image file path ==
|
|
59
|
+
|
|
60
|
+
A string is treated as an image path unless it matches the UI locator
|
|
61
|
+
syntax. Relative paths are resolved against the folders given with the
|
|
62
|
+
``image_paths`` import argument or `Add Image Path`, in order, then the
|
|
63
|
+
current working directory. The extension can be omitted
|
|
64
|
+
(``.png``, ``.jpg``, ``.jpeg`` and ``.bmp`` are tried).
|
|
65
|
+
|
|
66
|
+
The default similarity is ``0.6`` and can be changed with
|
|
67
|
+
`Set Min Similarity`. A per-call similarity can be given with
|
|
68
|
+
``image.png=0.85``. Use the ``image:`` prefix to force the image engine.
|
|
69
|
+
|
|
70
|
+
| Click | buttons/save.png |
|
|
71
|
+
| Click | save.png=0.9 |
|
|
72
|
+
| Click | image:C:/refs/save.png |
|
|
73
|
+
|
|
74
|
+
== Dictionary ==
|
|
75
|
+
|
|
76
|
+
| =Key= | =Description= |
|
|
77
|
+
| path | Image path (string) or list of image paths. With a list, the element is *any* of the images (the best match wins). |
|
|
78
|
+
| similarity | Similarity for this locator only (0-1). |
|
|
79
|
+
| offset | Dictionary ``{"x": int, "y": int}`` added to the center of the element when acting. |
|
|
80
|
+
| region | Dictionary ``{"x", "y", "width", "height"}`` limiting where the element is searched. |
|
|
81
|
+
| name, automation_id, class_name, control_type, ... | UI Automation criteria (instead of ``path``). |
|
|
82
|
+
| backend | ``uia`` or ``win32`` for UI criteria. |
|
|
83
|
+
| locator | Any other locator string, so ``offset``/``region`` can be added to it. |
|
|
84
|
+
|
|
85
|
+
| ${ok}= | Create Dictionary | path=ok.png | similarity=0.85 |
|
|
86
|
+
| Click | ${ok} |
|
|
87
|
+
| Click | ${{ {"path": ["ok.png", "ok_dark.png"], "offset": {"x": 10, "y": 0}} }} |
|
|
88
|
+
| Click | ${{ {"path": "ok.png", "region": {"x": 0, "y": 0, "width": 800, "height": 600}} }} |
|
|
89
|
+
| Click | ${{ {"automation_id": "btnSave", "offset": {"x": 5, "y": 5}} }} |
|
|
90
|
+
|
|
91
|
+
== UI Automation locator ==
|
|
92
|
+
|
|
93
|
+
Strings in the form ``key:value`` use pywinauto. Conditions can be combined
|
|
94
|
+
with ``AND``. A ``uia:`` or ``win32:`` prefix selects the backend
|
|
95
|
+
(default is the ``backend`` import argument, ``uia``).
|
|
96
|
+
|
|
97
|
+
| =Key= | =Meaning= |
|
|
98
|
+
| name / title | Element name (window title, button text...). |
|
|
99
|
+
| name_re / title_re | Regular expression for the name. |
|
|
100
|
+
| automation_id / id / auto_id | AutomationId (WPF ``x:Name``, WinForms ``Name``). |
|
|
101
|
+
| class / class_name | Class name. |
|
|
102
|
+
| control_type / type | Control type: ``Button``, ``Edit``, ``ComboBox``... (uia only). |
|
|
103
|
+
| index / found_index | 0 based index when multiple elements match. |
|
|
104
|
+
| handle, process | Window handle or process id. |
|
|
105
|
+
|
|
106
|
+
| Attach To Window | name:Sample App |
|
|
107
|
+
| Click | automation_id:btnSave |
|
|
108
|
+
| Click | name:OK AND control_type:Button |
|
|
109
|
+
| Click | win32:class:Button AND name:Cancel |
|
|
110
|
+
|
|
111
|
+
Searches are made inside the window set with `Attach To Window`, or in the
|
|
112
|
+
whole desktop otherwise (slower).
|
|
113
|
+
|
|
114
|
+
== Region and match ==
|
|
115
|
+
|
|
116
|
+
Regions (``[x, y, width, height]`` or values returned by keywords such
|
|
117
|
+
as `Get Extended Region From Image`) and matches can be used as elements:
|
|
118
|
+
the action happens at their center.
|
|
119
|
+
|
|
120
|
+
== Looping over elements ==
|
|
121
|
+
|
|
122
|
+
`Find Element`, `Find Elements` and `Find Child Elements` return matches
|
|
123
|
+
that can be passed to any keyword. UI matches keep the UI Automation
|
|
124
|
+
element; image matches are screen positions (they become stale if the
|
|
125
|
+
screen changes).
|
|
126
|
+
|
|
127
|
+
| @{items}= | Find Elements | control_type:ListItem |
|
|
128
|
+
| FOR | ${item} | IN | @{items} |
|
|
129
|
+
| | ${text}= | Get Element Text | ${item} |
|
|
130
|
+
| | IF | $text == "Item 3" | Click | ${item} |
|
|
131
|
+
| END |
|
|
132
|
+
|
|
133
|
+
= Importing =
|
|
134
|
+
|
|
135
|
+
| Library | DesktopLibrary | image_paths=${CURDIR}/images;${CURDIR}/shared | similarity=0.7 | timeout=10 |
|
|
136
|
+
|
|
137
|
+
= Busy cursor handling =
|
|
138
|
+
|
|
139
|
+
Desktop applications often load data in background, so a fixed wait is
|
|
140
|
+
not reliable. DesktopLibrary watches the mouse cursor (Windows
|
|
141
|
+
``GetCursorInfo``) and, when it is *busy* (``wait`` or ``appstarting`` by
|
|
142
|
+
default), waits until it is not busy for a stability window
|
|
143
|
+
(``0.5`` seconds by default):
|
|
144
|
+
|
|
145
|
+
- before searching elements in action keywords (`Click`, `Input Text`, `Drag And Drop`,
|
|
146
|
+
`Get Element Text`, `Wait Until Screen Contain`, ...),
|
|
147
|
+
- before mouse and keyboard actions, also without element (`Press Special Key`,
|
|
148
|
+
`Type With Modifiers`, `Input Text` with ``${EMPTY}``, `Mouse Down`, wheel...),
|
|
149
|
+
- after action keywords: the cursor is watched for ``busy_cursor_after_delay``
|
|
150
|
+
(``0.2s`` by default) and, if the action made it busy, the keyword only
|
|
151
|
+
returns when the application is ready again.
|
|
152
|
+
|
|
153
|
+
Action keywords accept the named argument ``ignore_busy_error=True`` to log
|
|
154
|
+
a warning instead of failing when the busy cursor wait times out:
|
|
155
|
+
|
|
156
|
+
| Click | automation_id:btnLoad | ignore_busy_error=True |
|
|
157
|
+
|
|
158
|
+
Checks such as `Exists`, `Screen Should Not Contain` and `Get Match Score`
|
|
159
|
+
never wait. `Wait Until Screen Contain`/`Screen Should Contain` limit the
|
|
160
|
+
cursor wait to their own timeout. The cursor is read at its current
|
|
161
|
+
position; the mouse is not moved for the check.
|
|
162
|
+
|
|
163
|
+
If the cursor stays busy longer than ``busy_cursor_timeout`` (default
|
|
164
|
+
``30s``) the keyword fails by default, or logs a warning and continues when
|
|
165
|
+
``busy_cursor_fail=False``. Application specific cursors are reported as
|
|
166
|
+
``custom`` and are *not* busy unless added with `Set Busy Cursor Types`.
|
|
167
|
+
|
|
168
|
+
| Library | DesktopLibrary | busy_cursor_timeout=1 min | busy_cursor_fail=False |
|
|
169
|
+
| Set Busy Cursor Wait | False | | | # disable automatic waiting |
|
|
170
|
+
| Set Busy Cursor Types | wait,appstarting,custom |
|
|
171
|
+
| Wait Until Cursor Is Not Busy | 2 min |
|
|
172
|
+
| ${cursor}= | Get Current Cursor Type |
|
|
173
|
+
|
|
174
|
+
= Example =
|
|
175
|
+
|
|
176
|
+
| *** Settings ***
|
|
177
|
+
| Library DesktopLibrary image_paths=${CURDIR}/images
|
|
178
|
+
|
|
|
179
|
+
| *** Test Cases ***
|
|
180
|
+
| Save Document
|
|
181
|
+
| Open Application notepad.exe
|
|
182
|
+
| Attach To Window name_re:.*Notepad
|
|
183
|
+
| Input Text class:Edit Hello from Robot
|
|
184
|
+
| Type With Modifiers s CTRL
|
|
185
|
+
| Wait Until Screen Contain save_dialog.png 10
|
|
186
|
+
| Click ${{ {"path": ["save_btn.png", "save_btn_dark.png"], "similarity": 0.8} }}
|
|
187
|
+
| [Teardown] Close Application notepad.exe
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
ROBOT_LIBRARY_SCOPE = 'GLOBAL'
|
|
191
|
+
ROBOT_LIBRARY_VERSION = __version__
|
|
192
|
+
ROBOT_LIBRARY_DOC_FORMAT = 'ROBOT'
|
|
193
|
+
|
|
194
|
+
def __init__(
|
|
195
|
+
self,
|
|
196
|
+
image_paths=None,
|
|
197
|
+
similarity: float = 0.6,
|
|
198
|
+
timeout='5s',
|
|
199
|
+
backend: str = 'uia',
|
|
200
|
+
capture_folder: str | None = None,
|
|
201
|
+
scan_rate: float = 10.0,
|
|
202
|
+
screen_id: int = 0,
|
|
203
|
+
show_actions: bool = False,
|
|
204
|
+
busy_cursor_wait: bool = True,
|
|
205
|
+
busy_cursor_timeout='30s',
|
|
206
|
+
busy_cursor_stable='0.5s',
|
|
207
|
+
busy_cursor_types='wait,appstarting',
|
|
208
|
+
busy_cursor_fail: bool = True,
|
|
209
|
+
busy_cursor_after_delay='0.2s',
|
|
210
|
+
show_warnings: bool = True,
|
|
211
|
+
):
|
|
212
|
+
"""
|
|
213
|
+
| =Argument= | =Description= |
|
|
214
|
+
| image_paths | Folders for relative image paths: list or string separated by ``;``. |
|
|
215
|
+
| similarity | Default image similarity (0-1). Default ``0.6``. |
|
|
216
|
+
| timeout | Default wait timeout. Default ``5s``. |
|
|
217
|
+
| backend | Default UI backend: ``uia`` or ``win32``. |
|
|
218
|
+
| capture_folder | Folder for captures. Default ``${OUTPUT DIR}``. |
|
|
219
|
+
| scan_rate | Searches per second while waiting. Default ``10``. |
|
|
220
|
+
| screen_id | Monitor used as default search area: ``0`` primary, ``-1`` all monitors. Default ``0``. |
|
|
221
|
+
| show_actions | Highlight targets before acting. Default ``False``. |
|
|
222
|
+
| busy_cursor_wait | Automatically wait while the cursor is busy. Default ``True``. |
|
|
223
|
+
| busy_cursor_timeout | Maximum busy cursor wait. Default ``30s``. |
|
|
224
|
+
| busy_cursor_stable | Time the cursor must stay not busy. Default ``0.5s``. |
|
|
225
|
+
| busy_cursor_types | Cursor types considered busy. Default ``wait,appstarting``. |
|
|
226
|
+
| busy_cursor_fail | Fail (``True``) or warn and continue (``False``) on busy timeout. Default ``True``. |
|
|
227
|
+
| busy_cursor_after_delay | Time watched after an action for the cursor to become busy. Default ``0.2s``. |
|
|
228
|
+
| show_warnings | Emit library warnings (e.g. ignored busy cursor timeouts). When ``False`` they are logged as INFO only. Default ``True``. |
|
|
229
|
+
|
|
230
|
+
Examples:
|
|
231
|
+
| Library | DesktopLibrary |
|
|
232
|
+
| Library | DesktopLibrary | image_paths=${CURDIR}/images | similarity=0.8 |
|
|
233
|
+
| Library | DesktopLibrary | backend=win32 | timeout=10s |
|
|
234
|
+
| Library | DesktopLibrary | busy_cursor_timeout=2 min | busy_cursor_fail=False |
|
|
235
|
+
| Library | DesktopLibrary | busy_cursor_fail=False | show_warnings=False |
|
|
236
|
+
"""
|
|
237
|
+
|
|
238
|
+
def as_bool(value):
|
|
239
|
+
return (
|
|
240
|
+
value
|
|
241
|
+
if isinstance(value, bool)
|
|
242
|
+
else str(value).lower() in ('true', '1', 'yes', 'on')
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
show_actions = as_bool(show_actions)
|
|
246
|
+
self.ctx = LibraryContext(
|
|
247
|
+
image_paths=image_paths,
|
|
248
|
+
similarity=similarity,
|
|
249
|
+
timeout=timeout,
|
|
250
|
+
backend=backend,
|
|
251
|
+
capture_folder=capture_folder,
|
|
252
|
+
scan_rate=scan_rate,
|
|
253
|
+
screen_id=screen_id,
|
|
254
|
+
show_actions=show_actions,
|
|
255
|
+
busy_cursor_wait=as_bool(busy_cursor_wait),
|
|
256
|
+
busy_cursor_timeout=busy_cursor_timeout,
|
|
257
|
+
busy_cursor_stable=busy_cursor_stable,
|
|
258
|
+
busy_cursor_types=busy_cursor_types,
|
|
259
|
+
busy_cursor_fail=as_bool(busy_cursor_fail),
|
|
260
|
+
busy_cursor_after_delay=busy_cursor_after_delay,
|
|
261
|
+
show_warnings=as_bool(show_warnings),
|
|
262
|
+
)
|
|
263
|
+
components = [
|
|
264
|
+
cls(self.ctx)
|
|
265
|
+
for cls in (
|
|
266
|
+
MouseKeywords,
|
|
267
|
+
KeyboardKeywords,
|
|
268
|
+
WaitKeywords,
|
|
269
|
+
RegionKeywords,
|
|
270
|
+
OcrKeywords,
|
|
271
|
+
ApplicationKeywords,
|
|
272
|
+
ElementKeywords,
|
|
273
|
+
ConfigKeywords,
|
|
274
|
+
CursorKeywords,
|
|
275
|
+
FindKeywords,
|
|
276
|
+
)
|
|
277
|
+
]
|
|
278
|
+
super().__init__(components)
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, Callable
|
|
4
|
+
|
|
5
|
+
import mss
|
|
6
|
+
from robot.api import logger
|
|
7
|
+
from robot.utils import timestr_to_secs
|
|
8
|
+
|
|
9
|
+
from .cursor import CursorMonitor, normalize_cursor_types
|
|
10
|
+
from .engines import ImageEngine, ImagePathResolver, UIEngine
|
|
11
|
+
from .geometry import Match, Region
|
|
12
|
+
from .locator import Locator, parse_locator
|
|
13
|
+
from .overlay import Overlay
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ElementNotFound(AssertionError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LibraryContext:
|
|
21
|
+
"""Shared state and core lookup logic used by every keyword group."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
image_paths=None,
|
|
26
|
+
similarity=0.6,
|
|
27
|
+
timeout=5.0,
|
|
28
|
+
backend='uia',
|
|
29
|
+
capture_folder=None,
|
|
30
|
+
scan_rate=10.0,
|
|
31
|
+
screen_id=0,
|
|
32
|
+
show_actions=False,
|
|
33
|
+
busy_cursor_wait=True,
|
|
34
|
+
busy_cursor_timeout=30.0,
|
|
35
|
+
busy_cursor_stable=0.5,
|
|
36
|
+
busy_cursor_types='wait,appstarting',
|
|
37
|
+
busy_cursor_fail=True,
|
|
38
|
+
busy_cursor_after_delay=0.2,
|
|
39
|
+
show_warnings=True,
|
|
40
|
+
):
|
|
41
|
+
self.show_warnings = show_warnings
|
|
42
|
+
self.cursor = CursorMonitor(
|
|
43
|
+
enabled=busy_cursor_wait,
|
|
44
|
+
timeout=self.to_seconds(busy_cursor_timeout),
|
|
45
|
+
stable_duration=self.to_seconds(busy_cursor_stable),
|
|
46
|
+
busy_types=normalize_cursor_types(busy_cursor_types),
|
|
47
|
+
fail_on_timeout=busy_cursor_fail,
|
|
48
|
+
after_delay=self.to_seconds(busy_cursor_after_delay),
|
|
49
|
+
logger=self.log,
|
|
50
|
+
)
|
|
51
|
+
self.resolver = ImagePathResolver()
|
|
52
|
+
for p in self._split_paths(image_paths):
|
|
53
|
+
self.resolver.add(p)
|
|
54
|
+
self.image = ImageEngine(self.resolver)
|
|
55
|
+
self.ui = UIEngine(backend=str(backend).lower())
|
|
56
|
+
self.overlay = Overlay()
|
|
57
|
+
self.similarity = float(similarity)
|
|
58
|
+
self.timeout = self.to_seconds(timeout)
|
|
59
|
+
self.scan_rate = float(scan_rate)
|
|
60
|
+
self.screen_id = int(screen_id)
|
|
61
|
+
self.roi: Region | None = None
|
|
62
|
+
self.last_match: Match | None = None
|
|
63
|
+
self._capture_folder = capture_folder
|
|
64
|
+
self.capture_matched_image = False
|
|
65
|
+
self.show_actions = bool(show_actions)
|
|
66
|
+
self.slow_motion_delay = 0.5
|
|
67
|
+
self.move_mouse_delay = 0.0
|
|
68
|
+
self.ocr_enabled = True
|
|
69
|
+
self._capture_counter = 0
|
|
70
|
+
|
|
71
|
+
# ---- helpers ------------------------------------------------------------
|
|
72
|
+
def log(self, message: str, level: str = 'INFO'):
|
|
73
|
+
"""Logs ``message``; warnings are downgraded to INFO when
|
|
74
|
+
``show_warnings`` is disabled (kept in the log, hidden from console/report)."""
|
|
75
|
+
if level.upper() == 'WARN' and not self.show_warnings:
|
|
76
|
+
level = 'INFO'
|
|
77
|
+
message = f'[warning suppressed] {message}'
|
|
78
|
+
logger.write(message, level)
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def _split_paths(value) -> list[str]:
|
|
82
|
+
if not value:
|
|
83
|
+
return []
|
|
84
|
+
if isinstance(value, str):
|
|
85
|
+
return [p.strip() for p in value.split(';') if p.strip()]
|
|
86
|
+
return [str(p) for p in value]
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def to_seconds(value, default: float | None = None) -> float:
|
|
90
|
+
if value is None or (isinstance(value, str) and value.strip() == ''):
|
|
91
|
+
if default is None:
|
|
92
|
+
raise ValueError('Timeout value is required.')
|
|
93
|
+
return default
|
|
94
|
+
return float(timestr_to_secs(value))
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def capture_folder(self) -> Path:
|
|
98
|
+
folder = self._capture_folder
|
|
99
|
+
if not folder:
|
|
100
|
+
try:
|
|
101
|
+
from robot.libraries.BuiltIn import BuiltIn
|
|
102
|
+
|
|
103
|
+
folder = BuiltIn().get_variable_value('${OUTPUT DIR}')
|
|
104
|
+
except Exception:
|
|
105
|
+
folder = None
|
|
106
|
+
path = Path(folder or Path.cwd())
|
|
107
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
return path
|
|
109
|
+
|
|
110
|
+
@capture_folder.setter
|
|
111
|
+
def capture_folder(self, value):
|
|
112
|
+
self._capture_folder = value
|
|
113
|
+
|
|
114
|
+
def next_capture_path(
|
|
115
|
+
self, name: str = '', prefix: str = 'capture'
|
|
116
|
+
) -> Path:
|
|
117
|
+
if name:
|
|
118
|
+
p = Path(name)
|
|
119
|
+
if p.suffix.lower() not in ('.png', '.jpg', '.jpeg', '.bmp'):
|
|
120
|
+
p = p.with_name(p.name + '.png')
|
|
121
|
+
return p if p.is_absolute() else self.capture_folder / p
|
|
122
|
+
self._capture_counter += 1
|
|
123
|
+
stamp = time.strftime('%Y%m%d-%H%M%S')
|
|
124
|
+
return (
|
|
125
|
+
self.capture_folder
|
|
126
|
+
/ f'{prefix}-{stamp}-{self._capture_counter}.png'
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# ---- screens ------------------------------------------------------------
|
|
130
|
+
@staticmethod
|
|
131
|
+
def monitors() -> list[Region]:
|
|
132
|
+
"""Monitors with the primary one (containing 0,0) first."""
|
|
133
|
+
with mss.mss() as sct:
|
|
134
|
+
mons = sct.monitors[1:] or sct.monitors[:1]
|
|
135
|
+
regions = [
|
|
136
|
+
Region(m['left'], m['top'], m['width'], m['height'])
|
|
137
|
+
for m in mons
|
|
138
|
+
]
|
|
139
|
+
regions.sort(key=lambda r: (not r.contains_point(0, 0), r.x, r.y))
|
|
140
|
+
return regions
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def virtual_screen() -> Region:
|
|
144
|
+
with mss.mss() as sct:
|
|
145
|
+
m = sct.monitors[0]
|
|
146
|
+
return Region(m['left'], m['top'], m['width'], m['height'])
|
|
147
|
+
|
|
148
|
+
def screen(self) -> Region:
|
|
149
|
+
if self.screen_id == -1:
|
|
150
|
+
return self.virtual_screen()
|
|
151
|
+
mons = self.monitors()
|
|
152
|
+
if not 0 <= self.screen_id < len(mons):
|
|
153
|
+
raise ValueError(
|
|
154
|
+
f'Screen id {self.screen_id} out of range (0..{len(mons) - 1}).'
|
|
155
|
+
)
|
|
156
|
+
return mons[self.screen_id]
|
|
157
|
+
|
|
158
|
+
def search_region(self, locator: Locator | None = None) -> Region:
|
|
159
|
+
if locator is not None and locator.region is not None:
|
|
160
|
+
return locator.region
|
|
161
|
+
return self.roi or self.screen()
|
|
162
|
+
|
|
163
|
+
# ---- lookup ---------------------------------------------------------------
|
|
164
|
+
def find_now(self, locator: Locator) -> list[Match]:
|
|
165
|
+
if locator.kind == 'region':
|
|
166
|
+
fixed = locator.fixed
|
|
167
|
+
return [
|
|
168
|
+
Match(
|
|
169
|
+
fixed.x,
|
|
170
|
+
fixed.y,
|
|
171
|
+
fixed.width,
|
|
172
|
+
fixed.height,
|
|
173
|
+
score=getattr(fixed, 'score', 1.0),
|
|
174
|
+
source='region',
|
|
175
|
+
offset=tuple(locator.offset),
|
|
176
|
+
element=getattr(fixed, 'element', None),
|
|
177
|
+
)
|
|
178
|
+
]
|
|
179
|
+
if locator.kind == 'image':
|
|
180
|
+
similarity = locator.similarity or self.similarity
|
|
181
|
+
return self.image.find_all(
|
|
182
|
+
locator, self.search_region(locator), similarity
|
|
183
|
+
)
|
|
184
|
+
return self.ui.find_all(locator)
|
|
185
|
+
|
|
186
|
+
def _poll(self, condition: Callable[[], Any], timeout: float):
|
|
187
|
+
deadline = time.monotonic() + timeout
|
|
188
|
+
interval = 1.0 / self.scan_rate if self.scan_rate > 0 else 0.1
|
|
189
|
+
while True:
|
|
190
|
+
result = condition()
|
|
191
|
+
if result:
|
|
192
|
+
return result
|
|
193
|
+
if time.monotonic() >= deadline:
|
|
194
|
+
return result
|
|
195
|
+
time.sleep(interval)
|
|
196
|
+
|
|
197
|
+
def find(
|
|
198
|
+
self,
|
|
199
|
+
target: Any,
|
|
200
|
+
timeout: Any = None,
|
|
201
|
+
region: Region | None = None,
|
|
202
|
+
x_offset: int = 0,
|
|
203
|
+
y_offset: int = 0,
|
|
204
|
+
wait_cursor: bool = True,
|
|
205
|
+
) -> Match:
|
|
206
|
+
locator = parse_locator(target)
|
|
207
|
+
if region is not None:
|
|
208
|
+
locator = locator.with_region(region)
|
|
209
|
+
if x_offset or y_offset:
|
|
210
|
+
locator = locator.with_extra_offset(x_offset, y_offset)
|
|
211
|
+
secs = self.to_seconds(timeout, self.timeout)
|
|
212
|
+
if wait_cursor:
|
|
213
|
+
self.cursor.auto_wait()
|
|
214
|
+
matches = self._poll(lambda: self.find_now(locator), secs)
|
|
215
|
+
if not matches:
|
|
216
|
+
raise ElementNotFound(
|
|
217
|
+
f"Element '{locator.describe()}' not found in {secs:g} seconds."
|
|
218
|
+
)
|
|
219
|
+
match = max(matches, key=lambda m: m.score)
|
|
220
|
+
self.last_match = match
|
|
221
|
+
self._after_match(match)
|
|
222
|
+
return match
|
|
223
|
+
|
|
224
|
+
def find_all(
|
|
225
|
+
self, target: Any, timeout: Any = 0, wait_cursor: bool = False
|
|
226
|
+
) -> list[Match]:
|
|
227
|
+
locator = parse_locator(target)
|
|
228
|
+
secs = self.to_seconds(timeout, 0)
|
|
229
|
+
if wait_cursor:
|
|
230
|
+
self.cursor.auto_wait()
|
|
231
|
+
return self._poll(lambda: self.find_now(locator), secs) or []
|
|
232
|
+
|
|
233
|
+
def exists(self, target: Any, timeout: Any = None) -> bool:
|
|
234
|
+
try:
|
|
235
|
+
self.find(target, timeout, wait_cursor=False)
|
|
236
|
+
return True
|
|
237
|
+
except ElementNotFound:
|
|
238
|
+
return False
|
|
239
|
+
|
|
240
|
+
def wait_vanish(self, target: Any, timeout: Any = None) -> bool:
|
|
241
|
+
locator = parse_locator(target)
|
|
242
|
+
secs = self.to_seconds(timeout, self.timeout)
|
|
243
|
+
self.cursor.auto_wait(secs)
|
|
244
|
+
return bool(self._poll(lambda: not self.find_now(locator), secs))
|
|
245
|
+
|
|
246
|
+
def resolve_or_last(self, target: Any, timeout: Any = None) -> Match:
|
|
247
|
+
if target is None or (
|
|
248
|
+
isinstance(target, str) and target.strip() == ''
|
|
249
|
+
):
|
|
250
|
+
if self.last_match is None:
|
|
251
|
+
raise ElementNotFound('No previous match available.')
|
|
252
|
+
return self.last_match
|
|
253
|
+
return self.find(target, timeout)
|
|
254
|
+
|
|
255
|
+
# ---- visual feedback ----------------------------------------------------
|
|
256
|
+
def _after_match(self, match: Match):
|
|
257
|
+
if self.capture_matched_image:
|
|
258
|
+
try:
|
|
259
|
+
path = self.next_capture_path(prefix='match')
|
|
260
|
+
self.image.grab(match).save(path)
|
|
261
|
+
logger.info(
|
|
262
|
+
f'<img src="{path.as_posix()}" width="{min(match.width, 400)}">',
|
|
263
|
+
html=True,
|
|
264
|
+
)
|
|
265
|
+
except Exception as err: # pragma: no cover - diagnostics only
|
|
266
|
+
logger.debug(f'Could not capture matched image: {err}')
|
|
267
|
+
logger.info(f'Found {match}')
|
|
268
|
+
|
|
269
|
+
def show_action(self, region: Region):
|
|
270
|
+
if self.show_actions:
|
|
271
|
+
self.overlay.highlight(region, self.slow_motion_delay)
|
|
272
|
+
time.sleep(self.slow_motion_delay)
|