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
|