window-snap 0.1.2__tar.gz

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.
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.3
2
+ Name: window-snap
3
+ Version: 0.1.2
4
+ Summary: Snap windows to their configured positions
5
+ License: MIT
6
+ Author: Maker By Night
7
+ Author-email: 2565361+mshafer1@users.noreply.github.com
8
+ Requires-Python: >=3.11,<4
9
+ Classifier: Operating System :: Microsoft :: Windows
10
+ Requires-Dist: click (>=8.4.1,<9.0.0)
11
+ Requires-Dist: python-decouple (>=3.8,<4.0)
12
+ Requires-Dist: pywin32 (>=312,<313)
13
+ Requires-Dist: pyyaml (>=6.0.3,<7.0.0)
14
+ Requires-Dist: screeninfo (>=0.8.1,<0.9.0)
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "window-snap"
3
+ version = "0.1.2"
4
+ description = "Snap windows to their configured positions"
5
+ authors = [
6
+ {name = "Maker By Night",email = "2565361+mshafer1@users.noreply.github.com"}
7
+ ]
8
+ license = "MIT"
9
+ requires-python = ">=3.11,<4"
10
+ dynamic = ["dependencies"]
11
+ classifiers = [
12
+ "Operating System :: Microsoft :: Windows",
13
+ ]
14
+
15
+ [tool.poetry.dependencies]
16
+ pyyaml = "^6.0.3"
17
+ click = "^8.4.1"
18
+ python-decouple = "^3.8"
19
+ screeninfo = "^0.8.1"
20
+ pywin32 = "^312"
21
+
22
+ [tool.poetry.scripts]
23
+ window-snap = "window_snap.__main__:main"
24
+
25
+ [build-system]
26
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
27
+ build-backend = "poetry.core.masonry.api"
28
+
29
+ [dependency-groups]
30
+ dev = [
31
+ "ni-python-styleguide (>=0.5.0,<0.6.0)",
32
+ "flake8-logging (>=1.8.0,<2.0.0)"
33
+ ]
@@ -0,0 +1,383 @@
1
+ """Module to snap windows to specified positions/sizes based on a YAML configuration."""
2
+
3
+ import functools
4
+ import logging
5
+ import pathlib
6
+ import typing
7
+
8
+ import screeninfo
9
+ import win32con
10
+ import win32gui
11
+ import yaml
12
+
13
+ import window_snap
14
+ import window_snap._win32_helpers
15
+
16
+ _logger = logging.getLogger(__name__)
17
+ _logger.addHandler(logging.NullHandler()) # make sure there is a default handler available
18
+
19
+
20
+ def load_config(config_path: str | pathlib.Path) -> dict:
21
+ """Load YAML configuration from a file.
22
+
23
+ Args:
24
+ config_path: Path to the YAML config file.
25
+
26
+ Returns:
27
+ Parsed configuration as a dict. Returns an empty dict if the file
28
+ does not exist.
29
+
30
+ Raises:
31
+ yaml.YAMLError: If the YAML cannot be parsed.
32
+ """
33
+ try:
34
+ with open(config_path, "r") as f:
35
+ config = yaml.load(f, Loader=yaml.CSafeLoader)
36
+ return config
37
+ except FileNotFoundError:
38
+ _logger.warning("Config file not found: %s", config_path)
39
+ return {}
40
+ except yaml.YAMLError as e:
41
+ _logger.exception("Error parsing config file %s: %s", config_path, e)
42
+ raise
43
+
44
+
45
+ @functools.lru_cache(maxsize=1)
46
+ def _get_screen_info() -> typing.List[screeninfo.screeninfo.Monitor]:
47
+ return list(sorted(screeninfo.screeninfo.get_monitors(), key=lambda m: (m.x, m.y)))
48
+
49
+
50
+ def _dataclass_representer(dumper, data):
51
+ # This automatically converts the NamedTuple to a plain dict behind the scenes
52
+ return dumper.represent_dict({k: v for k, v in data._asdict().items() if v is not None})
53
+
54
+
55
+ class WindowSnapDestination(typing.NamedTuple):
56
+ """Destination specification for snapping a window.
57
+
58
+ Attributes:
59
+ monitor: Optional index of the monitor to move the window to.
60
+ left: Left position or fraction of the work area width.
61
+ top: Top position or fraction of the work area height.
62
+ width: Width in pixels or fraction of the work area width.
63
+ height: Height in pixels or fraction of the work area height.
64
+ maximized: If True, maximize the window on the target monitor/work area.
65
+ find_by_exe: If True, treat the name as an executable and find windows by exe.
66
+ """
67
+
68
+ monitor: typing.Optional[int] = None
69
+ left: typing.Optional[float] = None
70
+ top: typing.Optional[float] = None
71
+ width: typing.Optional[float] = None
72
+ height: typing.Optional[float] = None
73
+ maximized: typing.Optional[bool] = None
74
+ find_by_exe: typing.Optional[bool] = None
75
+
76
+
77
+ yaml.SafeDumper.add_multi_representer(WindowSnapDestination, _dataclass_representer)
78
+
79
+
80
+ def get_current_windows() -> typing.Dict[str, WindowSnapDestination]:
81
+ """Enumerate current top-level windows and return their positions.
82
+
83
+ Returns:
84
+ A dict mapping window title to a `WindowSnapDestination` describing
85
+ the window's current position/size.
86
+ """
87
+ current_windows = {}
88
+ more_than_one_screen = len(_get_screen_info()) > 1
89
+ for w in window_snap._win32_helpers.enum_windows():
90
+ title = w.get("title")
91
+ rect = w.get("rect")
92
+ if not title:
93
+ continue
94
+ pos_args = {}
95
+ hwnd = w.get("hwnd")
96
+ # detect maximized/minimized state via window placement
97
+ is_maximized = False
98
+ if hwnd:
99
+ try:
100
+ placement = win32gui.GetWindowPlacement(hwnd)
101
+ show_cmd = placement[1]
102
+ if show_cmd == win32con.SW_SHOWMINIMIZED:
103
+ continue
104
+ is_maximized = show_cmd == win32con.SW_SHOWMAXIMIZED
105
+ except Exception:
106
+ pass
107
+ # rect is (left, top, width, height)
108
+ if rect:
109
+ left, top, width, height = rect
110
+ if is_maximized or width == 0 or height == 0:
111
+ pos_args["maximized"] = True
112
+ else:
113
+ pos_args.update({"left": left, "top": top, "width": width, "height": height})
114
+ if more_than_one_screen:
115
+ monitor_info = _find_monitor_by_rect(left, top, width, height)
116
+ if monitor_info is not None:
117
+ monitor_index, _ = monitor_info
118
+ pos_args["monitor"] = monitor_index + 1 # 1-indexed
119
+ current_windows[title] = WindowSnapDestination(**pos_args)
120
+ return current_windows
121
+
122
+
123
+ def find_exe_names():
124
+ """Return a mapping of window titles to their executable names.
125
+
126
+ Attempts to build a map of window title -> exe (lowercased) using the
127
+ PID->exe mapping from `win32_helpers`.
128
+
129
+ Returns:
130
+ dict: mapping of window title to executable filename (lowercased).
131
+ """
132
+ title_to_exe_map = {}
133
+ try:
134
+ pid_map = window_snap._win32_helpers.get_pid_to_exe_map()
135
+ for w in window_snap._win32_helpers.enum_windows():
136
+ title = w.get("title")
137
+ pid = w.get("pid")
138
+ if title and pid:
139
+ exe = pid_map.get(pid)
140
+ if exe:
141
+ title_to_exe_map[title] = exe.lower()
142
+ except Exception as e:
143
+ _logger.debug("failed get exe names via win32 helpers: %s", e)
144
+ return title_to_exe_map
145
+
146
+
147
+ def _find_monitor_by_rect(left: int, top: int, width: int, height: int):
148
+ win_center_x = left + (width // 2)
149
+ win_center_y = top + (height // 2)
150
+
151
+ monitors = _get_screen_info()
152
+ for index, monitor in enumerate(monitors):
153
+ inside_x = monitor.x <= win_center_x < (monitor.x + monitor.width)
154
+ inside_y = monitor.y <= win_center_y < (monitor.y + monitor.height)
155
+ if inside_x and inside_y:
156
+ return index, monitor
157
+
158
+ _logger.warning("Could not determine monitor for rect %s,%s %sx%s", left, top, width, height)
159
+ return None
160
+
161
+
162
+ def _scale_to_dimension(value: typing.Union[float, int, None], dimension: int, base: int):
163
+ if value is None:
164
+ return None
165
+ if (
166
+ 0 < value <= 1
167
+ ): # it's a float. 0 means "at the point", 0<x<1 is percentage. 1 is full. Anything other then that is a pixel value.
168
+ return int(value * dimension) + base
169
+ else: # absolute value integer
170
+ return int(value) + base
171
+
172
+
173
+ def snap_window(window_title: str, destination: WindowSnapDestination):
174
+ """Snap a single window to the provided destination.
175
+
176
+ Args:
177
+ window_title: The window title or executable name (if
178
+ `destination.find_by_exe` is True).
179
+ destination: A `WindowSnapDestination` describing where/how to place
180
+ the window.
181
+ """
182
+ _logger.info("Snapping window '%s' to destination: %s", window_title, destination)
183
+
184
+ try:
185
+ hwnd = None
186
+ if destination.find_by_exe:
187
+ # window_title here is expected to be an exe name
188
+ hwnds = window_snap._win32_helpers.find_hwnds_by_exe(window_title)
189
+ if not hwnds:
190
+ _logger.warning("No window found for exe '%s', skipping", window_title)
191
+ return
192
+ hwnd = hwnds[0]
193
+ else:
194
+ hwnds = window_snap._win32_helpers.find_hwnds_by_title(window_title)
195
+ if not hwnds:
196
+ _logger.warning("Window '%s' not found, skipping", window_title)
197
+ return
198
+ hwnd = hwnds[0]
199
+ except IndexError:
200
+ _logger.info("Window '%s' not found, skipping", window_title)
201
+ return
202
+ # ensure window is not minimized or maximized before moving/resizing
203
+ try:
204
+ win32gui.ShowWindow(hwnd, win32con.SW_RESTORE)
205
+ except Exception:
206
+ pass
207
+
208
+ # Perform the window snapping logic here
209
+ _logger.info(
210
+ "Window '%s' found (hwnd=%s), snapping to destination: %s", window_title, hwnd, destination
211
+ )
212
+ screens = _get_screen_info()
213
+
214
+ if destination.monitor is not None and destination.monitor >= len(screens):
215
+ _logger.warning(
216
+ "Monitor index %d is out of range, skipping monitor assignment", destination.monitor
217
+ )
218
+ destination = WindowSnapDestination(
219
+ monitor=None,
220
+ left=destination.left,
221
+ top=destination.top,
222
+ width=destination.width,
223
+ height=destination.height,
224
+ maximized=destination.maximized,
225
+ find_by_exe=destination.find_by_exe,
226
+ )
227
+
228
+ if destination.monitor is not None:
229
+ # move to desired monitor
230
+ if destination.monitor >= len(screens):
231
+ _logger.warning(
232
+ "Monitor index %d is out of range, skipping monitor assignment", destination.monitor
233
+ )
234
+ return
235
+
236
+ screen = screens[destination.monitor]
237
+ work_left, work_top, work_width, work_height = (
238
+ window_snap._win32_helpers.get_monitor_work_area_at_point(screen.x + 1, screen.y + 1)
239
+ )
240
+
241
+ if destination.maximized:
242
+ try:
243
+ win32gui.SetWindowPos(
244
+ hwnd,
245
+ None,
246
+ work_left,
247
+ work_top,
248
+ 0,
249
+ 0,
250
+ win32con.SWP_NOSIZE | win32con.SWP_NOZORDER,
251
+ )
252
+ win32gui.ShowWindow(hwnd, win32con.SW_MAXIMIZE)
253
+ except Exception:
254
+ pass
255
+ else:
256
+ left, top, width, height = (
257
+ _scale_to_dimension(v, dimension, pos)
258
+ for v, dimension, pos in [
259
+ (destination.left, work_width, work_left),
260
+ (destination.top, work_height, work_top),
261
+ (destination.width, work_width, 0),
262
+ (destination.height, work_height, 0),
263
+ ]
264
+ )
265
+
266
+ _logger.debug("Handling %s as %s", destination, (left, top, width, height))
267
+ if left is not None and top is not None and width is not None and height is not None:
268
+ window_snap._win32_helpers.set_window_pos(
269
+ hwnd, left, top, width, height, activate=False
270
+ )
271
+ else:
272
+ # partial updates
273
+ try:
274
+ cur_left, cur_top, cur_w, cur_h = window_snap._win32_helpers.get_window_rect(
275
+ hwnd
276
+ )
277
+ nl = left if left is not None else cur_left
278
+ nt = top if top is not None else cur_top
279
+ nw = width if width is not None else cur_w
280
+ nh = height if height is not None else cur_h
281
+ window_snap._win32_helpers.set_window_pos(hwnd, nl, nt, nw, nh, activate=False)
282
+ except Exception:
283
+ pass
284
+ else:
285
+ # no monitor specified, just apply position/size changes
286
+ if destination.maximized:
287
+ try:
288
+ win32gui.ShowWindow(hwnd, win32con.SW_MAXIMIZE)
289
+ except Exception:
290
+ pass
291
+ else:
292
+ try:
293
+ cur_left, cur_top, cur_w, cur_h = window_snap._win32_helpers.get_window_rect(hwnd)
294
+ monitor_info = _find_monitor_by_rect(cur_left, cur_top, cur_w, cur_h)
295
+ if monitor_info is None:
296
+ _logger.debug(
297
+ "Could not determine monitor for hwnd %s, using primary monitor", hwnd
298
+ )
299
+ monitor = screens[0]
300
+ work_left, work_top, work_width, work_height = (
301
+ window_snap._win32_helpers.get_monitor_work_area_at_point(
302
+ monitor.x + 1, monitor.y + 1
303
+ )
304
+ )
305
+ else:
306
+ _, monitor = monitor_info
307
+ work_left, work_top, work_width, work_height = (
308
+ window_snap._win32_helpers.get_monitor_work_area_at_point(
309
+ cur_left + 1, cur_top + 1
310
+ )
311
+ )
312
+
313
+ left, top, width, height = (
314
+ _scale_to_dimension(v, dimension, pos)
315
+ for v, dimension, pos in [
316
+ (destination.left, work_width, work_left),
317
+ (destination.top, work_height, work_top),
318
+ (destination.width, work_width, 0),
319
+ (destination.height, work_height, 0),
320
+ ]
321
+ )
322
+
323
+ _logger.debug("Handling %s as %s", destination, (left, top, width, height))
324
+ if (
325
+ left is not None
326
+ and top is not None
327
+ and width is not None
328
+ and height is not None
329
+ ):
330
+ window_snap._win32_helpers.set_window_pos(
331
+ hwnd, left, top, width, height, activate=False
332
+ )
333
+ else:
334
+ cur_left, cur_top, cur_w, cur_h = window_snap._win32_helpers.get_window_rect(
335
+ hwnd
336
+ )
337
+ nl = left if left is not None else cur_left
338
+ nt = top if top is not None else cur_top
339
+ nw = width if width is not None else cur_w
340
+ nh = height if height is not None else cur_h
341
+ window_snap._win32_helpers.set_window_pos(hwnd, nl, nt, nw, nh, activate=False)
342
+ except Exception as e:
343
+ _logger.debug("failed to reposition hwnd %s: %s", hwnd, e)
344
+
345
+
346
+ def snap_windows(config):
347
+ """Apply snapping rules from configuration to all named windows.
348
+
349
+ Args:
350
+ config: Configuration dict which may contain a `windows` mapping.
351
+ """
352
+ windows = (config or {}).get("windows")
353
+ if not windows:
354
+ _logger.debug("No 'windows' configuration found; nothing to snap.")
355
+ return
356
+ for window_name, snap_config in windows.items():
357
+ _logger.debug("Processing window '%s' with config: %s", window_name, snap_config)
358
+ try:
359
+ monitor_value = snap_config.get("monitor") if isinstance(snap_config, dict) else None
360
+ if monitor_value is not None:
361
+ if not isinstance(monitor_value, int) or monitor_value < 1:
362
+ _logger.warning(
363
+ "Invalid monitor value %r for window '%s': must be an integer >= 1, skipping monitor assignment.",
364
+ monitor_value,
365
+ window_name,
366
+ )
367
+ snap_config = {k: v for k, v in snap_config.items() if k != "monitor"}
368
+ destination = window_snap.WindowSnapDestination(
369
+ **{
370
+ k: (v if k != "monitor" else v - 1) # convert monitor to 0-indexed internally
371
+ for k, v in snap_config.items()
372
+ if k in window_snap.WindowSnapDestination._fields
373
+ }
374
+ )
375
+ except TypeError as e:
376
+ _logger.exception(
377
+ "Invalid configuration for window '%s': %s. Error: %s", window_name, snap_config, e
378
+ )
379
+ continue
380
+ try:
381
+ window_snap.snap_window(window_name, destination)
382
+ except Exception as e:
383
+ _logger.exception("Error occurred while snapping window '%s': %s", window_name, e)
@@ -0,0 +1,71 @@
1
+ """Main entry point for the window_snap command-line tool."""
2
+
3
+ import logging
4
+
5
+ import click
6
+ import yaml
7
+
8
+ import window_snap
9
+ import window_snap._conf
10
+
11
+ _logger = logging.getLogger(__name__)
12
+
13
+
14
+ @click.command()
15
+ @click.version_option()
16
+ @click.option(
17
+ "verbosity",
18
+ "-v",
19
+ "--verbose",
20
+ count=True,
21
+ help="Increase verbosity (can be used multiple times)",
22
+ )
23
+ @click.option(
24
+ "--store-current",
25
+ is_flag=True,
26
+ help="Store the current position and size of all windows in the config file instead of snapping",
27
+ )
28
+ def main(verbosity: int, store_current: bool):
29
+ """Snap windows to desired locations.
30
+
31
+ Load window positions/sizes from a config file and snap windows accordingly,
32
+ or store current positions/sizes if --store-current is used.
33
+ """
34
+ my_handler = logging.StreamHandler()
35
+ logging_level = logging.WARNING - (10 * verbosity) if verbosity > 0 else logging.WARNING
36
+ my_handler.setFormatter(
37
+ logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
38
+ )
39
+ root_logger = logging.getLogger(None)
40
+ root_logger.setLevel(logging_level)
41
+ root_logger.addHandler(my_handler)
42
+ _logger.debug("Debug logging enabled") # this will only show if verbosity is set to 2 or higher
43
+
44
+ config_path = window_snap._conf.CONFIG_DIR / "config.yaml"
45
+
46
+ if store_current:
47
+ _logger.info("Storing current window positions and sizes to %s", config_path)
48
+ current_windows = window_snap.get_current_windows()
49
+ config_path.parent.mkdir(parents=True, exist_ok=True)
50
+ exe_names = window_snap.find_exe_names()
51
+ with open(config_path, "w") as f:
52
+ yaml.dump(
53
+ {
54
+ "_config_version": window_snap._conf.CONFIG_VERSION,
55
+ "windows": current_windows,
56
+ "available_exe_names": exe_names,
57
+ },
58
+ f,
59
+ Dumper=yaml.SafeDumper,
60
+ )
61
+ _logger.info("Current window positions and sizes stored successfully")
62
+ return
63
+ else:
64
+ _logger.info("Loading configuration from %s", config_path)
65
+ config = window_snap.load_config(config_path)
66
+
67
+ window_snap.snap_windows(config)
68
+
69
+
70
+ if __name__ == "__main__":
71
+ main()
@@ -0,0 +1,13 @@
1
+ import pathlib as _pathlib
2
+
3
+ import decouple as _decouple
4
+
5
+ _config = _decouple.AutoConfig(search_path=_pathlib.Path.cwd())
6
+
7
+ CONFIG_DIR: _pathlib.Path = _config(
8
+ "WINDOW_SNAP_CONFIG_DIR",
9
+ default=_pathlib.Path.home() / ".config" / "window-snap",
10
+ cast=_pathlib.Path,
11
+ )
12
+
13
+ CONFIG_VERSION = "0.1.0"
@@ -0,0 +1,275 @@
1
+ import ctypes
2
+ import ctypes.wintypes
3
+ import difflib
4
+ import os
5
+ import time
6
+ import typing
7
+
8
+ import win32api
9
+ import win32con
10
+ import win32gui
11
+ import win32process
12
+
13
+ # Toolhelp constants
14
+ TH32CS_SNAPPROCESS = 0x00000002
15
+
16
+
17
+ if ctypes.sizeof(ctypes.c_void_p) == 8:
18
+ ULONG_PTR = ctypes.c_uint64
19
+ else:
20
+ ULONG_PTR = ctypes.c_uint32
21
+
22
+
23
+ class PROCESSENTRY32W(ctypes.Structure):
24
+ """Structure for process entry used with CreateToolhelp32Snapshot."""
25
+
26
+ _fields_ = [
27
+ ("dwSize", ctypes.wintypes.DWORD),
28
+ ("cntUsage", ctypes.wintypes.DWORD),
29
+ ("th32ProcessID", ctypes.wintypes.DWORD),
30
+ ("th32DefaultHeapID", ULONG_PTR),
31
+ ("th32ModuleID", ctypes.wintypes.DWORD),
32
+ ("cntThreads", ctypes.wintypes.DWORD),
33
+ ("th32ParentProcessID", ctypes.wintypes.DWORD),
34
+ ("pcPriClassBase", ctypes.wintypes.LONG),
35
+ ("dwFlags", ctypes.wintypes.DWORD),
36
+ ("szExeFile", ctypes.c_wchar * 260),
37
+ ]
38
+
39
+
40
+ def enum_windows() -> typing.List[typing.Dict]:
41
+ """Enumerate visible top-level windows.
42
+
43
+ Returns:
44
+ List[Dict]: A list of window dictionaries containing hwnd, title, pid, and rect.
45
+ """
46
+ results = []
47
+
48
+ def _cb(hwnd, _):
49
+ if not win32gui.IsWindowVisible(hwnd):
50
+ return True
51
+ try:
52
+ title = win32gui.GetWindowText(hwnd).strip()
53
+ except Exception:
54
+ title = ""
55
+ if not title:
56
+ return True
57
+ try:
58
+ _, pid = win32process.GetWindowThreadProcessId(hwnd)
59
+ rect = win32gui.GetWindowRect(hwnd)
60
+ x1, y1, x2, y2 = rect
61
+ width = x2 - x1
62
+ height = y2 - y1
63
+ if width <= 10 or height <= 10:
64
+ return True
65
+ results.append(
66
+ {"hwnd": hwnd, "title": title, "pid": pid, "rect": (x1, y1, width, height)}
67
+ )
68
+ except Exception:
69
+ pass
70
+ return True
71
+
72
+ win32gui.EnumWindows(_cb, None)
73
+ return results
74
+
75
+
76
+ def get_window_rect(hwnd: int) -> typing.Tuple[int, int, int, int]:
77
+ """Return the window rectangle in left/top/width/height format.
78
+
79
+ Args:
80
+ hwnd (int): Handle to the target window.
81
+
82
+ Returns:
83
+ Tuple[int, int, int, int]: A tuple of (left, top, width, height).
84
+ """
85
+ x1, y1, x2, y2 = win32gui.GetWindowRect(hwnd)
86
+ return x1, y1, x2 - x1, y2 - y1
87
+
88
+
89
+ def get_monitor_work_area_at_point(x: int, y: int) -> typing.Tuple[int, int, int, int]:
90
+ """Return the work area rectangle for the monitor containing a point.
91
+
92
+ Args:
93
+ x (int): X coordinate of the point.
94
+ y (int): Y coordinate of the point.
95
+
96
+ Returns:
97
+ Tuple[int, int, int, int]: A tuple of (left, top, width, height) for the monitor work area.
98
+ """
99
+ hmonitor = win32api.MonitorFromPoint((x, y), win32con.MONITOR_DEFAULTTONEAREST)
100
+ info = win32api.GetMonitorInfo(hmonitor)
101
+ left, top, right, bottom = info["Work"]
102
+ return left, top, right - left, bottom - top
103
+
104
+
105
+ def set_window_pos(
106
+ hwnd: int, left: int, top: int, width: int, height: int, activate: bool = False
107
+ ) -> None:
108
+ """Position and resize a window without changing its z-order.
109
+
110
+ Args:
111
+ hwnd (int): Handle to the target window.
112
+ left (int): New left position.
113
+ top (int): New top position.
114
+ width (int): New window width.
115
+ height (int): New window height.
116
+ activate (bool): If True, activate the window after moving it.
117
+ """
118
+ flags = win32con.SWP_NOZORDER | win32con.SWP_NOOWNERZORDER
119
+ if not activate:
120
+ flags |= win32con.SWP_NOACTIVATE
121
+ win32gui.SetWindowPos(hwnd, None, left, top, width, height, flags)
122
+
123
+
124
+ # Simple TTL cache for pid->exe mapping
125
+ _pid_exe_cache: typing.Dict[int, typing.Tuple[str, float]] = {}
126
+ _cache_ttl_seconds = 2.0
127
+
128
+
129
+ def _build_pid_to_exe_map() -> typing.Dict[int, str]:
130
+ kernel32 = ctypes.windll.kernel32
131
+
132
+ # Set proper argtypes/restype so HANDLE values are not truncated on 64-bit Python
133
+ kernel32.CreateToolhelp32Snapshot.argtypes = [ctypes.wintypes.DWORD, ctypes.wintypes.DWORD]
134
+ kernel32.CreateToolhelp32Snapshot.restype = ctypes.wintypes.HANDLE
135
+ kernel32.Process32FirstW.argtypes = [ctypes.wintypes.HANDLE, ctypes.POINTER(PROCESSENTRY32W)]
136
+ kernel32.Process32FirstW.restype = ctypes.wintypes.BOOL
137
+ kernel32.Process32NextW.argtypes = [ctypes.wintypes.HANDLE, ctypes.POINTER(PROCESSENTRY32W)]
138
+ kernel32.Process32NextW.restype = ctypes.wintypes.BOOL
139
+ kernel32.CloseHandle.argtypes = [ctypes.wintypes.HANDLE]
140
+ kernel32.CloseHandle.restype = ctypes.wintypes.BOOL
141
+
142
+ invalid_handle_value = ctypes.c_void_p(-1).value
143
+ snapshot = kernel32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
144
+ if snapshot == invalid_handle_value or snapshot is None:
145
+ raise RuntimeError("CreateToolhelp32Snapshot failed")
146
+
147
+ entry = PROCESSENTRY32W()
148
+ entry.dwSize = ctypes.sizeof(PROCESSENTRY32W)
149
+ pid_to_exe: typing.Dict[int, str] = {}
150
+
151
+ try:
152
+ success = kernel32.Process32FirstW(snapshot, ctypes.byref(entry))
153
+ while success:
154
+ pid = entry.th32ProcessID
155
+ exe = entry.szExeFile
156
+ pid_to_exe[pid] = exe
157
+ success = kernel32.Process32NextW(snapshot, ctypes.byref(entry))
158
+ finally:
159
+ kernel32.CloseHandle(snapshot)
160
+
161
+ return pid_to_exe
162
+
163
+
164
+ def get_pid_to_exe_map(force_refresh: bool = False) -> typing.Dict[int, str]:
165
+ """Return a cached mapping of process IDs to executable names.
166
+
167
+ Args:
168
+ force_refresh (bool): If True, rebuild the process map even if the cache is fresh.
169
+
170
+ Returns:
171
+ Dict[int, str]: A mapping from PID to executable filename.
172
+ """
173
+ global _pid_exe_cache
174
+ now = time.time()
175
+ if force_refresh:
176
+ _pid_exe_cache.clear()
177
+ # If cache empty or expired, rebuild
178
+ if not _pid_exe_cache or any(
179
+ now - ts > _cache_ttl_seconds for _, ts in _pid_exe_cache.values()
180
+ ):
181
+ pid_map = _build_pid_to_exe_map()
182
+ _pid_exe_cache = {pid: (exe, now) for pid, exe in pid_map.items()}
183
+ return {pid: exe for pid, (exe, ts) in _pid_exe_cache.items()}
184
+
185
+
186
+ def find_hwnds_by_title(title: str) -> typing.List[int]:
187
+ """Return visible top-level window handles matching a title query.
188
+
189
+ Uses difflib.get_close_matches to sort candidate titles by similarity.
190
+
191
+ Args:
192
+ title (str): Window title query.
193
+
194
+ Returns:
195
+ List[int]: Matching window handles sorted by closest title matches.
196
+ """
197
+ query = title.strip()
198
+ if not query:
199
+ return []
200
+
201
+ title_to_hwnds: typing.Dict[str, typing.List[int]] = {}
202
+
203
+ def _cb(hwnd, _):
204
+ try:
205
+ if not win32gui.IsWindowVisible(hwnd):
206
+ return True
207
+ t = win32gui.GetWindowText(hwnd).strip()
208
+ if not t:
209
+ return True
210
+ title_to_hwnds.setdefault(t, []).append(hwnd)
211
+ except Exception:
212
+ pass
213
+ return True
214
+
215
+ win32gui.EnumWindows(_cb, None)
216
+ if not title_to_hwnds:
217
+ return []
218
+
219
+ ordered_titles = difflib.get_close_matches(
220
+ query, list(title_to_hwnds.keys()), n=len(title_to_hwnds), cutoff=0.0
221
+ )
222
+
223
+ # Keep exact-match titles first when present.
224
+ if query in title_to_hwnds:
225
+ ordered_titles = [query] + [t for t in ordered_titles if t != query]
226
+
227
+ ordered_hwnds: typing.List[int] = []
228
+ for matched_title in ordered_titles:
229
+ ordered_hwnds.extend(title_to_hwnds.get(matched_title, []))
230
+ return ordered_hwnds
231
+
232
+
233
+ def find_hwnds_by_exe(exe_name: str) -> typing.List[int]:
234
+ """Return window handles for processes matching an executable name.
235
+
236
+ This normalizes names by stripping paths and extensions so callers can pass
237
+ either 'chrome', 'chrome.exe', or a full path.
238
+
239
+ Args:
240
+ exe_name (str): Executable name or path to match.
241
+
242
+ Returns:
243
+ List[int]: List of matching window handles.
244
+ """
245
+
246
+ def _norm(n: str) -> str:
247
+ if not n:
248
+ return ""
249
+ # if a bytes-like got here, convert
250
+ try:
251
+ n = str(n)
252
+ except Exception:
253
+ pass
254
+ base = os.path.basename(n)
255
+ name, _ext = os.path.splitext(base)
256
+ return name.lower()
257
+
258
+ pid_map = get_pid_to_exe_map()
259
+ target_pids = {pid for pid, exe in pid_map.items() if _norm(exe) == _norm(exe_name)}
260
+ if not target_pids:
261
+ return []
262
+ matches: typing.List[int] = []
263
+ def _cb(hwnd, _):
264
+ try:
265
+ if not win32gui.IsWindowVisible(hwnd):
266
+ return True
267
+ _, pid = win32process.GetWindowThreadProcessId(hwnd)
268
+ if pid in target_pids:
269
+ matches.append(hwnd)
270
+ except Exception:
271
+ pass
272
+ return True
273
+
274
+ win32gui.EnumWindows(_cb, None)
275
+ return matches